一 防御性编码的意义
二 如何防御性编码?
1 并发冲突问题
-
存在共享变量 或者 数据。(不限于堆内存,也可能是缓存、DB、文件等)
有线程 A 和线程 B 两个线程,需要更新「同一条」数据,会发生这样的场景:
1. 线程 A 更新数据库(X = 1) 2. 线程 B 更新数据库(X = 2) 3. 线程 B 更新缓存(X = 2) 4. 线程 A 更新缓存(X = 1)
最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致。
// 某个 Spring singleton Bean 'aService' 存在一个调用来源标记,记录调用来源是HSF还是HTTP。
// 先 记录来源标记。
aService.setSource(source);
// 再结合source执行其他逻辑。例如将上面记录的source 和 其他参数 插入数据库.
aService.doSomethings(params);
如果这个代码被 HSF和 HTTP 同时调用就会发生问题。
在一个系统中,有两个价格类型 small 和 large,业务逻辑要求 small <= large,且 small 和 large 有2个入口可以分别修改。
目前方案是:对要改变的small或large,增加上面大小关系校验,不通过则拦截,例如 改动small的入口上,校验改后的small <= 系统里的large,不通过则不允许修改。
假如,最新需求要求:修改large的入口继续拦截,但修改small的入口不再拦截,而是发现如果改后small > 系统的large,则将 系统large = 改后的small+0.1,让 约束关系继续成立。 这种改法有问题吗?
-
初始时,系统的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 事务问题
-
数据库事务。 发生概率不高,大多会主动预防。
这个问题发生概率倒不高,也比较容易解决。 但要注意,事务执行耗时不要太久,以及避免死锁问题发生。
-
上下文一致性问题。
以上传并处理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 失败处理问题
-
失败处理
可能的处理方式:
1. failover。失败立即重试。 2. failback。记录失败,后置处理。 3. failfast。直接失败,返回异常。 4. failsafe。忽略失败,继续流程。
这里不在于选择那种处理方式,而是要“头脑清醒”的结合自己场景需求做出选择。
-
注意默认值
例如,在最开始时,代码里配置了当时的开城信息,但这个状态并没有跟业务操作流程打通,也就是没有办法做到及时更新。
那随着时间发展,开发了新的城市,那就可能产生问题。
6 switch配置问题
-
分批推送的时间间隔
switch发布时,不同批次会有时间间隔,大部分场景下都可以容忍这个时间间隔。但个别情况下,可能引发诸如数据不一致等问题。
再使用switch时需要对这个问题做提前考虑,若不能容忍这种情况,那需要更换其他方案。
-
内存值与持久值
switch的逻辑是这样:
1. switch会默认记录代码中的默认值。此时并不是 持久值。
2. 当在代码中修改默认值时,switch平台也会显示代码默认值。此时也并不是 持久值。
3. 只有在switch平台修改值并推送成功,swith平台会保存持久值。
4. switch保存持久值之后,不管代码修改默认值还是去掉 @AppSwitch 配置,持久值都是存在的。
如果你看到switch平台上展示了开关值,以为已经持久化,然后在代码里就把默认值删掉,此时也可能导致故障。
-
代码重构注意事项
关于应用级服务发现与接口级服务发现的区别和 dubbo 生态的解决方案,本文中不多赘述,可以参考刘军前辈写的文章文章《Dubbo 迈出云原生重要一步 应用级服务发现解析》
简单来说,应用级服务发现需要开发者关心接口之外还要关心应用名,注册中心的冗余信息较少;接口级服务发现开发者只需要引入接口名,但注册中心的冗余信息较多。
-
合理使用,避免滥用
switch 提供了简单易用的配置化能力,但不要把应该正常编码要考虑和处理的问题,丢到switch上做开关。否则,最后开关一大堆,维护越发困难,就隐藏了风险。
7 重大风险评估和处置
1、梳理 关键的业务流。
2、梳理 每个业务流的关键环节。
3、梳理 每个关键环节的关键逻辑 和 关键上下游。
4、结合自己场景,假定 关键逻辑 和 关键上下游 出现极端问题。例如 网络挂掉、机器重启、高并发来临、缓存挂掉等。
假设,有一个用户资金转账系统,用户可以通过App进行跨行转账操作。
那这个系统就要考虑到 转账超时、转账失败等场景。同时还要考虑 转账超时 或 失败时,是fail-fast 好,还是 fail-over好?
此外,还需要考虑到 App端的用户交互设计,假如遭遇网络中断或超时,且用户看不到任何问题提示,那用户很可能再次发起转账尝试,最后转了两笔的钱。