关于编码:如何做好防御性编码

41次阅读

共计 5993 个字符,预计需要花费 15 分钟才能阅读完成。

简介:相似于“防御性驾驶”对驾驶平安的重要性,防御性编码目标概括起来就一条:将代码品质问题毁灭于萌芽。要做到“防御性编码”,就要求咱们充分认识到代码品质的严肃性,也就是“一旦你感觉这个中央可能出问题,那根本它就会(在某个时刻)出问题”。当然,理论状况比这个更严厉。因为大家的编码教训和格调差别,导致大家的意识边界是大小不一的,那些埋伏在意识边界之外的“危险”更加荫蔽和不可推敲。在意识层面上,咱们当然要摒弃“想当然”和“差不多”的思维,庄重评估这些问题产生的可能性,认真对待这些危险。但如若话题止步于此,那其实还是不足执行层面的指导意义的,激不起半点“涟漪”的。这个文章目标也更多是关注到“实操层面”的疏导

作者 | 字白
起源 | 阿里开发者公众号

一 防御性编码的意义

相似于“防御性驾驶”对驾驶平安的重要性,防御性编码目标概括起来就一条:将代码品质问题毁灭于萌芽。要做到“防御性编码”,就要求咱们充分认识到代码品质的严肃性,也就是“一旦你感觉这个中央可能出问题,那根本它就会(在某个时刻)出问题”。当然,理论状况比这个更严厉。因为大家的编码教训和格调差别,导致大家的意识边界是大小不一的,那些埋伏在意识边界之外的“危险”更加荫蔽和不可推敲。

在意识层面上,咱们当然要摒弃“想当然”和“差不多”的思维,庄重评估这些问题产生的可能性,认真对待这些危险。但如若话题止步于此,那其实还是不足执行层面的指导意义的,激不起半点“涟漪”的。

这个文章目标也更多是关注到“实操层面”的疏导。

二 如何防御性编码?

以下需关注的具体方面更多来自于我的习惯和察看,并且对立用伪代码作问题示例。

欢送大家把本人的“防御性编码心得”在评论区分享进去。

1 并发抵触问题

这个问题在理论我的项目中,被谬误地漠视的比例相当高。它的外在表现形式形形色色,但关键点是:“当你的代码被并发调用时,它会怎么体现?”

咱们心里要有个运行时的世界观,代码运行的 Context 是这样的:多线程 -> 多过程 -> 多机器 -> 多集群。咱们编码时,要充分考虑代码在上述世界观多点并发的可能性,及相应的潜在结果。

举几个具体的问题例子):

存在共享变量 或者 数据。(不限于堆内存,也可能是缓存、DB、文件等)

例子 1:

  • 有线程 A 和线程 B 两个线程,须要更新「同一条」数据,会产生这样的场景:
  • 1、线程 A 更新数据库(X = 1)
  • 2、线程 B 更新数据库(X = 2)
  • 3、线程 B 更新缓存(X = 2)
  • 4、线程 A 更新缓存(X = 1)
  • 最终 X 的值在缓存中是 1,在数据库中是 2,产生不统一。

例子 2:

// 某个 Spring singleton Bean 'aService' 存在一个调用起源标记,记录调用起源是 HSF 还是 HTTP。// 先 记录起源标记。aService.setSource(source);
// 再联合 source 执行其余逻辑。例如将下面记录的 source 和 其余参数 插入数据库.
aService.doSomethings(params);

如果这个代码被 HSF 和 HTTP 同时调用就会产生问题。

例子 3 :

  • 在一个零碎中,有两个价格类型 small 和 large,业务逻辑要求 small <= large,且 small 和 large 有 2 个入口能够别离批改。
  • 目前计划是:对要扭转的 small 或 large,减少下面大小关系校验,不通过则拦挡,例如 改变 small 的入口上,校验改后的 small <= 零碎里的 large,不通过则不容许批改。
  • 如果,最新需要要求:批改 large 的入口持续拦挡,但批改 small 的入口不再拦挡,而是发现如果改后 small > 零碎的 large,则将 零碎 large = 改后的 small+0.1,让 束缚关系持续成立。这种改法有问题吗?

答案:这种改法会有问题。即 small 这个价格类型存有两个链路同时批改,也是一种并发抵触问题。

举个具体例子:

  • 初始时,零碎的 small = 2; large = 2;
  • 批改 large 链路 1:筹备将 large 改为 3,查看规定 3(改后 large) >= 2(零碎 small) 通过。筹备写入新的 large (3)。
  • 批改 small 链路 2:筹备降 small 改为 4, 发现 4(改后 small)> 2(零碎 large) 不合乎规定,则 筹备 主动批改 large = 4(改后 small)+ 0.1 = 4.1。筹备写入 改后 small = 4,主动改后 large = 4.1;
  • 如果 链路 2 最终先实现写入,链路 1 再实现写入。则 链路 2 写入的 large=4.1 会被链路 1 写入的 large=3 笼罩。最终零碎 large =3,而 零碎 small = 4;毁坏了最后的 small <= large 的束缚。
  • 未思考集群并发
// 在短信发送服务中,管制对用户的发送频率
timestamp = rateLimitService.getMsgTimestamp(userId);
if(timestamp == null){rateLimitService.putMsgTimestamp(userId, now);
  sendMsg(msg);
}else if(timestamp - now > 1 hour){rateLimitService.putMsgTimestamp(userId, now);
  sendMsg(msg);
}

这个例子在单机环境执行时没有问题,但线上集群多节点的话,那发送频率的管制就不对了。

  • 非原子操作问题。
// 先查问是否存在指标记录
resultList = dbRepo.list(query);
// 有后果就更新,没有就插入
if(resultList.size() > 0 ){dbRepo.update(xxxx);
} else {dbRepo.insert(xxxx);
}

如果这个代码被多个 request 同时执行也会产生问题。

  • 谬误的产生并发

单个工作周期性的触发,原本不会有并发问题。
但因单次执行工夫变长,导致先后两次执行工夫呈现重叠。

2 事务问题

对于先 A 再 B 后 C 的这类组合操作,要认真思考保障一致性的必要性,做好是否做事务保障的评估。

事务即要求:对一组的 operation combo,要保障好执行程序,保障好 context 的一致性,保障好后果的一致性。

数据库事务。产生概率不高,大多会被动预防。

这个问题产生概率倒不高,也比拟容易解决。

但要留神,事务执行耗时不要太久,以及防止死锁问题产生。

  • 上下文一致性问题。

以上传并解决 Excel 文件为例,如果实现分为 2 步:

1、前端调用后端 API,上传文件到 Server 的某个长期目录。

2、前端 在上传实现时,调用后端另一个 API,告诉 后端解决此文件。

这个例子在集群环境中就会呈现概率性胜利或失败的状况,集群节点数量越多,失败概率越高。这是因为 前端的前后两次申请调用到了不同节点上,执行上下文呈现了不统一。

  • 程序一致性问题。

常见的,例如对于 ECS 运行状态的时序音讯,如果上游消费者不是程序生产,而是并行生产,就可能导致最终记录的状态 与理论不符。

3 分布式锁问题

分布式锁日常也常常用到,在应用细节上存在一些容易疏忽的盲点。

  • 获取锁

1、是阻塞式期待锁,还是等不到锁重试,还是等不到锁间接返回。

这个层面次要考量点,这个调用链路对工夫和成功率要求是什么。

例如,上游是用户操作,那必定不能阻塞在等锁那里太久;

2、锁的 key 设计很要害。

正当设计 lock key,可能升高锁碰撞的概率。

例如,你的 lock 是加在一个 BU 层面上,还是加到某个人身上,那抵触概率显然差异很大。

3、对于 长久锁,在循环执行业务逻辑时,要做好锁的状态查看。

RLock lock = redisson.getLock(lock);
lock.lock(-1L, TimeUnit.MINUTES);
// 获取到锁就长久占有,防止重复切换
while(!isStopped){if( lock.isHeldByCurrentThread() ){// do some work}else{// try to acquire lock again.}
     SleepUtil.sleep(loopInterval, TimeUnit.MINUTES);
}

4、能用本地锁 不必全局锁。

  • 锁超时

1、正当设置锁的 TTL,联合本人业务场景做取舍

例如,加锁之后执行大量数据的 batch 计算的场景。

如果锁 TTL 太长,那计算被异常中断(如机器重启)时,这个长 TTL 内是无奈被其余节点 / 线程获取到执行权限的;但如果 TTL 设置太短,那可能还没等执行实现,锁就被意外抢走了。

2、留神 watchDog 机制

像 Redisson 之类的会有锁的 watchdog,超过设置或默认的工夫,锁就被偷偷开释了。

  • 开释锁

1、非必要状况下,防止强行开释锁,要查看锁的持有人是否是本人。

2、对于没有 TTL 的锁,要思考极其状况下(过程被强制杀死、机器重启)的锁状态治理。否则意外一旦呈现,锁就永远失落了。

4 缓存问题

  • 缓存穿透问题

缓存和数据库都没有的数据,但被大量申请,导致 DB 压力过大。

常见的解决形式:对空值也进行缓存,但 TTL 设置绝对较短。

  • 缓存击穿问题

个别是缓存的热点 key 产生过期生效,此时大量申请透过缓存 击中 DB,导致 DB 压力过大。

常见解决形式:缓存查问 miss 时,设置个互斥锁,只容许一个 request 实在申请 DB 和重写缓存,防止大量申请涌入。

  • 缓存雪崩问题

缓存中的大量数据在较短的时间段内集中过期。个别产生在流量一波波来,缓存创立工夫和 TTL 很靠近。

常见解决方案:在 TTL 设置上不是一刀切,而是在一个正当范畴内随机浮动,防止缓存集中生效。

  • 缓存的一致性

个别状况下,一致性要求不会十分严格。但如果须要强一致性保障时,要思考缓存和 DB 之间的数据强一致性。

一种可能的计划:只在写 DB 时才写缓存,读 DB 操作不写缓存。DB 和缓存的写操作要加锁,防止并发问题。具体流程如下:

当写 DB 申请产生时:

1、删除 缓存。此时读操作缓存会 miss,读取到 DB 中的老值。

2、写入 DB。此时读操作缓存会 miss,读取到 DB 中的新值。

3、写入缓存。此时读操作缓存会 hit,读取到缓存中的新值(与 DB 新值统一)。

须要留神的是:

1、缓存针对数据库所有的数据记录,可能导致缓存空间占用高,理论利用率却不高。

2、如果某个缓存 key 是热点,或者 流量比拟大,只管缓存“删除 - 重写入”距离短,仍然可能会引发 缓存击穿问题。

3、如果缓存写入失败,须要有相应的弥补机制再写入,且需关注 弥补写入与其余失常写入的抵触和时序问题。

  • 缓存命中率

这个自身不是问题,但命中率低阐明缓存的设计或应用存在问题,须要从新设计。

  • 热点 key 问题

如果特定缓存节点 CPU 使用率远高于其余节点,阐明可能存在热点 key。这个时候须要正当对缓存 key 做拆分,将流量进一步打散。

5 失败解决问题

这类问题虽属于低级问题,但往往比拟荫蔽。在异样产生时,抉择相应解决 action 时,咱们要头脑十分苏醒。

  • 失败解决

可能的解决形式:

1、failover。失败立刻重试。

2、failback。记录失败,后置解决。

3、failfast。间接失败,返回异样。

4、failsafe。疏忽失败,持续流程。

这里不在于抉择那种解决形式,而是要“头脑清醒”的联合本人场景需要做出抉择。

  • 留神默认值

一些状况下,咱们会初始化时设定一些默认值、默认状态等,对于这些状况要充分考虑异样产生时是否存在危险。

例如,在最开始时,代码里配置了过后的开城信息,但这个状态并没有跟业务操作流程买通,也就是没有方法做到及时更新。

那随着工夫倒退,开发了新的城市,那就可能产生问题。

6 switch 配置问题

  • 分批推送的工夫距离

switch 公布时,不同批次会有工夫距离,大部分场景下都能够容忍这个工夫距离。但个别情况下,可能引发诸如数据不统一等问题。

再应用 switch 时须要对这个问题做提前思考,若不能容忍这种状况,那须要更换其余计划。

  • 内存值与长久值

switch 的逻辑是这样:

1、switch 会默认记录代码中的默认值。此时并不是 长久值。

2、当在代码中批改默认值时,switch 平台也会显示代码默认值。此时也并不是 长久值。

3、只有在 switch 平台批改值并推送胜利,swith 平台会保留长久值。

4、switch 保留长久值之后,不论代码批改默认值还是去掉 @AppSwitch 配置,长久值都是存在的。

如果你看到 switch 平台上展现了开关值,认为曾经长久化,而后在代码里就把默认值删掉,此时也可能导致故障。

  • 代码重构注意事项

做代码构造重构时,如果没有指定 switch 的 namespace,会导致你推送过的长久化开关生效,进而引发重大的线上故障。

对于利用级服务发现与接口级服务发现的区别和 dubbo 生态的解决方案,本文中不多赘述,能够参考刘军前辈写的文章文章《Dubbo 迈出云原生重要一步 利用级服务发现解析》

简略来说,利用级服务发现须要开发者关怀接口之外还要关怀利用名,注册核心的冗余信息较少;接口级服务发现开发者只须要引入接口名,但注册核心的冗余信息较多。

  • 正当应用,防止滥用

switch 提供了简略易用的配置化能力,但不要把应该失常编码要思考和解决的问题,丢到 switch 上做开关。否则,最初开关一大堆,保护越发艰难,就暗藏了危险。

7 重大危险评估和处理

针对一个需要开发,咱们须要评估危险及咱们的承受能力。次要目标是 预防重大故障的产生,而不是要预防所有 Bug。

对于危险处理,也没有一个固定的规范。我倡议是联合业务场景,评估危险概率和潜在问题的重大水平,最初来制订相应的解决方案。例如,如果发现有资损危险,那要采取所有伎俩把破绽堵上;但如果只是小概率的漏掉钉钉告诉,那减少相应的告警即可。

咱们如何评估 重大危险呢?我倡议分这么几个环节做评估:

1、梳理 要害的业务流。

2、梳理 每个业务流的关键环节

3、梳理 每个关键环节的要害逻辑 和 要害上下游。

4、联合本人场景,假设 要害逻辑 和 要害上下游 呈现极其问题。例如 网络挂掉、机器重启、高并发降临、缓存挂掉等。

这里须要强调一点,并非所有模块都须要假设十分极其的状况,要联合本人理论业务要求、历史危险等 来综合判断。

再举个例子:

假如,有一个用户资金转账零碎,用户能够通过 App 进行跨行转账操作。

那这个零碎就要思考到 转账超时、转账失败等场景。同时还要思考 转账超时 或 失败时,是 fail-fast 好,还是 fail-over 好?

此外,还须要思考到 App 端的用户交互设计,如果遭逢网络中断或超时,且用户看不到任何问题提醒,那用户很可能再次发动转账尝试,最初转了两笔的钱。

这个评估过程看上去有点简短,但其实对于理解本人零碎和需要细节的人来讲,应该是很容易做到的。如果做不到那就只能增强细节的了解和学习了。

三 最初

以研发同学为核心,向内看:需继续晋升防御性编码的意识和实操能力;向外看:外部环境须要尽可能提供与之匹配的环境。

例如,在面临有紧急 DeadLine 的需要时,防御性编码的执行残缺度就会受到肯定影响。

再次欢送大家把本人的心得留言。

原文链接
本文为阿里云原创内容,未经容许不得转载。

正文完
 0