当前位置: 首页 > 科技观察

很多宕机事故都归咎于最初的设计失败……

时间:2023-03-15 00:16:15 科技观察

2015年5月,杭州市萧山区某地光缆被剪断,某公司的支付软件受到影响。用户多次登录无法使用。一时间#XXX炒了#成为微博热词;2021年7月,某视频网站深夜宕机,各种产品的所有功能似乎都崩溃了,直到次日凌晨才恢复服务。这两个故事引发了人们对公司技??术实力的质疑和误解,影响深远……从这两个故事可以看出,对故障场景考虑不足对公司声誉造成的损害有多大.从程序员个人的角度来看,面向失败的设计对个人的影响也是巨大的。企业发生事故的责任最终会落在程序员个人身上,而事故往往会消耗组织对个人的信任,直接或间接影响到个人。发展。在字节跳动,事故对个人的影响并不算太大,但在其他一些公司,一场事故往往意味着程序员“一年无所事事”。不同年代的程序员区别在哪里?对于这个问题,我的理解是,除了架构设计能力、项目管理能力、技术规划能力、技术领导力之外,面向故障的设计能力也是极其重要的一环。业务开发的新同学有时可能会很自信,认为自己写的代码和老手写的代码没什么区别。其实写业务代码和正常流程并没有太大的区别,但是对于异常、边界、不确定性的处理确实体现了一个程序员的功力。老兵往往经过长期的训练,已经形成了多种多样的肌肉记忆。遇到具体问题,他们会举一反三,脑子里想出很多面向失败的设计点,从而写出高可用的业务代码。如何学习面向失败设计的方法论,慢慢形成自己独特的肌肉记忆,才是新手变老手的必经之路。基于这样的考虑,我写下这篇文章,总结自己多年来的一些经验和教训,希望能够启发更多的老手分享经验,互相学习,共同进步。首先,在道家层面,谈谈面向失败设计的世界观。1.失败无处不在。理想状态是机器硬件永不过期,系统软件永不过期,流量永远在预期范围内,自己写的代码没有bug,产品经理永远不会改变需求,但现实经常给你老拳,给你社会的重拳:硬件总会在某个时间点失效,软件总会在某个时间点跟不上时代潮流,流量总会突然暴增当你没有想到的时候——即使你在婚礼上,没有程序员如果你不写bug,产品经理不仅每天都在更改需求,甚至还会给你自相矛盾或自相矛盾的需求有逻辑漏洞。无论是在传统软件时代,还是在互联网和云时代,系统终究会在某个时间点失效。面向失败的设计不是为了消除失败,而是减少甚至消除失败的影响,守护企业和个人的腰包。2.唯一不变的就是变化。不仅失败无处不在,变化无处不在。1)Don'twritetodeath——yourPMwereborntochangerequirements“Don'twritetodeath,yourPMisborntochangerequirements”,这句话是我一个产品经理的签名,给我赢得了一个很多心。总是对硬编码感到不安,根据墨菲定律,你认为一个领域或功能越不会改变,它就越会改变。因此,多配置,少硬编码,可以让你在产品需要变更的时候快速响应,打动别人,也可以让你在出现故障时,有更多的手段快速恢复。2)可变性的隔离——程序员天生就是响应软件变化的,如果系统软件永远不变,我们还需要设计模式吗?还需要面向对象?面向过程不是又快又好吗?然而,程序员对于永不改变的系统软件有什么用呢?抖音已经这么厉害了,字节不用换什么都能赚大钱,难道抖音的程序员都下岗吗?好像不是这样的。设计模式是前人总结出来的应对变化的利器。23种设计模式,用一个词来概括:隔离可变性。无论是创造模型、结构模型还是行为模型,设计的目的都是在设计模型的笼子里保持变化。3)正则回归——进化过程中的功能退化正则回归也是处理失败的重要原则。互联网的迭代实在是太快了。传统软件往往以年、月为周期进行迭代,而互联网往往以几周甚至几天为周期进行迭代。每一天,系统的功能都可能在进化过程中退化。快速迭代不仅很快将业务代码腐化成一堆狗屎,还会使内部逻辑越来越臃肿,甚至相互冲突。总有一天,原本运行良好没有bug的代码会成为事故的导火索。3、警惕代码的世界警惕代码的世界,否则总有一天你会经历血与泪。1)不要轻信对方的“鬼话”。对合作伙伴给你的所有接口和解决方案都持怀疑态度,不要相信合作伙伴任何未经你验证的断言。实践是检验真理的唯一标准,永远对世界持怀疑态度是工程师的核心品质。不要在失败后和伴侣互相指责时后悔。前期多做验证,保护你和他,保护你们之间的塑料友谊。2)不要相信代码注释。一行错误的代码注释把我从阿里带到了字节,是我亲身经历的血泪教训。错误的代码注释比没有注释更糟糕。不要用错误的评论来埋葬后代。救救孩子。3)不要相信函数输入NPE(NullPointerException空指针异常)可能是程序员在职业生涯中遇到最多的错误。这是相当混乱的,因为程序员知道需要检查函数参数。之所以会出现这样的结果,是因为线上生产环境遇到的场景远比代码问题复杂。这其实就是工业界和学术界的区别。学术界的问题是一定的,工业界的问题则不然。定。即使上游传递的参数是你认为极其可靠的系统,即使你扫描程序上下文确认不会有空参数,也最好做一些防御性设计,因为一个可靠的系统会返回你非-compliantParameters,目前没有空参数的代码,将来有一天会被改得面目全非。4)不要相信基础设施连支付宝都会崩溃,即使是可用性69的系统全年也有31秒的停机时间。不要轻信基础设施,做好灾备和混沌工程,才能每晚睡个好觉,避免被闹钟吵醒。4.设计原则1)简单的解决方案是最优雅的。如果你设计的技术方案没有太多花里胡哨的东西,整体透着简约的美感,那么你可能离成功很近。简洁的解决方案意味着更少的理解成本、更少的维护成本和更好的可扩展性。如果你的计划花里胡哨,看起来复杂严谨,那么也许你离让自己和他人头疼不远了。操作凶猛如虎,月薪2500元。当然,并不是最简洁的方案就是最合适的方案。比如核心交易环节的服务,肯定比数据展示对服务稳定性有更高的要求,所以做更多的高可用设计后,解决方案会更复杂,所以建议在满足稳定性的前提下,选择尽可能简单的方案。2)开闭原则是设计模式的大致轮廓。开闭原则是设计模式的大致轮廓。大多数设计模式都有开闭原则的影子。软件实体应该对扩展开放,对修改关闭。变”实现开闭原则。开闭原则可以使软件实体具有一定的适应性和灵活性,以及??稳定性和连续性。基于开闭原则,许多常见的设计问题都有了答案:①大量if-else狗屎山代码问题大量if-else肯定不符合开闭原则,对于破坏原有代码结构,可以应用工厂+策略设计模式stripif-else,逻辑的添加和修改仅限于工厂模式的子类。②冗长的业务流程处理问题业务流程代码往往非常冗长,如果封装不好,代码阅读和维护都非常困难。可以考虑使用命令+职责链设计模式来封装工作流。封装的好处是整体的工作流程会非常清晰可读,主要流程代码往往可以从几百行减少到不到十行,流程的修改只是简单的断开链接或操作添加链接节点,从而最大限度地减少修改的影响。③历史字段类型修改问题在互联网开发过程中,经常需要修改历史字段的类型。根据开闭原则,我们不应该修改原有字段的类型,而应该增加一个新的字段,以保证上下游环节的保护。最小的影响。④中间篡改对象属性的问题。我们来看一个实际的业务场景。在某些业务请求中,抖音精简版需要和抖音一样处理。把抖音精简版的APPID改成抖音的APPID是最简单的方法,但是这种方法不符合开闭原则。在中间篡改对象的属性会改变程序中对象的语义。总有一天会有意想不到的表现,很多的事故都会因此而起。上升。正确的做法是在上下文中传入一个新的字段,下游处理的每一步都可以选择正确的字段进行正确的处理,而不会被中途篡改的字段所蒙蔽。3)懒惰是程序员最大的美德。懒惰是程序员最大的美德。好的程序员往往不为人知。他们在团队里越多,到处喊叫、到处打火的程序员越多,他们就越有可能是团队的慢性毒药。.为了让自己变懒,做好业务,程序员必须掌握平台化、工具化、自动化这三大招数。平台化,让程序员免于无休止的重复劳动;工具化,将程序员从人工运维和oncall的困境中解救出来;自动化,使程序像流水线一样流畅,从而提高程序员的效率。这三把斧头能挥到什么程度,也反映了程序员能力的高低。通过平台化、工具化和自动化,可以实现标准化和规模化,帮助企业和业务持续发展。其次,在技术层面,我想谈谈如何从组织和流程的角度来设计失败。一、组织1)面向故障设计的工种测试工程师、测试开发工程师、风控&安全合规工程师是开发工程师最可靠的伙伴,也是企业为故障而设立的工种——导向设计。测试工程师是软件质量的守门人。他们是在线质量的守护者,对开发工程师代码的质量和性能负责。测试开发工程师是一项技术软件测试工作。除了做日常的测试工作,他还可以编写一些测试工具和自动化脚本,通过自动化的方式来提高测试的质量和效率。风控与反欺诈工程师负责业务生态,监控业务异常问题,提升业务风控效果。安全合规工程师负责信息安全,可为项目提供合规咨询和信息安全风险评估。2)面向故障设计的组织形式安全生产小组是面向故障设计的一种组织形式。安全生产团队往往是横向技术团队,为规范制定与实施、生产过程控制、事故恢复组织等多个业务团队提供技术支持。负责线上质量,通常在各业务团队中设置系统稳定性领导者,作为接口人有效实施自己开发的系统。结对编程也是面向失败设计的一种组织形式。严格意义上的结对编程需要两个程序员在一台计算机上一起工作。一个人输入代码,而另一个人检查他输入的每一行代码。结对编程允许程序员编写更短的程序、更好的设计和更少的缺陷。同时,结对编程还可以促进知识的传播,让新人快速进步,让老人们在带新人的过程中总结。自身的知识和经验也可以避免相应开发人员请假或辞职导致的工作交接问题。严格意义上的结对编程在互联网行业是极其少见的,也很少有团队会真正去实践,或许是因为在管理者看来,两个人做同样的事情大大增加了人力成本。不过,结对编程的一些思想和概念也值得借鉴。比如我们可以让两个程序员结对做企业主,互相备份,互相review对方的代码,从而在一定程度上获得结对编程的好处。2.过程假设不做面向失败的设计,软件开发过程可能会简化为编码+发布两步。但是成熟企业的发展过程大致是这样的:在需求制定阶段,需要提前做一些合规评估、反作弊评估、安全评估,排除一些潜在的安全合规风险。早期。在编码阶段,设计技术方案时需要考虑止血/降级/回滚措施,组织技术评审和安全技术评审,评估技术方案中存在的安全风险。另外,最好做一些单元测试,这样可以大大提高代码的质量。在测试阶段,需要开发者先做自测,然后让测试工程师参与功能测试,安全工程师做安全检查。针对代码变更可能带来的额外影响,需要做更大规模的回归测试,排除一些意想不到的问题。影响。在发布阶段,需要有灰度发布机制。先放出少量机器,或者只对部分地区的用户灰度。灰度发布后做灰度测试,验证功能正常,再继续分批发布,全量发布。在验证阶段,测试同学可以在发布后进行在线回归,确保在线环境下功能稳定可用。对于大型活动,往往需要组织在线预览或内部用户公开测试。对于意外流量可能导致系统挂掉的风险,可以进行单链路压测和全链路压测。在大型活动开始前,如果条件允许,或者在小范围内进行线上试运行,提前暴露一些风险。在运行阶段,开发者需要做好监控告警和离线数据对账工作。对于项目的效果,可以通过AB测试来量化收益。当出现故障时,需要尽快从故障中快速恢复,将线上损失降到最低,然后再考虑定位故障原因。项目结束或故障排除结束后,需要组织一次有效的回顾,并对过程中存在的问题做一些总结,形成有效的改进方案,并持续跟进改进方案的实施情况3.一些观点1)测试学生重要性怎么强调都不为过。测试工程师是在线质量最重要的守护者。它们的重要性怎么强调都不为过。一个优秀的测试生可以做到以下几点:非黑盒测试,具备理解开发代码的能力,根据代码设计测试用例;设计完整的测试用例,覆盖所有测试场景;编写数据对账脚本,能够进行离线数据对账和实时数据对账;编写自动化测试工具;编写数据一致性监控脚本、资产流失防控工具。2)单元测试最节省时间。编写单元测试用例看似费时,其实是最省时的方法。单元测试确保代码的行为与我们的预期一致,从而节省大量发布、自测、联调、修改代码的返工时间。另外,能够进行单元测试的代码,往往职责更清晰,层次更分块,更合理,也更稳定。3)评审是对接高标准工作的必要途径评审是持续优化组织、对接高标准工作的必要途径。通过PDCA(Plan-Do-Check-Action,戴明圈)这样一个循环,在工作不断完善后,最终形成知识积累,作用于下一步的计划执行,团队执行力越来越强,而个人成为更好的我。4)研发红线是程序员的保护伞。研发红线是企业针对失败设计的有效暴力机器。它由无数零件(标准和物品)组成,冰冷,机械,运行时无法停止,不以个人意志为转移。研发红线强制程序员遵守公司的流程和规范,告诫程序员不要犯低级错误。看似冷酷无情,实则是程序员的一把保护伞。3.技术在技术层面,我想说一下面向故障设计的具体技术细节。但技术细节太多,限于篇幅,这里只列出一些经典技术问题的解决方案。1.将面向故障作为系统设计的一部分。针对突发流量,可实现系统限流、系统过载保护、自适应扩缩容;对于依赖服务超时或错误,需要对依赖系统设置超时时间,对所有依赖系统设置超时时间。梳理强弱依赖,关键时刻降级非核心依赖;对于突发情况,可提前制定应急预案,做好预案演练;对于瞬时大流量,需要敏锐判断系统的极限,做好流量的打散,避免DB和cachehotkeys;针对机房可能出现的问题,做好同城双(多)住和异地多住工作;对于人为错误,可以通过平台化、工具化、自动化的方式减少人为操作;对于单点问题,做冗余设计,减少局部故障对系统的影响;重试失败时要谨慎,避免踩雪崩;故障只能减少,不能消除。做好监控告警、故障演练、攻防演练,锻炼风险应急处置能力。2.分布式锁六层你只看到第二层,你把我当成第一层了。实际上,我在五楼。——芜湖大司马Redis分布式锁有六个级别,来看看你平时用的分布式锁在哪一个级别。分布式锁设计原则:互斥任何时候,只有一个客户端持有锁。非死锁分布式锁本质上是一种基于租约的锁。如果客户端获取到锁后发生异常,一段时间后锁会自动释放,资源不会被锁定。持续的硬件故障或网络异常等外部问题,以及查询慢和自身缺陷等内部因素,都可能导致Redis切换到高可用,replica被提升为新的master。这时候,如果业务对互斥的要求非常高,切换到新的master后,锁需要保持原来的状态。1)Level1redis.SetNX(ctx,key,"1")deferredis.del(ctx,key)使用SetNx命令解决互斥问题,但无法避免死锁。2)Level2redis.SetNX(ctx,key,"1",expiration)deferredis.del(ctx,key)使用lua脚本保证SetNX和Expire的原子性,这样就没有死锁,但是没有一致性。3)Level3redis.SetNX(ctx,key,randomValue,expiration)deferredis.del(ctx,key,randomValue)//下面是delifredis.call("get",KEYS[1])的lua脚本)==ARGV[1]thenreturnredis.call("del",KEYS[1])elsereturn0end分布式锁值设置一个随机数,只删除funcmyFunc()(errCode*constant.ErrorCode){errCode:=DistributedLock(ctx,key,randomValue,LockTime)延迟DelDistributedLock(ctx,key,randomValue)iferrCode!=nil{returnerrCode}//doSomeThing}funcDistributedLock(ctxcontext.Context,key,valuestring,expirationtime.Duration)(errCode*constant.ErrorCode){ok,err:=redis.SetNX(ctx,key,value,expiration)iferr==nil{if!ok{returnconstant.ERR_MISSION_GOT_LOCK}returnnil}//处理超时并在成功的场景,先看下情况超时并成功返回nnil}elseifv!=""{//表示被别人抢了returnconstant.ERR_MISSION_GOT_LOCK}//表示锁没有被别人抢走,再抢一次ok,err=redis.SetNX(ctx,key,value,expiration)iferr!=nil{returnconstant.ERR_CACHE}if!ok{returnconstant.ERR_MISSION_GOT_LOCK}returnnil}//下面是del的lua脚本ifredis.call("get",KEYS[1])==ARGV[1]thenreturnredis.call("del",KEYS[1]])elsereturn0end//如果你的Redis版本已经支持CAD命令,那么上面的lua脚本可以改成如下代码funcDelDistributedLock(ctxcontext.Context,key,valuestring)(errCode*constant.ErrorCode){v,err:=redis.Cad(ctx,key,value)iferr!=nil{returnconstant.ERR_CACHE}returnnil}解决超时和成功的问题,写入超时和成功是零星的,而且经典的灾难性问题依然存在问题是:单点问题,单master有问题,如果有master-slave,那么master-slavereplication的时候就有问题过程有问题;锁过期了,进程没有完成怎么办。5)Level5在锁到期但进程未完成时启动定时器并续租。只能更新当前线程/协程获取的锁。//下面是续租的lua脚本,实现CAS(compareandset)ifredis.call("get",KEYS[1])==ARGV[1]thenreturnredis.call("expire",KEYS[1]],ARGV[2])elsereturn0end//如果你的Redis版本已经支持CAS命令,那么上面的lua脚本可以改成如下代码redis.Cas(ctx,key,value,value)即可保证锁过期的一致性,但不能解决单点问题。同时,你可以发散地想一想,如果续租的方法失败了怎么办?我们如何解决“用于保证高可用的高可用方法的高可用问题”的套娃问题呢?开源类库Redisson使用watchdog的方式在一定程度上解决了锁更新的问题,但是这里我个人建议不要做锁更新。更简洁优雅的方式是延长过期时间,因为我们的分布式锁lock代码块的最大执行时间是可控的(取决于RPC、DB、中间件等调用设置的超时时间),所以我们可以设置超时时间要大于最大执行时间,以保证锁过期时间的一致性简洁性和优雅性。6)Level6Redis的主从同步(复制)是异步的。如果master在向master发送修改数据的请求后突然出现异常,发生高可用切换,buffer中的数据可能无法同步到新的master(原replica),导致数据不一致。如果丢失的数据与分布式锁有关,会导致锁机制出现问题,导致业务异常。介绍一下这个问题的两种解决方案:①使用红锁(RedLock)红锁是Redis的作者提出的一种一致性解决方案。红锁的本质是一个概率问题:如果一个主从Redis在高可用切换时丢锁的概率是k%,那么N个独立的Redis同时丢锁的概率是多少?如果用红锁来实现分布式锁,那么丢锁的概率就是(k%)^N。鉴于Redis极高的稳定性,此时的概率完全可以满足产品的需求。红锁的问题是加锁和解锁有很大的延迟。在集群版或标准版(主从架构)的Redis实例中难以实现。占用资源太多。为了实现红锁,需要创建多个不相关的云Redis实例或者自己搭建Redis。②使用WAIT命令Redis的WAIT命令会阻塞当前客户端,直到该命令之前的所有写命令都从master成功同步到指定数量的replicas。该命令可以以毫秒为单位设置等待超时。加锁后,客户端会等待数据成功同步到副本,再进行其他操作。执行WAIT命令后,如果返回结果为1,则表示同步成功,不用担心数据不一致。与RedLock相比,这种实现方式大大降低了成本。3.热点库存扣秒杀是很常见的面试题。很多面试官上来让面试官设计一个秒杀系统。当然,面试官也是“有经验”的,能很快给出熟记的“标准答案”。不过秒杀还是比较简单的热点库存扣减问题,因为扣减的库存量不大。一个比较典型的热点库存扣款问题是春节期间的红包雨,同一个资金池的上亿人抢红包。春节红包雨介绍两种解决方案:1)第一种方案存在问题:不同桶之间的库存消耗不均衡,可能导致部分用户无法扣减库存,但其他用户可以扣减库存,导致用户投诉。2)方案2小批量、多次分配库存,缓解桶内库存消耗不均的问题。2021年抖音春节红包也是一个很好的技术思路,可以打散用户进入的时间,降低瞬时请求的峰值。3)如何体现面向故障的设计①为什么在桶库存不足时,使用定时任务调度主动分配库存,而不是被动拉动库存?答:因为主动调库存的QPS比被动拉库存低几个数量级。②如何应对超流量?答:流量没有到达DB,分桶,分块。③为什么Redis的总库存池不是由某台master机器来维护,而是使用scheduledtask调度来随机选择机器?答:防单点。编程之美令人叹为观止。好的代码往往结构清晰、含义明确、设计精巧。无论是阅读代码还是编写代码,都能给程序员一种直击心灵的美感,甚至让读者爱不释手。使作者引以为豪,并视之为自己的杰作。但是,为了保留这份美好,我们还是需要做面向失败的设计,充分考虑失败的场景,从而降低失败的概率,死里逃生。本文对面向失败的设计做了一些简单的思考,欢迎讨论、补充、指正。