1. 问题背景与压测场景还原
去年双十一大促前,我们团队对秒杀系统进行了一次全链路压测。这个秒杀系统的核心流程采用业界常见的设计模式:
- 用户请求先通过Redis Lua脚本完成资格校验和库存扣减
- 生成订单事件写入Redis Stream
- 异步消费者从Stream读取消息并持久化到MySQL
压测参数设置为5,200并发请求,模拟1,000个真实用户抢购限量1,000件的商品。第一次压测跑完后,系统表面看起来运行良好:业务日志显示成功下单1,000次,Redis库存也确实扣减到了0。但核对数据库时发现了一个致命问题——实际订单记录只有999条,库存剩余1。
关键提示:这种"差1"现象在高并发系统中往往不是统计误差,而是系统性缺陷的表现。就像量子物理中的"丢失的角动量",必须找到合理解释。
2. 问题定位与证据链构建
2.1 四维数据对账法
我采用了四维度数据交叉验证的方法来定位问题根源:
- 压测统计报表:显示成功请求1,000次
- Redis库存记录:确认已从1,000扣减到0
- MySQL订单表:实际记录999条
- 应用错误日志:发现关键异常堆栈
通过对比发现,系统没有出现超卖(最终库存≥0),但存在Redis与MySQL之间的数据不一致。这指向异步处理链路中存在消息丢失或处理失败的情况。
2.2 日志中的关键线索
深入分析日志后,发现两类异常模式:
- 空指针异常:
java复制java.lang.NullPointerException:
at com.xxx.service.impl.VoucherOrderServiceImpl.lambda$null$0(VoucherOrderServiceImpl.java:87)
at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:223)
异常发生在proxy.createVoucherOrder()方法调用处
- 用户ID为空的异常订单:
code复制[WARN] 消费到异常订单:userId=null, voucherId=10001
3. 问题根因深度分析
3.1 并发竞态:危险的代理对象共享
原系统设计存在严重的线程安全问题:
java复制// 问题代码示例
private Object proxy;
public Result seckillVoucher(Long voucherId) {
// 通过AOP上下文获取当前代理对象
proxy = AopContext.currentProxy();
// 将订单事件写入Redis Stream
redisTemplate.opsForStream().add(orderEvent);
}
这里存在两个致命缺陷:
- 生命周期错配:消费线程是常驻的,而proxy对象依赖每次请求临时初始化
- 缺乏线程安全保护:共享的proxy变量没有volatile修饰,也没有同步控制
在高并发下,可能出现:
- 消费线程读取proxy时,请求线程还未初始化proxy
- 由于内存可见性问题,消费线程读取到过期的null值
3.2 消息污染:控制消息与业务消息混淆
系统初始化Stream时写入了一条控制消息:
json复制{
"type": "init",
"timestamp": 1630000000
}
但消费者代码没有区分消息类型:
java复制VoucherOrder order = objectMapper.readValue(message, VoucherOrder.class);
// 直接使用order.getUserId()等字段
当消费到初始化消息时,反序列化得到的order对象所有字段都为null,导致后续业务逻辑异常。
3.3 事务保护不足:库存扣减失败仍下单
核心事务方法存在逻辑漏洞:
java复制@Transactional
public boolean createVoucherOrder(VoucherOrder order) {
// 扣减库存
int updated = voucherMapper.deductStock(order.getVoucherId());
if(updated <= 0) {
log.warn("库存不足");
// 但没有return false!
}
// 创建订单
return orderMapper.save(order) > 0;
}
当多个请求同时扣减最后一件库存时,只有一个update会成功,其他应该失败。但原实现没有及时终止流程,导致创建了无效订单。
4. 系统性解决方案
4.1 代理对象改造:从懒加载到依赖注入
改造前:
java复制private Object proxy; // 请求线程懒初始化
改造后:
java复制@Autowired
@Qualifier("voucherOrderService")
private VoucherOrderService proxy; // 容器启动时注入
关键改进点:
- 通过Spring容器管理代理对象生命周期
- 使用接口类型而非Object,增强类型安全
- 消除对请求时序的依赖
4.2 消息消费端加固
消息结构规范化:
java复制public class StreamMessage {
private String msgId;
private MessageType type; // INIT/ORDER/ERROR
private VoucherOrder order;
private Long timestamp;
}
消费端防护:
java复制public void handleMessage(StreamMessage message) {
if(message.getType() != MessageType.ORDER) {
log.warn("丢弃非订单消息:{}", message);
return;
}
if(!validateOrder(message.getOrder())) {
log.error("订单校验失败:{}", message);
alertService.notifyAdmin();
return;
}
proxy.createVoucherOrder(message.getOrder());
}
4.3 事务完整性增强
改造后的事务方法:
java复制@Transactional
public Result createVoucherOrder(VoucherOrder order) {
// 扣减库存(CAS操作)
int updated = voucherMapper.deductStock(order.getVoucherId());
if(updated <= 0) {
log.warn("库存扣减失败,voucherId={}", order.getVoucherId());
return Result.fail("库存不足");
}
// 一人一单校验
Long userId = order.getUserId();
int count = query().eq("user_id", userId)
.eq("voucher_id", order.getVoucherId())
.count();
if(count > 0) {
return Result.fail("请勿重复下单");
}
// 保存订单
boolean saved = save(order);
return saved ? Result.ok(order.getId()) : Result.fail("下单失败");
}
5. 验证与效果
5.1 修复后压测数据对比
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 请求成功率 | 99.9% | 100% |
| Redis库存 | 0 | 0 |
| MySQL订单数 | 999 | 1,000 |
| MySQL库存 | 1 | 0 |
| 异常订单数 | 1 | 0 |
5.2 关键问题解决情况
- 并发竞态问题:通过依赖注入解决代理对象线程安全问题,5,200并发下零NPE
- 消息污染问题:增加消息类型校验后,无效消息处理耗时从200ms降为0.5ms
- 事务完整性问题:严格检查库存扣减结果,杜绝了无效订单产生
6. 经验沉淀与最佳实践
6.1 异步系统设计三原则
- 生命周期管理:常驻消费者只能依赖同样具有常驻生命周期的组件
- 消息契约:控制消息与业务消息必须物理隔离或显式区分
- 失败快速终止:任何前置条件失败必须立即终止流程
6.2 高并发系统验证方法
必须检查的四个一致性:
- 业务日志与实际效果
- 缓存与数据库
- 成功量与库存扣减量
- 订单量与用户量
推荐的对账SQL:
sql复制-- 库存对账
SELECT
(SELECT stock FROM redis_stock WHERE id = 1001) AS redis_stock,
(SELECT stock FROM db_stock WHERE id = 1001) AS db_stock;
-- 订单对账
SELECT
COUNT(DISTINCT user_id) AS user_count,
COUNT(*) AS order_count,
voucher_id
FROM voucher_order
WHERE voucher_id = 1001
GROUP BY voucher_id;
7. 后续优化方向
- 引入分布式事务:考虑使用Seata的AT模式解决极端情况下的不一致
- 完善监控体系:增加Redis与MySQL的实时比对监控
- 压测常态化:将全链路对账压测纳入每周例行任务
这次事故给我们的核心启示是:高并发系统不能依赖"测试时没发现问题"来保证正确性,必须通过设计使错误不可能发生。正如Tony Hoare所说:"程序的正确性不应该依赖于执行的速度或特定的事件时序"。