常见代码运行的Context:多线程 -> 多进程 -> 多机器 -> 多集群
在这种情况下,写的代码经常遇到的情况
- 并发冲突
有线程 A 和线程 B 两个线程,需要更新「同一条」数据,会发生这样的场景:
- 线程 A 更新数据库(X = 1)
- 线程 B 更新数据库(X = 2)
- 线程 B 更新缓存(X = 2)
- 线程 A 更新缓存(X = 1)
最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致。
- 非原子性
// 先查询是否存在目标记录
resultList = dbRepo.list(query);
// 有结果就更新,没有就插入
if( resultList.size() > 0 ){
dbRepo.update(xxxx);
} else {
dbRepo.insert(xxxx);
}
//如果这个代码被多个request 同时执行也会发生问题。
- 集群问题
// 在短信发送服务中,控制对用户的发送频率
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);
}
//这个例子在单机环境执行时没有问题,但线上集群多节点的话,那发送频率的控制就不对了。
- 错误并发
单个任务周期性的触发,本来不会有并发问题。
但因单次执行时间变长,导致先后两次执行时间出现重叠。
- 上下文一致问题
以上传并处理Excel文件为例,假如实现分为 2 步:
- 前端调用后端API,上传文件到Server的某个临时目录。
- 前端 在上传完成时,调用后端另一个API,通知 后端处理此文件。
这个例子在集群环境中就会出现概率性成功或失败的情况,集群节点数量越多,失败概率越高。这是因为 前端的前后两次请求调用到了不同节点上,执行上下文出现了不一致。
- 时序问题
常见的,例如对于 ECS运行状态的时序消息,如果下游消费者不是顺序消费,而是并行消费,就可能导致最终记录的状态 与实际不符。
- 分布式锁问题
获取锁
- 是阻塞式等待锁,还是等不到锁重试,还是等不到锁直接返回。
这个层面主要考量点,这个调用链路对时间和成功率要求是什么。
例如,上游是用户操作,那肯定不能阻塞在等锁那里太久;- 锁的key设计很关键。
合理设计lock key,能够降低锁碰撞的概率。
例如,你的lock 是加在一个BU层面上,还是加到某个人身上,那冲突概率显然差别很大。- 对于 持久锁,在循环执行业务逻辑时,要做好锁的状态检查。
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);
}- 能用本地锁 不用全局锁。
锁超时
- 合理设置锁的TTL,结合自己业务场景做取舍
例如,加锁之后执行大量数据的batch计算的场景。
如果锁TTL太长,那计算被异常中断(如机器重启)时,这个长TTL内是无法被其他节点/线程获取到执行权限的;但如果TTL设置太短,那可能还没等执行完成,锁就被意外抢走了。- 注意watchDog机制
像Redisson之类的会有锁的watchdog,超过设置或默认的时间,锁就被偷偷释放了。
释放锁
- 非必要情况下,避免强行释放锁,要检查锁的持有人是否是自己。
- 对于没有TTL的锁,要考虑极端情况下(进程被强制杀死、机器重启)的锁状态管理。否则意外一旦出现,锁就永远丢失了。
- 缓存问题
缓存穿透:缓存数据库都没有
缓存击穿:热点key失效大量请求落到db
缓存雪崩:同一时间段大量热点key失效
缓存的一致性
一般情况下,一致性要求不会非常严格。但如果需要强一致性保障时,要考虑缓存和DB之间的数据强一致性
一种可能的方案:只在写DB时才写缓存,读DB操作不写缓存。DB和缓存的写操作要加锁,避免并发问题。具体流程如下:
当写DB请求发生时:
- 删除 缓存。此时读操作缓存会miss,读取到DB中的老值。
- 写入DB。此时读操作缓存会miss,读取到DB中的新值。
- 写入缓存。此时读操作缓存会 hit,读取到缓存中的新值(与DB新值一致)。
需要注意的是:
- 缓存针对数据库所有的数据记录,可能导致缓存空间占用高,实际利用率却不高。
- 如果某个缓存key 是热点,或者 流量比较大,尽管缓存“删除-重写入”间隔短,依然可能会引发 缓存击穿问题。
- 如果缓存写入失败,需要有相应的补偿机制再写入,且需关注 补偿写入与其他正常写入的冲突和时序问题。
- 失败处理
可能的处理方式:
- failover。失败立即重试。
- failback。记录失败,后置处理。
- failfast。直接失败,返回异常。
- failsafe。忽略失败,继续流程。
根据实际业务场景使用
- 默认值问题
初始化时设定一些默认值、默认状态等,对于这些情况要充分考虑异常发生时是否存在风险
充分考虑进入你代码逻辑的源数据可能存在的各种形态
文章评论