如果你做过电商类的后端开发,大概率遇到过这类线上问题:优惠券页面显示还有库存,用户也顺利抢到了,但下单时却提示“券已抢光”,或者更糟——系统发出去的券数量超过了实际库存,亏到需要人工对账。这就是典型的超卖问题。它不挑业务规模,小到一次秒杀活动,大到整条订单链路,只要涉及“库存扣减”这个动作,就一定会碰到。
这篇文章我会完整拆解一个我最近在模拟项目X里做的优惠券秒杀功能,核心就是用乐观锁解决超卖。内容从问题发生的底层原因讲起,到表结构设计、核心代码实现、多线程压测验证,再到实际踩过的坑和排查方法,全程给出可直接复用的代码和SQL。适合正在做秒杀、抢购、库存扣减类功能的同学阅读,不管你是刚接触并发的新手,还是想对比锁方案的老手,都能从这里拿走一套能落地的方案。
1. 这次做的是什么:一个优惠券秒杀系统
1.1 业务背景和功能范围
这次要实现的不是一个完整商城,而是一个最小可复现的优惠券秒杀后端服务。核心功能就三个:查询优惠券库存、用户点击秒杀、后台扣减库存并生成一条领取记录。听起来简单,但问题恰恰出在“扣减库存”这个只有一行SQL的动作上。
项目采用Spring Boot搭建,数据库用MySQL,ORM用的是MyBatis。考虑到秒杀场景对响应速度要求高,我没有引入消息队列和Redis,纯粹靠数据库层面的并发控制来验证乐观锁的效果。这样的好处是方案足够轻量,出了问题容易排查,也方便你把它移植到自己现有的项目里。
我在设计时把优惠券库存单独拆了一张表,和优惠券基本信息表分开。原因很直接:秒杀场景下,库存表的更新频率极高,频繁的update会让这张表的行锁竞争非常激烈。如果和优惠券名称、面额这些不常变动的字段放在同一张表,一次扣库存的update会把整行数据都锁住,反而拖慢其他读取优惠券信息的请求。
1.2 为什么选择乐观锁而不是其他方案
在真正动手写代码之前,我先梳理了一下市面上解决超卖的几种常见手段:
- 数据库悲观锁,也就是SELECT ... FOR UPDATE,在读取库存时直接锁住这行记录,直到事务提交才释放。优点是绝对安全,缺点是并发高时所有请求都会排队,吞吐量直线下降。
- 数据库乐观锁,通过版本号或库存条件来保证更新操作的原子性,冲突时让请求失败或重试。优点是并发性能好,缺点是有一定的失败率,需要业务方接受。
- Redis预扣库存,把库存放在Redis里用原子操作扣减,再异步同步到数据库。优点是抗住极高并发,缺点是架构复杂,还要处理Redis和数据库的数据一致性问题。
最终我选择了乐观锁,因为它最贴合这个项目的真实情况:优惠券秒杀的并发量虽然集中,但远没到需要上Redis集群的程度;同时业务方可以接受“抢不到”的结果,不要求每个请求都一定成功。这种情况下,乐观锁既能保证数据不错,又不会像悲观锁那样把数据库拖垮,性价比最高。
2. 超卖到底是怎么发生的
2.1 一段伪造超卖的代码
很多新人第一次接触超卖问题时会觉得不可思议:明明我在代码里判断了库存大于0才扣减,怎么还会超卖?为了讲清楚这个问题,我先展示一段典型的错误写法——
public boolean seckillWrong(Long couponId, Long userId) { // 1.查询剩余库存 CouponStock stock = stockMapper.selectById(couponId); if (stock == null || stock.getRemainStock() <= 0) { return false; } // 2.扣减库存 int rows = stockMapper.decreaseStock(couponId); return rows > 0; }对应的SQL是这样:
UPDATE coupon_stock SET remain_stock = remain_stock - 1 WHERE coupon_id = #{couponId}这段代码在单线程下完全没问题,但在并发场景下,两个请求可能同时执行完第一步查库存,都发现剩余库存是1,然后都执行了扣减SQL。结果就是库存从1变成了-1,超卖了。
这个问题的本质是“检查库存”和“扣减库存”这两个动作之间没有原子性。你查到的库存只是一个时间点的快照,在快照和真正执行更新之间,数据库的状态可能已经被其他事务改变了。
2.2 从数据库隔离级别角度看超卖
如果你对事务隔离级别有一定了解,会知道MySQL默认的隔离级别是REPEATABLE READ,它通过快照读解决了不可重复读的问题,但普通SELECT属于快照读,并不会对记录加锁。这意味着多个事务可以同时对同一行库存数据执行SELECT,彼此互不阻塞。
真正危险的是两个事务同时读到相同库存快照后,各自继续执行UPDATE。MySQL的UPDATE语句本身会加行锁,这里的加锁顺序是“先读到快照,后更新时再加锁”,加锁时数据库发现版本没有变化,允许两个更新都执行成功。于是两个请求都扣减了一次,库存就变成负数了。
所以,要彻底解决超卖问题,核心思路就一条:让“检查库存”和“扣减库存”变成一个不可分割的原子操作,或者让后执行的更新操作因为条件不满足而失败。
3. 环境准备与表结构设计
3.1 项目依赖和基础环境
代码层面,我用的是Spring Boot 2.x,依赖清单里核心就这几样:
- spring-boot-starter-web
- mybatis-spring-boot-starter
- mysql-connector-java
- lombok
数据库用的是MySQL 5.7,存储引擎InnoDB。这里要特别强调,库存表的引擎必须用InnoDB,因为它支持行级锁和事务,MyISAM在这种场景下基本没法用。
3.2 优惠券库存表DDL
为了配合乐观锁,我设计了两张表:一张是优惠券基本信息表,一张是库存表。库存表里加了乐观锁专门用的version字段。
CREATE TABLE `coupon` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '优惠券ID', `name` varchar(64) NOT NULL COMMENT '优惠券名称', `amount` decimal(10,2) NOT NULL COMMENT '面额', `total_stock` int(11) NOT NULL COMMENT '总库存', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券基本信息表'; CREATE TABLE `coupon_stock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `coupon_id` bigint(20) NOT NULL COMMENT '优惠券ID', `remain_stock` int(11) NOT NULL COMMENT '剩余库存', `version` int(11) NOT NULL DEFAULT 0 COMMENT '版本号,乐观锁使用', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_coupon_id` (`coupon_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券库存表';初始数据插入时,version设为0,remain_stock设为实际要发放的优惠券数量。version字段是整个方案的关键,每次成功扣减库存,都要把它加1,下次再更新时拿这个version作为更新条件之一。
4. 乐观锁方案设计与核心代码实现
4.1 乐观锁更新SQL的写法
乐观锁的本质,是在执行UPDATE的时候带上一个“数据没有被别人改过”的校验条件。具体到库存扣减这个场景,SQL长这样:
UPDATE coupon_stock SET remain_stock = remain_stock - 1, version = version + 1 WHERE coupon_id = #{couponId} AND version = #{version} AND remain_stock > 0这条SQL做了三件事:扣减库存、版本号加一、用version和remain_stock双重条件确保数据没被改过且库存没耗尽。执行完成后,通过受影响行数判断是否成功。
为什么这里还要额外加一个remain_stock > 0条件?因为version只能防止“别人同时改过数据”,但还有一个边界情况:如果库存刚好剩1,version也没变,两个请求同时进来,一个成功一个失败,不会超卖。可如果因为某些原因version被重置过,或者事务隔离级别设置不当,单独靠version不够保险,加上库存大于0这个条件就相当于加了第二道保险。
4.2 Mapper层和Service层代码
先定义Mapper接口:
@Mapper public interface CouponStockMapper { CouponStock selectByCouponId(Long couponId); int decreaseStock(@Param("couponId") Long couponId, @Param("version") Integer version); }对应的XML文件:
<update id="decreaseStock"> UPDATE coupon_stock SET remain_stock = remain_stock - 1, version = version + 1 WHERE coupon_id = #{couponId} AND version = #{version} AND remain_stock > 0 </update>注意,这里我把version当作条件传进去,而不是在SQL里写version = version - 1,目的就是让每次更新都基于调用方之前查到的那个版本号。如果中途有其他请求改了数据,版本号变化了,这个UPDATE就会匹配不到任何记录,返回0行。
Service层的完整逻辑是这样:
@Service public class CouponSeckillService { @Resource private CouponStockMapper stockMapper; @Resource private CouponRecordMapper recordMapper; @Transactional(rollbackFor = Exception.class) public boolean seckill(Long couponId, Long userId) { // 1.读取当前库存和版本号 CouponStock stock = stockMapper.selectByCouponId(couponId); if (stock == null || stock.getRemainStock() <= 0) { return false; } // 2.尝试用乐观锁扣减库存 int rows = stockMapper.decreaseStock(couponId, stock.getVersion()); if (rows == 0) { // 说明version不匹配,别人已经先改过了,秒杀失败 return false; } // 3.扣减成功,插入一条领取记录 CouponRecord record = new CouponRecord(); record.setCouponId(couponId); record.setUserId(userId); record.setCreateTime(new Date()); recordMapper.insert(record); return true; } }这个方法加了@Transactional,保证“扣库存”和“插入领取记录”在同一个事务里。只要库存扣减失败,整个事务回滚,不会出现“库存扣了但记录没生成”这种数据不一致问题。
4.3 同一用户重复秒杀的处理
秒杀场景里还有一个常见问题:同一个用户狂点按钮,同时发来多个秒杀请求。乐观锁能防库存超卖,但防不了同一个用户重复领取。所以我额外加了一张用户领取记录表,并在领取记录表里对user_id和coupon_id建了唯一索引。
ALTER TABLE coupon_record ADD UNIQUE KEY `uk_user_coupon` (`user_id`, `coupon_id`);由于唯一索引的存在,如果一个用户已经领取过这个优惠券,再插入记录时数据库会直接报唯一索引冲突。但这个冲突会被Spring事务当作运行时异常,导致整个事务回滚。所以Service层插入记录前,可以先做一次查询判断用户是否已领取;哪怕查询判断因为并发问题漏掉了,数据库中唯一索引还会兜底拦截。
这里有一个我在实际开发中觉得特别重要的细节:乐观锁在并发高的时候必然会出现更新失败,失败后你肯定不能直接让用户看到“系统错误”,而是要让用户感知到“券已经被抢完”或者“手慢了”,所以失败的情况要走一个明确的业务结果返回,而不是抛异常。
5. 多线程压测验证方案有效性
5.1 用Thread模拟并发请求
代码写完只能说明逻辑上没问题,要验证它真的能挡住超卖,必须做并发测试。我这里没用JMeter,而是直接用Java的线程加CountDownLatch写了一个压测入口,这样可以在测试代码里精确控制并发数和请求总数。
测试场景设定为:优惠券总库存10张,模拟1000个用户同时发起秒杀请求。预期结果:成功领取记录数10条,数据库剩余库存0,所有失败请求都返回“抢不到”。
@Test public void testSeckillConcurrency() throws InterruptedException { int threadCount = 1000; ExecutorService executor = Executors.newFixedThreadPool(200); CountDownLatch readyLatch = new CountDownLatch(threadCount); CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch endLatch = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(0); AtomicInteger failCount = new AtomicInteger(0); for (int i = 0; i < threadCount; i++) { Long userId = 10000L + i; executor.submit(() -> { readyLatch.countDown(); try { startLatch.await(); boolean result = seckillService.seckill(1L, userId); if (result) { successCount.incrementAndGet(); } else { failCount.incrementAndGet(); } } catch (Exception e) { failCount.incrementAndGet(); } finally { endLatch.countDown(); } }); } readyLatch.await(); startLatch.countDown(); endLatch.await(); executor.shutdown(); System.out.println("成功:" + successCount.get()); System.out.println("失败:" + failCount.get()); CouponStock stock = stockMapper.selectByCouponId(1L); System.out.println("剩余库存:" + stock.getRemainStock()); }CountDownLatch在这里的作用特别关键:所有线程先等待startLatch放行,这样可以尽量保证1000个请求在同一瞬间涌入,模拟出真实的秒杀峰值效果。如果不做这个同步,线程是逐渐启动的,并发度根本测不出来。
5.2 压测结果与分析
上面这段测试我跑了多次,结果非常稳定,这里贴一次有代表性的输出:
| 请求数 | 成功数 | 失败数 | 剩余库存 |
|---|---|---|---|
| 1000 | 10 | 990 | 0 |
成功数正好等于总库存10,剩余库存为0,没有出现负数,也没有出现成功数大于库存数的情况,超卖问题得到了有效控制。
分析一下执行过程:1000个请求同时进来,第一个请求查到version为0,执行UPDATE成功,version变成1。其他999个请求中,查到的version也可能是0,但执行UPDATE时会发现当前记录的version已经变成1,条件不匹配,受影响行数为0,于是返回失败。这个“最后只有一个人能成功”的效果,就是乐观锁的核心价值。
5.3 MySQL并发更新底层发生了什么
为了让你更信服这个结果,我再补充一点MySQL底层的执行细节。当多个UPDATE语句同时经过WHERE条件匹配时,InnoDB引擎会对命中的行加排他锁。第一个UPDATE成功后会持有行锁,直到事务提交才释放。后续的UPDATE会阻塞等待,但等它们拿到行锁去执行时,会重新检查WHERE条件,发现version已经变了,就会放弃更新,返回0行受影响。
这正是乐观锁和悲观锁本质不同的地方:悲观锁是把所有并发请求串行化,让大家排队执行;乐观锁允许并发执行,但通过在UPDATE阶段的条件冲突来踢掉那些“状态已经过期”的请求。前者是让请求等锁,后者是让请求失败重试或者直接失败,业务上通常可以接受这种失败。
6. 常见问题与排查技巧实录
6.1 乐观锁出现内存中的ABA问题怎么解
熟悉并发编程的人会知道CAS操作有个ABA问题,也就是线程A读到值为1,线程B改成2又改回1,线程A比较时发现还是1,以为没人改过,结果继续操作。乐观锁会不会有这个隐患?
我的答案是:在数据库乐观锁里,version字段是只增不减的,每次UPDATE都会把version加1,永远不会回到旧值,所以ABA问题在这里并不成立。即使你把version改成时间戳,只要它单调递增,ABA问题也不会发生。但如果你设计的是用一个业务字段本身作为乐观锁条件,比如用库存数量当版本号,那确实可能出现ABA问题。所以最稳妥的做法就是用一个独立的、单调递增的version字段,别为了省字段去做奇怪的设计。
6.2 秒杀失败后要不要自动重试
这是一个经常被问到的点。有的人拿到乐观锁方案后,喜欢在失败后循环重试,直到成功为止,理由是“不能浪费每个进来的请求”。我的建议是:重试可以,但要有限重试,不要无限死循环。
假设库存只有10张,1000个请求全来,如果每个失败请求重试10次,那相当于对数据库发起了上万次UPDATE,不仅拖垮数据库,还可能让秒杀接口的响应时间飙升。更关键的是,限时秒杀本身讲究的就是“先到先得”,重试并没有改变这个本质,只是多消耗资源。
我在这个项目里的做法是失败直接返回结果,不重试。如果你的业务允许排队等库存,那可以用消息队列把失败请求转化为异步任务,等待其他人取消订单后释放库存再补发。但这属于另一个复杂度等级的架构了,不在这次讨论范围内。
6.3 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 两个事务同时扣减成功,库存变成负数 | 没有用乐观锁或悲观锁,单纯靠代码判断库存 | 给UPDATE加上version和库存大于0条件 |
| 秒杀成功但领取记录没查到 | 扣库存和插记录不在同一个事务 | 在Service方法上添加@Transactional |
| 一个用户反复秒杀同一张券成功 | 没有唯一索引或代码里没去重 | 在领取记录表加user_id和coupon_id唯一约束 |
| 压测时发现数据库CPU飙升 | 失败请求被循环重试,或者并发线程池开太大 | 控制重试次数,限制入口流量 |
| 扣库存成功了但库存显示没变化 | UPDATE的WHERE条件没匹配上,但代码误判rows大于0 | 检查返回的受影响行数是否作为唯一成功标准 |
6.4 压测时发现还有一个隐性问题
我在第一次压测时,其实犯了一个很典型的错误:线程池大小直接设成了1000。结果数据库连接池默认最大连接数是10,大量线程都在等待获取数据库连接,压测结果变得非常慢,超时异常也比比皆是。后来把线程池改成200,同时把数据库连接池的最大连接数调大到50,整个压测才恢复正常。
这里提醒你,压测前一定要先确认你的数据库连接池配置。自己本地的MySQL默认可以接受的连接数是有限的,别让连接池成为隐藏瓶颈。另外,压测用的机器如果不是多核心,开几千个线程反而会因为上下文切换降低真实吞吐量,线程数一般控制在CPU核心数的2到4倍就够用了。
6.5 乐观锁的适用边界和后续扩展
用下来我的整体感受是,乐观锁是非常适合优惠券秒杀这种“冲突率高但允许失败”的场景。但如果你的秒杀量级真的到了十几万QPS,那数据库层面的乐观锁也会扛不住这么多UPDATE请求。这时要考虑更前置的流量控制手段,比如在接口入口加Redis计数器,先让请求在Redis层消耗一次库存,真正扣减库存再用数据库乐观锁兜底。
我自己后面就做了这个扩展:在查询数据库之前,先对Redis里的库存key执行DECR操作,返回值小于0说明已抢完,直接返回失败;DECR成功的请求才进入下单流程。这样数据库真正承受的并发量被控制在一个很低的水平,数据库乐观锁依然作为最终保证数据准确性的防线。
这个思路其实就是一个漏斗模型,流量先经过最便宜的一层,最后才落到最贵的数据库层。如果你的项目将来面临更高的并发需求,不妨从这个方向继续演进,而不是直接把乐观锁换成悲观锁,那会让整个系统的吞吐量降低一个数量级。
最后再分享一个我个人的使用习惯:上线任何秒杀类功能之前,白盒测试和黑盒测试都只是第一步,真正要信服的还是模拟真实流量峰值的并发压测报告。建议你就在自己本地搭一套和线上表结构一致的测试环境,把这篇文章里的压测代码跑起来,亲眼看看库存不会变成负数,再决定要不要用乐观锁方案。踩过几次坑之后,你会对“并发安全”这四个字有完全不一样的理解。