并发编程三:超卖、幂等与一致性
真实项目里的并发问题,不只是“两个线程同时改一个变量”。一般有以下情况
- 用户连续点击两次下单,生成了两笔订单;
- 秒杀库存只有 1 件,却卖出去 2 件;
- 支付回调重复通知,订单状态被重复处理;
- MQ 消息重复消费,积分重复增加;
- 瞬间流量太大,把数据库打挂;
- 异步任务失败,主流程却已经返回成功。
锁和 CAS 是并发控制的底层基础,细节单独看这两篇:
并发问题从哪里来
业务并发问题通常来自三个方向。
| 来源 | 例子 |
|---|---|
| 同一份数据被同时修改 | 多个请求同时扣库存 |
| 同一个操作被重复执行 | 用户重复提交订单 |
| 系统承受的请求超过处理能力 | 秒杀瞬间大量请求进入数据库 |
比如库存扣减:
库存 = 1
请求A读取库存:1请求B读取库存:1请求A扣减库存:0请求B扣减库存:0两个请求都认为自己扣减成功,结果库存只有 1 件,却卖出了 2 件。
这就是典型的并发超卖。
库存扣减:不要先查再改
不推荐这样写:
Product product = productMapper.selectById(productId);if (product.getStock() <= 0) { throw new RuntimeException("库存不足");}
productMapper.updateStock(productId, product.getStock() - 1);并发下,多个请求可能同时查到库存大于 0,然后一起扣减。
更稳的做法是把判断和扣减放到一条 SQL:
UPDATE productSET stock = stock - 1WHERE id = #{productId} AND stock > 0;Java 里判断影响行数:
int rows = productMapper.decreaseStock(productId);if (rows == 0) { throw new RuntimeException("库存不足");}这样即使多个请求同时进来,数据库也会保证同一行更新的原子性。
这个方案适合普通并发。如果是秒杀级流量,还要配合 Redis 预扣库存、消息队列削峰、限流和异步下单。
乐观锁和悲观锁怎么放到业务里
乐观锁和悲观锁都可以解决并发修改问题,但适用场景不同。
乐观锁常见写法是带 version:
UPDATE productSET stock = stock - 1, version = version + 1WHERE id = #{productId} AND stock > 0 AND version = #{version};悲观锁常见写法是 for update:
SELECT stockFROM productWHERE id = #{productId}FOR UPDATE;简单选择:
| 场景 | 倾向方案 |
|---|---|
| 冲突不高,可以失败重试 | 乐观锁 |
| 强一致,必须串行处理 | 悲观锁 |
| 秒杀高冲突 | Redis 预扣库存 + MQ 削峰 |
锁的详细区别、死锁问题和分布式锁注意事项,放到专题里:Java 锁专题:从 synchronized 到分布式锁。
幂等:重复请求也只能成功一次
幂等的意思是:同一个操作执行一次和执行多次,最终效果一样。
支付回调就是典型场景。第三方支付平台可能会重复通知:
第一次通知:订单支付成功第二次通知:订单支付成功第三次通知:订单支付成功如果没有幂等控制,可能会重复发货、重复加积分、重复记账。
常见幂等方案:
| 方案 | 适合场景 |
|---|---|
| 唯一索引 | 防止重复插入 |
| 状态机 | 防止状态重复流转 |
| 幂等表 | 记录请求是否处理过 |
| Redis token | 防止表单重复提交 |
| 分布式锁 | 控制同一业务对象并发处理 |
比如支付流水表可以对 pay_no 加唯一索引:
ALTER TABLE payment_recordADD UNIQUE KEY uk_pay_no (pay_no);重复插入时,数据库会直接拒绝。
订单状态更新也要带条件:
UPDATE ordersSET status = 2WHERE order_no = #{orderNo} AND status = 1;含义是:只有“待支付”的订单才能更新成“已支付”。如果订单已经是已支付,影响行数就是 0,说明不用重复处理。
防重复提交:前端做体验,后端做兜底
用户重复点击按钮很常见。
前端可以做:
- 点击后按钮置灰;
- 请求完成前禁止再次提交;
- 给按钮加 loading 状态。
但只靠前端不够,因为请求可以被重放,接口也可能被绕过。
后端可以用 token 防重:
- 进入下单页时,服务端生成 token;
- token 存入 Redis,并返回给前端;
- 提交订单时带上 token;
- 后端校验并删除 token;
- 删除成功才继续创建订单。
伪代码:
public void submitOrder(String token, OrderRequest request) { Boolean success = redisTemplate.delete("order:token:" + token); if (!Boolean.TRUE.equals(success)) { throw new RuntimeException("请勿重复提交"); }
createOrder(request);}关键点是:校验和删除要保证原子性。真实项目里可以用 Lua 脚本完成。
限流:保护系统不被打穿
并发问题不只是数据一致性,也包括系统承载能力。
如果瞬间 10 万个请求都打到数据库,哪怕 SQL 是正确的,数据库也可能扛不住。
常见限流算法:
- 固定窗口;
- 滑动窗口;
- 漏桶;
- 令牌桶。
简单理解令牌桶:
系统按固定速度往桶里放令牌请求来了先拿令牌拿到令牌才能继续执行拿不到就被限流接口限流后可以返回:
{ "code": 429, "message": "请求太频繁,请稍后再试"}限流可以放在:
- 网关层;
- 接口层;
- 用户维度;
- IP 维度;
- 商品维度。
秒杀接口一般不能让所有请求都进入下单逻辑,应该先限流、校验活动状态、预扣库存,再异步创建订单。
异步不是万能的
异步可以提高接口响应速度,但也会带来一致性问题。
比如下单成功后异步发消息:
orderService.createOrder(request);messageQueue.send("ORDER_CREATED", orderNo);如果订单创建成功,但消息发送失败,后续发券、通知、统计都可能漏掉。
更稳的方案是本地消息表:
创建订单事务中:1. 写订单表2. 写本地消息表
事务提交后:后台任务扫描未发送消息发送成功后更新消息状态本地消息表字段示例:
idtopicbusiness_keycontentstatusretry_countcreated_atupdated_at这样即使消息发送失败,也可以重试,不会因为一次网络抖动丢掉业务事件。
异步的关键不是“丢给线程池就完事”,而是要考虑:
- 失败怎么重试;
- 重复消费是否幂等;
- 顺序是否重要;
- 消息积压怎么办;
- 主流程和异步流程的数据是否一致。
线程池和 CompletableFuture 的用法可以回看第二篇:并发编程二:线程池、JUC 工具与异步编程。
状态机:并发下的业务闸门
订单状态不能随便改。
比如订单状态:
待支付 -> 已支付 -> 已发货 -> 已完成待支付 -> 已取消已支付 -> 已退款不应该允许:
已取消 -> 已支付已完成 -> 已取消已退款 -> 已发货更新状态时要带上当前状态条件:
UPDATE ordersSET status = 2WHERE order_no = #{orderNo} AND status = 1;这条 SQL 表示:只有待支付状态才能变成已支付。
Java 里判断影响行数:
int rows = orderMapper.markPaid(orderNo);if (rows == 0) { return; // 已处理过,或者状态不允许流转}状态机是业务并发里非常重要的一环。很多重复回调、重复消费、并发取消和支付冲突,都可以通过状态条件挡住。
秒杀场景怎么组合方案
秒杀不是靠一个锁解决的,它是多个方案组合起来的。
一个常见流程:
请求进入 ↓网关限流 / 用户限流 ↓校验活动状态 ↓Redis 预扣库存 ↓写入 MQ ↓消费者异步创建订单 ↓数据库最终扣减库存这里每一层都在减少后面的压力:
- 限流挡住异常流量;
- Redis 扛住热点库存读写;
- MQ 削峰填谷;
- 数据库只处理最终有效请求;
- 幂等和状态机保证重复请求不会重复成功。
并发问题怎么排查
遇到并发问题,不要只看一条日志,要把同一个业务 ID 的完整链路串起来。
建议排查顺序:
- 找到业务唯一标识:订单号、支付单号、用户 ID、商品 ID;
- 查接口日志:同一时间是否有重复请求;
- 查数据库记录:是否重复插入、状态是否异常;
- 查消息日志:是否重复消费、是否重试;
- 查线程池指标:队列是否堆积、是否触发拒绝策略;
- 查锁和事务:是否长事务、死锁、锁等待。
日志里最好带上业务 ID:
log.info("create order start, userId={}, productId={}, requestId={}", userId, productId, requestId);如果没有 requestId 或 traceId,并发问题会非常难查。
常见业务并发方案选择
| 场景 | 推荐方案 |
|---|---|
| 防止库存超卖 | 条件更新、乐观锁、Redis 预扣库存 |
| 支付重复回调 | 状态机、唯一流水号、幂等处理 |
| 用户重复提交 | Redis token、幂等表、唯一索引 |
| MQ 重复消费 | 消费幂等表、业务唯一键、状态机 |
| 秒杀高峰流量 | 限流、削峰、缓存、消息队列 |
| 异步任务可靠性 | 本地消息表、重试、幂等消费 |
| 多实例重复处理 | 数据库唯一约束、Redis 分布式锁 |
选择方案时,不要只问“哪个技术更高级”,要问:
这个业务能不能重复执行?失败后能不能重试?数据必须强一致还是最终一致?并发冲突高不高?请求量会不会打穿数据库?总结
业务并发不是单纯学几个 Java 类,而是围绕业务正确性做设计。
几个核心原则:
- 库存扣减优先使用条件更新,避免先查再改;
- 重复请求要做幂等,特别是支付、下单、消息消费;
- 高并发入口要限流,不能让所有请求都打到数据库;
- 异步任务要考虑失败、重试、重复消费和消息丢失;
- 状态流转要带条件,不能让订单状态随便跳;
- 秒杀要组合限流、缓存、MQ、幂等和最终一致性。
If this article helped you, please share it with others!
Some information may be outdated






