“优惠券风波”:一段代码引发的线上事故
起因:优惠券功能上线
故事的开始源于公司新上线的一项促销活动——在用户未使用优惠券时,系统会自动赠送一张优惠券。这个功能不仅能提升用户体验,还能拉动平台的销售额。为了赶上活动上线时间,组长将这项任务交给了刚转正的开发小张。
小张心中既紧张又兴奋,立刻投入工作。经过一夜的奋战,当晚,他就提交了代码,并信心满满地对组长说:“放心吧,功能已经写好,测试通过,没有任何问题!”小张对自己的代码很满意,认为加锁和状态校验都已经足够周全。在他的测试环境里,功能表现一切正常,组长也批准了代码上线。
转折:生产环境的“优惠券雨”
功能上线后,活动初期数据非常亮眼——大量用户在收到优惠券后进行了消费。然而,短短几个小时后,运营部却收到了大量用户的投诉:有用户发现自己的账户里收到的优惠券数量成倍增长.
- 有些用户账户里多出了数十张甚至上百张优惠券。
- 数据库的CPU使用率飙升,写操作排队严重,导致整个系统几乎瘫痪。
- 线上出现多起订单错误日志,运维团队忙得焦头烂额。
与此同时,后台的服务压力剧增,数据库的CPU使用率飙升,监控系统发出了一片告警声。
运维火速介入,更严重的是,因为系统的故障,公司损失了几笔大额订单,高层震怒,扬言如果问题不能解决,整个团队都可能面临解散的危机!
话不多说,直接上代码:
public void getCouponsByOrder(String orderId) {
//获取订单
Order order = orderService.getOrderById(orderId);
if(order == null){
return ;
}
//加锁
lock.lock();
order = getOrderForUpdate(orderId);
try{
//判断订单是否有优惠券
if(!order.getHasCoupons()){
//如果没有使用优惠卷,系统默认赠送优惠卷
couponsService.createCouponsAndSave(orderId);
}
}finally {
lock.unlock();
}
}
危机:组内的混乱排查
团队进入了“战斗模式”。大家围绕这段代码进行分析,但一时半会儿谁也看不出问题。
“逻辑没问题啊,加了锁的,这不就是标准的并发处理方式吗?”小王一脸茫然。
“是不是数据库问题?或者是缓存同步有问题?”测试工程师提出猜测。
“这种锁到底管不管用?”运维开始怀疑架构本身。
大家讨论了整整两天,依然没有找到根本原因。
转机:老员工的登场
无奈之下,组长拨通了一个“传说中”的号码。这是团队里已经调岗的资深工程师老李,他曾是系统的核心开发者,对架构的每个细节了如指掌。
老李接到电话时,正在家中喂猫。听完情况后,他轻轻一笑:“这事儿,听起来有点意思。把代码发我看看。”
老李摇摇头:“这代码确实像个实习生写的,不过问题也不复杂,改改就行了。”
1. MySQL事务默认隔离级别
MySQL默认隔离级别:可重复读 (REPEATABLE READ)
MySQL默认的事务隔离级别是 可重复读 (REPEATABLE READ)。在这种隔离级别下,事务开始后,事务内的所有查询在同一事务中多次读取数据时,结果是一致的,即使其他事务对该数据进行了修改。为了实现这一点,MySQL通过 MVCC(多版本并发控制) 实现快照读。
问题分析:逻辑与隔离级别的交互
代码中的逻辑和隔离级别产生了以下几个潜在问题:
- 快照读导致状态不一致
在调用orderService.getOrderById(orderId)
时,读取的是快照数据。如果这时有其他事务更新了订单的hasCoupons
状态,这些修改不会被当前事务看到。 - 加锁不生效
MySQL的SELECT
默认是快照读,只有显式使用FOR UPDATE
或类似语法才能触发行锁。如果没有正确使用锁,即使在事务中操作,订单状态的并发修改也无法避免。 - 重复赠送优惠券
当多个事务几乎同时执行,读取hasCoupons
时可能都为false
,并发调用了createCouponsAndSave(orderId)
,最终导致用户多次收到优惠券。
总的来说就是,当线程A,B进入事务时,生成了此刻的快照,然后A先获取到锁,A修改了数据,但是B此时已经生成了快照,所以B后面拿到的是之前的旧数据。
如何从隔离级别下手解决?
以下是从隔离级别与事务设计入手的解决方案:
改进方案
-
将数据库的隔离级别更改成读已提交,即可完美解决
读已提交是在每一次select的时候生成快照
-
使用分布式锁。