关于并发编程常见业务问题及解决方案
一、库存超卖问题 🔴
1.1 问题现象
商品库存仅剩1件,多个用户同时下单,最终下单成功多件,库存变为负数,引发超卖。
1.2 根本原因
典型并发竞争:查询库存、判断库存、扣减库存属于三步复合操作,不具备原子性。多线程同时查询到库存充足,全部通过判断,执行扣减,导致超卖。
1.3 五种解决方案(由低到高)
方案1:数据库悲观锁 for Update
操作:查询库存时加行级排他锁,事务提交释放锁,串行执行扣减。
优点:数据强一致、绝对不会超卖、逻辑简单。
缺点:串行执行、并发性能极差、容易产生锁等待、死锁。
适用:低并发、内部系统、数据一致性要求极高场景。
方案2:数据库乐观锁(版本号机制)
数据表新增字段 data_version。
扣减SQL:
update goods set stock=stock-1,data_version=data_version+1 where id=100 and stock > 0 and data_version=1
核心本质:业务乐观思想,DB底层行锁悲观机制。查询无锁,更新校验版本,冲突直接失败不阻塞。
优点:并发性能高、无阻塞、不死锁。
缺点:高并发下大量请求失败,吞吐量上不去,容易出现下单失败扎堆。
方案3:数据库乐观锁(库存CAS方案,无版本号)
update goods set stock=stock-1 where id=100 and stock > 0
直接依靠库存字段做CAS校验,无需版本号,最简防超卖。
特点:业务最简单、生产最常用、足够防超卖。
方案4:Redis原子扣减(Lua 无锁方案)
扣减成功执行后续业务,扣减失败直接返回库存不足。
方案原理
Redis 单线程执行 Lua 脚本 = 原子性操作
查询 + 判断 + 扣减 三步变成一个原子命令,
不加锁、不等待、不阻塞、高并发、绝对不超卖。
LUA脚本内容:
- 获取当前库存
- 判断库存是否 > 0
- 扣减库存
- 返回成功 / 失败
整个过程原子性、无锁、高并发、绝对不会超卖。
这是秒杀、库存、抢购的标准工业级方案。
|
|
方案5:本地预扣减库存 + Redis原子扣减
适用场景:大促秒杀,超大并发,请求量远大于库存,支持用户单次多件购买。
核心设计: 本地预扣减小批量库存,减少redis访问次数,减少网络IO。
执行流程:
- 商品总库存初始化存入 Redis
- 优先扣减本地 AtomicInteger 库存,扣减成功则库存扣减完成,进入后续业务流程
- 扣减失败,校验本地售罄标记,如果售罄,返回库存不足。
- 本地库存不足,调用 Redis Lua 脚本,从 Redis 扣减库存。
- 如果脚本返回 0:库存不足,下单失败,添加秒级售罄标记。
- 如果脚本返回扣减数量 (10~20):本地 AtomicInteger 原子新增【扣减数量 - 本次购买数量】,库存扣减完成,进入后续业务流程
- 如果脚本返回用户购买数量:库存扣减完成,进入后续业务流程,不更新本地库存
- 如果Redis 库存耗尽,节点本地添加秒级售罄标记
注意事项:
其中校验本地售罄标记,如果在一个标记失效且标记时间距离当前时间在一个标记周期内,则将 4-5 步骤使用DCL双重检查锁包裹。
这样做的目的是极致的性能优化,且尽可能避免对Redis的无效访问。
另外,实际业务中,库存是按照商品-sku来的,所以无论是本地 DCL 锁粒度,还是本地库存、本地售罄标记,一定是在并发容器中的。
这种方案,会存在尾部少量库存卖不出去的情况,如果要解决这种问题,就要加短期本地库存回流到redis的逻辑,每次redis lua扣减库存前加本地锁,代码复杂度会高很多。
|
|
方案6:Redis预减库存 + MQ异步削峰落地(大厂标准方案)
- 预热库存放入Redis;2. 前端请求先扣Redis库存;3. 扣减成功发送MQ消息;4. 消费者异步落地数据库。
优点:支撑秒杀几十万并发、抗峰值、数据库压力极小。
缺点:架构复杂、存在短暂数据不一致,需要补偿机制。
二、接口幂等性问题(业务必遇)
2.1 问题现象
网络超时、前端重复点击、重试机制导致:重复下单、重复扣款、重复充值、重复回调。
2.2 核心定义
同一个请求多次提交,最终业务结果一致,不会产生多条数据。
2.3 五大解决方案
-
数据库唯一索引:订单号、交易号设唯一索引,重复插入直接报错拦截。最简单、最稳定。
-
数据库状态机控制:通过订单状态判断,已支付/已处理任务直接忽略,不重复执行。适合状态流转业务。
-
Token令牌机制:前端获取唯一Token,下单携带Token,后端Redis校验Token且一次性失效,防止重复提交。
-
全局唯一ID幂等:基于雪花算法唯一业务ID,Redis记录已处理ID,做请求去重。
-
MQ消费幂等:消息ID去重、业务唯一标识去重。
三、缓存并发问题(缓存穿透/击穿/雪崩)
3.1 缓存穿透
问题:查询数据库不存在的数据,缓存永远不命中,请求直接打满DB。
解决方案:
-
缓存空值(短期过期)
-
布隆过滤器拦截不存在key
-
接口参数校验
3.2 缓存击穿(热点key失效)
问题:热点缓存key瞬间过期,大量并发请求瞬间打入数据库。
解决方案:
-
互斥锁(本地锁/分布式锁,只放行一个线程更新缓存)
-
热点key永不过期
-
缓存过期时间加随机值
3.3 缓存雪崩(大量key同时失效)
问题:海量缓存同时过期,或Redis宕机,所有请求压入数据库,导致DB宕机。
解决方案:
-
过期时间加随机偏移量,错开失效时间
-
Redis集群高可用
-
服务熔断、降级、限流
四、线程池业务坑点(线上高频故障)
4.1 任务丢失问题
使用 DiscardPolicy 静默丢弃任务,业务无报错,导致数据丢失、业务不执行。
避坑:业务系统禁止使用丢弃策略,优先使用 CallerRunsPolicy 限流或自定义告警策略。
4.2 线程池异常丢失
线程池内部任务异常如果不try-catch,直接消失,无日志、无报错,业务无声失败。
解决:线程任务内部必须try-catch全部异常,自定义线程工厂统一异常捕获。
4.3 线程池参数配置错误
IO密集型设置过少线程,导致接口阻塞、超时、堆积;CPU密集型线程过多,导致上下文切换频繁、CPU打满。
五、ThreadLocal 业务坑点
5.1 内存泄漏
线程池线程复用,ThreadLocal使用完毕不remove,value常驻内存,导致内存泄漏。
铁律:finally中必须remove()
5.2 数据串号(极其严重)
线程池复用线程,上一个用户的线程变量残留,导致下一个用户读到别人的数据,造成用户数据错乱、权限漏洞。
解决:使用前set、使用后强制清除。
六、复合操作线程不安全(最普遍BUG)
6.1 问题
所有JUC并发容器、锁,只能保证单一操作原子性。
例如 ConcurrentHashMap、ReentrantLock 都无法保证:先查询、再修改复合操作原子性。
6.2 场景
积分增减、余额修改、计数统计、状态更新。
6.3 解决方案
-
业务层加锁(分布式锁)
-
数据库CAS更新
-
事务+乐观锁兜底
七、死锁业务问题与规避
7.1 常见场景
转账业务、多资源锁定、嵌套锁、多表更新顺序不一致。
7.2 规避方案
-
统一锁获取顺序
-
使用tryLock超时抢锁
-
尽量减少锁嵌套
-
缩小锁范围,只锁核心代码
八、高并发限流、削峰、降级(业务架构)
8.1 限流
防止请求量过大打垮服务:Semaphore单机限流、Redis令牌桶/漏桶限流、网关限流。
8.2 削峰
瞬时高并发:MQ异步队列削峰,将瞬时流量转为平稳流量。
8.3 降级
服务压力过大时,关闭非核心业务,保证核心业务可用(比如关闭商品推荐,保证下单支付)。
九、避坑总结
-
单机并发问题本质:复合操作不原子、共享变量竞争。
-
分布式并发问题本质:多节点无法共享本地锁、数据不同步。
-
能用数据库CAS乐观锁解决,不加代码锁;能Redis分布式锁解决,不用数据库锁。
-
高并发场景优先:无锁设计、异步削峰、缓存兜底,尽量避免加锁串行。
-
所有并发工具只保证单一操作安全,业务复合操作必须自己保证原子性。
-
极端单热点key,核心思路是去中心化,将流量尽可能在服务层拦截。