那天凌晨三点,财务部的老张给我发了一张截图,脸色铁青。
“昨晚大促,少了80万订单。”
我打开后台日志一看,头皮瞬间发麻。不是因为金额大,而是因为排查过程比想象中更荒诞——数据不是被覆盖的,而是“凭空消失”的。
这在MySQL高并发场景下,是一个极其典型且致命的陷阱。今天我想和你聊聊,当流量洪峰撞上数据库,我们是如何一步步守住数据一致性的底线的。这不是一篇教科书式的理论文章,而是一场从“尸检”现场开始的复盘。
一、 那个“失踪”的订单,到底去了哪?
1.1 表象:看似正常的业务逻辑
首先,让我们还原一下那个出问题的核心代码逻辑。这看起来非常、非常标准,甚至可以说得上是“教科书级”的简洁:
// 【问题代码示例】伪代码,用于说明场景
public void createOrder(OrderDTO dto) {
// 1. 检查库存是否充足
int stock = orderMapper.checkStock(dto.getSpuId());
if (stock < dto.getQuantity()) {
throw new BusinessException("库存不足");
}
// 2. 扣减库存
orderMapper.deductStock(dto.getSpuId(), dto.getQuantity());
// 3. 创建订单记录
Order order = new Order();
order.setSpuId(dto.getSpuId());
order.setQuantity(dto.getQuantity());
order.setStatus(OrderStatus.CREATED);
orderMapper.insert(order);
// 4. 更新用户优惠券状态
couponMapper.useCoupon(dto.getCouponId(), order.getId());
}
乍一看,这段代码逻辑严密,有库存检查、有库存扣减、有订单创建、还有优惠券处理。在单机、低并发环境下,它运行得完美无缺。
但在高并发场景下,特别是秒杀活动,第1步和第2步之间,存在一个致命的“时间窗口”。
1.2 根因分析:幻读与并发竞态
当10000个用户同时点击“购买”时,MySQL面临的是怎样的压力?
假设库存只有100件:
- 线程A执行到第1步,检查库存=100,满足条件。
- 线程B也执行到第1步,检查库存=100(因为A还没提交),满足条件。
- 线程C同理,也通过了检查。
- 随后,A、B、C几乎同时执行第2步,各自扣减1件。
- 结果:库存变成了97,但扣减了3件。如果并发更高,库存可能直接变成负数!
更可怕的是,如果我们的代码里没有加任何锁,或者加锁方式不对,就会导致:
- 订单创建成功,但库存扣减失败(反之亦然),导致账实不符。
- 订单创建成功,但后续步骤失败,导致订单处于“僵尸状态”,在最终对账时无法匹配。
老张提到的“少了80万订单”,其实是指订单状态不一致。有些订单在业务库显示“已创建”,但在库存库或财务对账表中却找不到对应的扣减记录,导致对账失败。
二、 为什么MySQL在高并发下会“失灵”?
要解决这个问题,我们必须先理解MySQL的几个核心机制在高并发下的表现。
2.1 隔离级别:默认值可能不够用
MySQL默认的隔离级别是RR(Read Committed)吗?不,InnoDB引擎默认是RC(Read Committed),而MySQL 8.0之前的默认是RR(Repeatable Read)。
这里有一个常见的误区:很多人认为RR级别能解决所有并发问题。但实际上:
- RC级别:每次查询都读取最新的数据。在并发扣减库存时,如果两个事务同时读取到相同的库存值,就会导致“超卖”。
- RR级别:理论上解决了幻读,但在高并发下,如果使用的是间隙锁(Gap Lock),可能会导致锁竞争加剧,吞吐量下降。
关键点:对于库存扣减这种写操作,隔离级别的影响不如锁机制直接。
2.2 锁的粒度与性能权衡
在高并发场景下,我们面临两个选择:
悲观锁(Pessimistic Lock):使用
SELECT ... FOR UPDATE。- 优点:数据一致性高。
- 缺点:高并发下锁竞争严重,吞吐量极低。想象一下,10000个请求同时排队等待一把锁,后面的请求只能阻塞。
乐观锁(Optimistic Lock):使用版本号或CAS(Compare And Swap)。
- 优点:无锁,吞吐量高。
- 缺点:冲突时需要重试,如果冲突率高,反复重试也会严重影响性能。
2.3 事务的传播与边界
在上述代码示例中,createOrder方法没有声明@Transactional注解,或者事务范围过大,都可能导致问题。
- 事务范围过大:将库存检查、扣减、订单创建、优惠券处理全部放在一个大事务中,会长时间持有锁,阻塞其他请求。
- 事务范围过小:如果分多个小事务,又没有合理的全局事务管理(如分布式事务),很容易出现“部分成功”的脏数据。
三、 实战解决方案:三层防线守住一致性
经过多次踩坑和演练,我们总结出一套“三层防线”的解决方案,从架构设计到代码实现,层层把关。
第一层防线:数据库层面的“强一致性”保证
这是最基础,也是最关键的一层。
3.1.1 使用“悲观锁”+“SQL原子性”
与其在代码里做“检查-扣减”两步走,不如让数据库一步完成。
-- 【推荐方案】利用数据库的行锁特性
UPDATE stock_table
SET stock = stock - #{quantity}
WHERE spu_id = #{spuId}
AND stock >= #{quantity};
这行SQL看似简单,实则蕴含了极大的智慧:
- 原子性:
UPDATE语句本身是原子的。 - 行锁:在执行
UPDATE时,MySQL会自动对这一行加排他锁(X锁)。其他事务如果要修改这一行,必须等待锁释放。 - 条件判断:
WHERE stock >= #{quantity}保证了库存充足才会执行扣减。如果库存不足,影响行数为0,我们可以直接返回失败,无需再查一遍库存。
Java代码改造如下:
public void createOrder(OrderDTO dto) {
// 1. 原子性扣减库存,直接判断影响行数
int affectedRows = orderMapper.deductStockWithCondition(dto.getSpuId(), dto.getQuantity());
if (affectedRows == 0) {
throw new BusinessException("库存不足或已售罄");
}
// 2. 创建订单(此时库存已扣减,不存在超卖风险)
Order order = new Order();
order.setSpuId(dto.getSpuId());
order.setQuantity(dto.getQuantity());
order.setStatus(OrderStatus.CREATED);
orderMapper.insert(order);
// 3. 更新优惠券状态(可异步或同步)
couponMapper.useCoupon(dto.getCouponId(), order.getId());
}
注意:
deductStockWithCondition这个SQL必须是在同一个事务中执行的,或者Mapper方法上标注了@Transactional。
3.1.2 引入“版本号”乐观锁
如果并发量极大,悲观锁的SELECT FOR UPDATE或UPDATE ... WHERE可能导致严重的锁竞争。此时,可以考虑使用乐观锁。
-- 假设 stock_table 有一个 version 字段
UPDATE stock_table
SET stock = stock - #{quantity}, version = version + 1
WHERE spu_id = #{spuId}
AND stock >= #{quantity}
AND version = #{version}; -- 乐观锁的核心:版本号匹配才更新
在Java中,如果affectedRows == 0,则说明并发冲突,可以进行重试(通常重试3-5次)。
public void createOrderWithOptimisticLock(OrderDTO dto) {
int retryCount = 3;
while (retryCount > 0) {
// 1. 先查询当前版本号和库存
Stock stock = orderMapper.selectStockWithVersion(dto.getSpuId());
if (stock == null || stock.getStock() < dto.getQuantity()) {
throw new BusinessException("库存不足");
}
// 2. 尝试原子性扣减,带上版本号
int affectedRows = orderMapper.deductStockWithVersion(dto.getSpuId(), dto.getQuantity(), stock.getVersion());
if (affectedRows > 0) {
// 扣减成功,创建订单
Order order = new Order();
order.setSpuId(dto.getSpuId());
order.setQuantity(dto.getQuantity());
order.setStatus(OrderStatus.CREATED);
orderMapper.insert(order);
break; // 成功退出循环
} else {
// 版本号不匹配,说明有并发修改,重试
retryCount--;
if (retryCount == 0) {
throw new BusinessException("系统繁忙,请稍后重试");
}
}
}
}
第二层防线:应用层面的“分布式锁”与“限流”
当单机数据库无法承受时,我们需要引入Redis等分布式缓存。
3.2.1 使用Redis分布式锁
在流量进入数据库之前,先在Redis中加锁。
// 伪代码:使用Redisson分布式锁
RLock lock = redissonClient.getLock("stock_lock_" + dto.getSpuId());
try {
if (lock.tryLock(100, 3000, TimeUnit.MILLISECONDS)) {
// 1. 查询库存(此时只有持有锁的事务能操作)
int stock = redisTemplate.opsForValue().get("stock_" + dto.getSpuId());
if (stock < dto.getQuantity()) {
throw new BusinessException("库存不足");
}
// 2. 扣减Redis中的库存
redisTemplate.opsForValue().increment("stock_" + dto.getSpuId(), -dto.getQuantity());
// 3. 发送消息到MQ,异步创建订单和扣减数据库库存
mqProducer.sendOrderCreateMessage(dto);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("系统错误");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
核心思想:将高并发的写操作转化为顺序操作,通过MQ异步解耦,保护数据库。
3.2.2 网关层限流
在请求到达数据库之前,先在API网关层进行限流。
- 令牌桶算法:控制单位时间内的请求数。
- 滑动窗口:更精确地控制流量。
// 使用Sentinel进行限流
@SentinelResource(value = "createOrder", blockHandler = "handleBlock")
public void createOrder(OrderDTO dto) {
// 业务逻辑
}
public void handleBlock(OrderDTO dto, BlockException e) {
throw new BusinessException("当前系统繁忙,请稍后重试");
}
第三层防线:最终一致性与对账兜底
没有绝对的系统能保证100%不出错。因此,我们必须建立“事后补救”机制。
3.3.1 分布式事务(TCC/Saga)
如果业务涉及多个微服务(如订单服务、库存服务、支付服务),必须保证数据的最终一致性。
- TCC(Try-Confirm-Cancel):
- Try:预留资源(冻结库存)。
- Confirm:确认提交(扣减库存)。
- Cancel:取消预留(释放库存)。
- Saga:长事务的解决方案,通过补偿机制保证一致性。
3.3.2 定时对账任务
这是最后的一道防线。每天凌晨,系统自动比对“订单表”和“库存扣减表”、“支付流水表”。
@Component
public class DailyReconciliationJob {
@Scheduled(cron = "0 2 0 * * ?") // 每天凌晨0点02分执行
public void reconcile() {
log.info("开始对账...");
// 1. 查询昨日所有订单
List<Order> yesterdayOrders = orderMapper.selectByDate(LocalDate.now().minusDays(1));
// 2. 查询昨日所有库存扣减记录
List<StockRecord> yesterdayStockRecords = stockRecordMapper.selectByDate(LocalDate.now().minusDays(1));
// 3. 查询昨日所有支付流水
List<PaymentRecord> yesterdayPayments = paymentRecordMapper.selectByDate(LocalDate.now().minusDays(1));
// 4. 比对数据
Map<String, Order> orderMap = yesterdayOrders.stream().collect(Collectors.toMap(Order::getId, o -> o));
Map<String, StockRecord> stockMap = yesterdayStockRecords.stream().collect(Collectors.toMap(StockRecord::getOrderId, s -> s));
Map<String, PaymentRecord> paymentMap = yesterdayPayments.stream().collect(Collectors.toMap(PaymentRecord::getOrderId, p -> p));
List<String> exceptions = new ArrayList<>();
for (Order order : yesterdayOrders) {
if (!stockMap.containsKey(order.getId())) {
exceptions.add("订单[" + order.getId() + "]缺少库存扣减记录");
}
if (!paymentMap.containsKey(order.getId()) && order.getStatus() == OrderStatus.PAID) {
exceptions.add("订单[" + order.getId() + "]已支付但无支付流水");
}
}
if (!exceptions.isEmpty()) {
log.error("对账发现异常: {}", exceptions);
// 发送告警邮件/钉钉消息
alertService.sendAlert(exceptions);
} else {
log.info("对账完成,无异常");
}
}
}
四、 架构演进:从单体到微服务的挑战
如果系统已经演变成微服务架构,上述方案还需要进一步调整。
4.1 库存服务的独立化
将库存管理独立成一个微服务,所有扣减操作都通过RPC调用库存服务。这样可以避免订单服务和库存服务之间的数据不一致。
4.2 消息队列的可靠性保证
在使用MQ异步解耦时,必须保证消息的不丢失和顺序性。
- 不丢失:使用持久化队列,生产者确认机制,消费者手动ACK。
- 顺序性:对于同一SKU的扣减,使用同一个Key进行Hash,确保同一个SKU的消息按顺序消费。
// 使用RocketMQ保证顺序消息
Message msg = new Message("OrderTopic", "TagA", order.getId().getBytes(), orderDTO);
// 使用messageQueueSelector确保同一SKU的消息发送到同一个队列
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
String spuId = (String) arg;
int index = Math.abs(spuId.hashCode()) % mqs.size();
return mqs.get(index);
}
}, dto.getSpuId());
五、 给小朋友的比喻:如何公平地分糖果
为了让大家更好地理解这个复杂的技术问题,我们可以用一个简单的比喻:
想象一下,班级里有100颗糖果(库存),有1000个小朋友(并发请求)想分糖果。
没有锁的情况(原始代码): 老师喊“开始分”,所有小朋友一拥而上。结果,糖果被抢光了,但登记簿上只记了10颗糖被分走,因为很多小朋友同时伸手,却只被记了一次名。这就是数据不一致。
悲观锁(排队领糖): 老师规定,只能一个一个来领糖。每个人必须等前一个人领完离开,下一个才能进。这样很公平,但很慢,前面的小朋友可能要等很久。这就是数据库行锁。
乐观锁(抢糖+对账): 老师允许大家同时伸手去抓糖,但每个小朋友抓之前要喊出自己的编号。如果抓的时候,发现糖已经被别人拿走了(编号对不上),就得重新排队再试一次。这效率高,但如果抢的人太多,大家会反复重试,也很累。这就是CAS机制。
分布式锁(微信群通知): 老师建立一个微信群,只有群里的“管理员”能发糖。小朋友先向管理员申请,管理员确认还有糖后,再通知小朋友来领。这样既保证了秩序,又不至于让所有人都挤在办公室门口。这就是Redis分布式锁。
对账(事后清点): 不管前面怎么分,每天晚上老师都会重新数一遍糖果和登记簿。如果少了,就追查是谁没记清楚,补上记录。这就是定时对账任务。
六、 总结与最佳实践清单
回顾整个案例,我们从“订单丢失”的恐慌中走出来,建立了一套完整的数据一致性保障体系。以下是核心要点总结:
- 数据库层面:优先使用数据库自身的原子性操作(如
UPDATE ... WHERE stock >= quantity),避免在应用层做“检查-修改”两步走。 - 锁的选择:
- 低并发、高一致性要求:使用悲观锁(
SELECT FOR UPDATE)。 - 高并发、可接受重试:使用乐观锁(版本号/CAS)。
- 超高并发、复杂业务:使用Redis分布式锁。
- 低并发、高一致性要求:使用悲观锁(
- 架构层面:通过MQ异步解耦,将同步写操作转化为异步消息处理,保护数据库。
- 兜底机制:建立定时对账任务,及时发现并修复数据不一致问题。
- 监控告警:对关键业务指标(如订单创建成功率、库存扣减成功率)进行实时监控,异常时立即告警。
