作者|子白1、防御性编码的含义类似于“防御性驾驶”对行车安全的重要性。防御性编码的目的可以概括为一个:将代码质量问题消灭在萌芽状态。要做到“防御性编码”,就要求我们充分认识到代码质量的严重性,即“一旦你认为这个地方可能会出错,它就基本上会出错(在某个点上)”。当然,实际情况比这更严重。由于每个人编码经验和风格的差异,每个人的意识边界大小不一,潜伏在意识边界之外的“危险”更加隐蔽和难以预料。在意识层面,我们当然要摒弃“想当然”“差不多”的思维,认真评估这些问题发生的可能性,认真对待这些风险。但如果话题就此打住,仍然缺乏执行层面的指导意义,激不起任何“涟漪”。这篇文章的目的也是为了更加注重“实践层面”的指导。2.如何防御性编码?下面具体需要注意的方面更多来自于我的习惯和观察,我以伪代码为例。欢迎大家在评论区分享你的“防御性编码经验”。1.实际项目中并发冲突问题比较多。它的外在表现是多种多样的,但关键是:“当你的代码被并发调用时,它会如何表现?”我们心中一定有运行时世界观,代码运行的上下文是这样的:多线程->多进程->多机->多集群。我们在编码的时候,要充分考虑上述世界观中多点并发编码的可能性,以及相应的潜在后果。举几个具体问题的例子):有共享变量或数据。(不限于堆内存,还可能是缓存、DB、文件等)例1:有两个线程,线程A和线程B,需要更新“同一块”数据。这样的场景会发生:1)。线程A更新数据库(X=1)2)线程B更新数据库(X=2)3)线程B更新缓存(X=2)4)线程A更新缓存(X=1)X在缓存中为1,在数据库中为1,为2,出现不一致。示例2://一个Springsingletonbean'aService'有一个调用源标记,它记录了调用源是HSF还是HTTP。//先记录源标签。aService.setSource(source);//结合source执行其他逻辑。比如将上面记录的source和其他参数插入到数据库中。aService.doSomethings(参数);示例3:某系统中有small和large两种价格类型,业务逻辑要求small<=large,small和large有2个条目,可以单独修改。目前的方案是:对于要改变的small或者large,在上面加上size关系校验,如果不通过则拦截,比如改变small的入口,改变的small<=large在系统会进行验证,不通过则不允许修改。比如最新需求要求:修改大入口继续拦截,但是修改小入口不再拦截,但是发现如果修改小>系统大,则设置系统大=修改小+0.1,使约束关系继续建立。这个修改有问题吗?答:这样修改会有问题。即smallprice类型有两个链接同时修改,也是并发冲突问题。举一个具体的例子:最初,系统的small=2;大=2;修改大链接1:准备大改3,检查规则3(修改后大)>=2(系统小)通过。准备写入newlarge(3)。修改小环节2:准备小改4,发现4(修改后小)>2(系统大)不符合规则,则准备自动修改大=4(修改后小)+0.1=4.1.准备改完写small=4,自动改成large=4.1;如果链路2先写完,则链路1先写完。然后链接2写的large=4.1会被链接1写的large=3覆盖,最终系统large=3,系统small=4;打破原来的small<=large约束。不考虑集群并发。//在短信发送服务中,控制发送给用户的频率sendMsg(msg);}elseif(timestamp-now>1hour){rateLimitService.putMsgTimestamp(userId,now);sendMsg(msg);}非原子操作问题。//先检查目标记录是否存在resultList=dbRepo.list(query);//有结果则更新,没有则插入if(resultList.size()>0){dbRepo.update(xxxx);}否则{dbRepo.insert(xxxx);}并发错误,周期性触发单个任务,不会有并发问题。但是由于单次执行的执行时间较长,连续两次执行的执行时间会重叠。2、事务问题对于A、B、C这样的组合操作,需要慎重考虑保证一致性的必要性,做好是否做事务保护的评估。事务是需求:对于一组操作combo,需要保证执行的顺序,上下文的一致性,结果的一致性。数据库事务。发生概率不高,大部分都会主动预防。这个问题出现的概率不高,解决起来也比较容易。但是需要注意的是事务执行时间不能太长,避免出现死锁问题。上下文一致性问题。以上传处理Excel文件为例,如果实现分为2步:1)前端调用后端API,将文件上传到服务器的一个临时目录。2)上传完成后,前端调用后端的另一个API,通知后端处理文件。在此示例中,集群环境中将有概率成功或失败。集群节点数越多,故障概率越高。这是因为前端前后的两次请求调用的是不同的节点,执行上下文不一致。顺序一致性问题。通常,比如ECS运行状态的定时消息,如果下游consumer不是顺序消费,而是并行消费,最终记录的状态可能与实际状态不符。3、分布式锁问题分布式锁在日常生活中经常使用,在使用细节上有一些容易被忽视的盲点。获取锁1)是阻塞等待锁,还是等待锁重试,还是等待锁直接返回。这一层主要考虑调用链路的时间和成功率要求是多少。比如上游是用户操作,一定不能因为等待锁而阻塞太久;2)锁的钥匙设计很关键。锁匙设计合理,可减少撞锁机率。比如你的锁是加到一个BU级还是给别人,冲突的概率显然是有很大区别的。3)对于持久锁,在循环执行业务逻辑时,检查锁的状态。RLocklock=redisson.getLock(锁);lock.lock(-1L,TimeUnit.MINUTES);//一旦获得锁,将永久持有,避免重复切换while(!isStopped){if(lock.isHeldByCurrentThread()){//做一些工作}else{//尝试再次获得锁。}SleepUtil.sleep(loopInterval,TimeUnit.MINUTES);}4)局部锁可以不用全局锁。锁超时1)合理设置锁的TTL,根据自己的业务场景进行取舍。比如对大量数据加锁后进行批量计算的场景。如果锁TTL过长,当计算异常中断(如机器重启)时,其他节点/线程无法在这个长TTL内获得执行权限;但如果TTL设置太短,可能会等不及执行完毕。锁不小心被抢了。2)注意看门狗机制,比如Redisson,有锁看门狗。如果超过设定或默认时间,锁将被秘密释放。解锁1)如非必要,避免强行解锁,检查锁持有人是否为本人。2)对于没有TTL的锁,要考虑极端情况下(进程被强行杀掉,机器重启)的锁状态管理。否则,一旦发生意外,锁将永远丢失。4、缓存问题缓存穿透问题缓存和数据库都没有的数据,却被大量请求,导致DB压力过大。一个常见的解决方案:缓存空值,但是TTL设置比较短。缓存击穿的问题一般是缓存的hotkey过期失效了。这时候大量的请求通过缓存打到DB上,导致DB压力过大。常见解决方案:当缓存查询未命中时,设置互斥锁,只允许一次请求真正请求DB,重写缓存,避免大量请求涌入。缓存雪崩问题缓存中的大量数据在短时间内集中过期。它通常发生在流量一波接一波地到来时,缓存创建时间非常接近TTL。常见解决方案:TTL设置不是一刀切,而是在合理范围内随机浮动,避免集中缓存失效。缓存一致性一般来说,一致性要求不是很严格。但是,如果需要强一致性保证,就要考虑缓存和DB之间的数据强一致性。一种可能的解决方案:只在写DB的时候写缓存,读DB的时候不写缓存。DB和cache的写操作要加锁,避免并发问题。具体过程如下:发生写DB请求时:1)删除缓存。这时候读操作缓存会miss,读取的是DB中的旧值。2)写入数据库。这时读操作缓存会miss,读取DB中的新值。3)写缓存。此时读操作缓存会命中并读取缓存中的新值(与DB的新值一致)。需要注意的是:1)缓存是针对数据库中的所有数据记录,这可能会导致缓存空间占用率高,但实际利用率并不高。2)如果某个缓存键是热点,或者流量比较大,即使缓存“删除-重写”的时间间隔很短,也有可能造成缓存崩溃。3)如果缓存写入失败,需要相应的补偿机制重新写入,需要注意补偿写入与其他正常写入的冲突和时序问题。缓存命中率本身没有问题,但是命中率低说明缓存的设计或使用有问题,需要重新设计。Hotkey问题如果某个缓存节点的CPU使用率远高于其他节点,则可能存在hotkey。这时候就需要对缓存key进行合理的拆分,进一步打散流量。5、处理问题失败这类问题虽然是低级问题,但往往是隐蔽的。当异常发生时,我们在选择相应的动作时一定要非常清醒。故障处理可能的处理方法:1)故障转移。失败立即重试。2)故障恢复。录制失败,后期处理。3)快速失败。直接失败,返回异常。4)万无一失。忽略失败并继续该过程。这里不是选择那种处理方式,而是要以“清醒的头脑”,根据自己的场景需求做出选择。注意默认值在某些情况下,我们会在初始化时设置一些默认值、默认状态等。对于这些情况,我们必须充分考虑异常发生时是否存在风险。比如最开始的时候代码配置了当时的开诚信息,但是这个状态没有和业务操作流程对接,也就是没办法及时更新。随着时间的发展和新城市的发展,可能会出现问题。6.开关配置问题批量推送的时间间隔当开关发布时,不同的批次之间会有一个时间间隔,这个时间间隔在大多数场景下是可以容忍的。但在个别情况下,可能会出现数据不一致等问题。再次使用交换机时需要提前考虑这个问题。如果不能容忍这种情况,则需要更换其他解决方案。内存值和持久值switch的逻辑如下:1)switch默认会在代码中记录默认值。此时它不是持久值。2)当代码中修改默认值时,switch平台也会显示代码的默认值。此时,它不是一个持久值。3)只有在switch平台上修改并推送成功后,switch平台才会保存持久化值。4)交换机保存持久值后,无论代码修改默认值还是去掉@AppSwitch配置,持久值依然存在。如果看到switch平台上显示的switch值,就认为已经持久化了,然后在代码中删除default值,也有可能导致失败。代码重构注意事项重构代码结构时,如果不指定开关的命名空间,会导致你推送的持久化开关失效,导致上线严重失败。关于应用级服务发现和接口级服务发现的区别以及dubbo生态的解决方案,本文不再赘述。可以参考刘军前辈写的文章《Dubbo 迈出云原生重要一步 应用级服务发现解析》简单来说,应用级服务发现需要开发者关心接口,除了应用名,注册中心冗余信息少;接口级服务发现的开发者只需要引入接口名称,而注册中心的冗余信息较多。合理使用,避免滥用switch提供了简单易用的配置能力,但不要把正常编码应该考虑和处理的问题当成switch扔到switch上。否则到最后开关会很多,维护起来会比较困难,会暗藏风险。7.重大风险评估与处置制定需求时,需要评估风险及其承受能力。主要目的是为了防止重大故障,而不是为了防止所有的错误。关于风险管理,没有固定的标准。我的建议是结合业务场景,评估风险概率和潜在问题的严重程度,最后制定相应的解决方案。比如,发现存在资产流失风险,必须想方设法堵住漏洞;但如果错过钉钉通知的概率很小,增加相应的告警即可。我们如何评估重大风险?我建议按以下步骤进行评估:1)梳理关键业务流程。2)梳理各业务流程的重点环节。3)梳理各关键环节的关键逻辑和关键上下游。4)结合自己的场景,假设关键逻辑和关键上下游出现极端问题。比如网络掉了,机器重启了,高并发来了,缓存掉了等等。这里需要强调的是,并不是所有的模块都需要假设非常极端的情况,要综合判断他们的实际业务需求和历史风险。又如:假设有一个用户转账系统,用户可以通过App进行跨行转账操作。那么这个系统就要考虑到转账超时、转账失败等场景。同时,当传输超时或失败时,fail-fast或fail-over哪个更好?此外,还需要考虑App端的用户交互设计。如果网络中断或超时,用户看不到任何问题提示,用户很可能再次发起转账尝试,最终转了两笔钱。这个评估过程可能看起来有点冗长,但对于那些了解自己系统和需求细节的人来说应该很容易。如果做不到,只能加强对细节的理解和学习。3、最后,关注研发生,向内看:要不断提高防御性编码的意识和实践能力;向外看:外部环境需要尽可能提供与之相匹配的环境。例如,当面临一个紧急的DeadLine需求时,防御性编码的执行完整性会受到一定程度的影响。
