GPT摘要
这篇文章介绍了基于MySQL实现秒杀系统的基础版本,重点探讨了高并发场景下的数据一致性问题。秒杀系统核心需求包括商品展示、库存管理、订单处理、防重复购买和时间控制。文章对比了四种库存扣减方案: 1. V0错误示范:不加锁直接更新库存,导致超卖问题。 2. V1悲观锁:使用FOR UPDATE
实现串行化操作,保证一致性但吞吐量低。 3. V2乐观锁:版本号机制减少锁竞争,但高并发下失败率高。 4. V3优化方案:单条UPDATE
语句结合条件判断(stock_count > 0
),利用MySQL行锁实现原子操作,平衡性能与一致性,是最佳实践。 总结指出仅依赖数据库优化存在瓶颈,预告下一篇文章将引入Redis(预减库存、异步下单、分布式锁)进一步优化系统性能。
从零开始实现秒杀系统(一):MySQL行锁与事务篇
引言
秒杀系统是电商领域常见的应用场景,也是检验系统高并发处理能力的经典案例。本系列文章将从零开始,一步步构建一个完整的秒杀系统,探讨其中涉及的技术点和解决方案。
本篇作为系列的第一篇,将重点介绍基于MySQL实现的秒杀基础版本。在这个版本中,我们将专注于基础业务逻辑的实现和数据库层面的并发控制,确保系统在高并发访问下不会出现超卖或少卖的问题。
1. 秒杀系统需求分析
秒杀系统本质上是一个短时间内承受高并发访问,并保证数据一致性的交易系统。核心需求包括:
- 商品展示:展示秒杀商品列表和详情
- 库存管理:准确控制商品库存,防止超卖和少卖
- 订单处理:秒杀成功后生成订单
- 防重复购买:同一用户只能秒杀一次同一商品
- 秒杀时间控制:在指定时间段内才能秒杀
2. 数据库设计
秒杀系统的数据库设计相对简单,主要包含以下几个表:
1 2 3 4 5 6 7 8 9 10 11
| CREATE TABLE `user` (...)
CREATE TABLE `goods` (...)
CREATE TABLE `seckill_goods` (...)
CREATE TABLE `seckill_order` (...)
|
其中,最为关键的是seckill_goods
表中的stock_count
字段和version
字段,它们将用于实现库存控制和并发管理。在后续优化版本中,我们也会介绍没有version
实现的版本。
3. 秒杀核心流程设计
秒杀的核心流程包括:
- 查询商品信息:展示商品详情和秒杀状态
- 验证秒杀条件:判断用户是否合法、秒杀是否已开始、是否已结束、是否已参与过
- 扣减库存:使用MySQL行锁保证库存操作的原子性
- 生成订单:创建秒杀订单记录
- 返回结果:通知用户秒杀结果
整个流程的关键在于第3步和第4步,需要保证这两步的原子性,避免出现库存减了但订单没创建成功,或者订单创建了多次但库存只减了一次的情况。
4. 高并发下的数据一致性挑战
秒杀系统面临的最大挑战是如何在高并发情况下保证数据一致性。共包含两个问题:
- 生成订单和扣减库存操作一致:例如库存扣了10, 那么订单就要生成10个。
- 库存不超卖:10个库存不能卖出超过10个
在V1版本中,我们主要通过MySQL的行锁机制和事务来解决这个问题。事务保证生成订单和扣减库存操作要么同时成功,要么同时失败,保证二者的一致性。锁机制保证不超卖
4.0使用事务确保原子性
在秒杀过程中,减库存和创建订单必须是一个原子操作,要么都成功,要么都失败。我们使用Spring的事务管理来实现这一点:
1 2 3 4 5 6 7 8 9 10 11 12
| @Override @Transactional public SeckillOrder seckill(Long userId, GoodsVo goodsVo) { boolean success = goodsService.reduceStock(goodsVo.getSeckillGoodsId()); if (success) { return orderService.createOrder(userId, goodsVo); } return null; }
|
通过@Transactional
注解,Spring会自动管理事务,确保减库存和创建订单要么都成功,要么都失败。
4.1 超卖问题
超卖是指实际卖出的商品数量超过了库存数量。这通常是由于并发环境下,多个事务同时读取了当前库存,并同时进行了扣减,导致最终库存变成了负数。
核心代码为 boolean success = goodsService.reduceStock(goodsVo.getSeckillGoodsId());
v0错误示范

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| @Override public boolean reduceStockIncorrect(Long seckillGoodsId) { SeckillGoods goods = goodsDao.getStock(seckillGoodsId); Integer stock = goods.getStockCount(); if (stock > 0) { int affectedRows = goodsDao.updateStock(seckillGoodsId); return affectedRows > 0; } return false; }
<!-- 查询信息,不加锁 --> <select id="getStock" resultType="SeckillGoods"> SELECT * FROM seckill_goods WHERE id = #{seckillGoodsId} </select>
<!-- 更新库存 --> <update id="updateStock"> UPDATE seckill_goods SET stock_count = stock_count - 1, version = version + 1 WHERE id = #{seckillGoodsId} </update>
|
v1 悲观锁实现

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Override public boolean reduceStockByPessimisticLock(Long seckillGoodsId) { SeckillGoods goods = goodsDao.getSeckillGoodsForUpdate(seckillGoodsId); if (goods != null && goods.getStockCount() > 0) { int affectedRows = goodsDao.updateStock(seckillGoodsId); return affectedRows > 0; } return false; }
-- 查询时加锁 SELECT * FROM seckill_goods WHERE id = #{seckillGoodsId} AND stock_count > 0 FOR UPDATE
-- 更新库存 UPDATE seckill_goods SET stock_count = stock_count - 1, version = version + 1 WHERE id = #{seckillGoodsId}
|
这个版本为悲观锁的实现,是最基础的实现,也就是在查询的使用通过FOR UPDATE
实现排他,实现其他事务会被阻塞,直到当前事务结束。也就是说,当同时多个请求到达时,只有一个请求能够获取到行级锁,从而执行扣减库存以及生成订单过程,其他请求会被阻塞直到当前事务完成。相当于整个过程变成了串行。系统瓶颈变成了MySQL的处理速度。
v2 乐观锁实现(版本号方式)

这个版本中没用使用锁,而是通过版本号的方式来实现扣减库存,第一步获得库存以及版本后,基于版本进行CAS操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| @Override public boolean reduceStockByVersion(Long seckillGoodsId) { SeckillGoods goods = goodsDao.getStock(seckillGoodsId); Integer stock = goods.getStockCount(); Integer version = goods.getVersion();
if (version == null || stock < 0) { return false; } int affectedRows = goodsDao.reduceStockByVersion(seckillGoodsId, version); return affectedRows > 0; }
-- 获取版本号 以及库存信息 <!-- 查询信息,不加锁 --> <select id="getStock" resultType="SeckillGoods"> SELECT * FROM seckill_goods WHERE id = #{seckillGoodsId} </select> -- 使用乐观锁更新库存 <update id="reduceStockByVersion"> UPDATE seckill_goods SET stock_count = stock_count - 1, version = version + 1 WHERE id = #{seckillGoodsId} AND version = #{version} </update>
|
第一步查询操作会有多个用户同时获得同样的库存以及版本信息,然后拿着这个库存以及版本去做CAS的库存更新操作,只有第一个人的AND version = #{version}
条件可以满足,后面的都会失败,从而实现不超卖。
缺点:在大量并发请求下,非常多的请求都会失败,只有少部分能成功,导致并发量很低。例如极端场景下,1000个库存,100个人来抢并且同时进来,只会有一个人成功,剩余99个都因为版本对不上导致失败,但其实数据库还有999个库存。
改进:其实stock在这里就是代表着版本,并不需要再额外使用一个version字段
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @Override public boolean reduceStockByVersion(Long seckillGoodsId) { SeckillGoods goods = goodsDao.getStock(seckillGoodsId); Integer stock = goods.getStockCount();
if (stock < 0) { return false; } int affectedRows = goodsDao.reduceStockByVersion(seckillGoodsId, stock); return affectedRows > 0; }
<!-- 查询信息,不加锁 --> <select id="getStock" resultType="SeckillGoods"> SELECT * FROM seckill_goods WHERE id = #{seckillGoodsId} </select> -- 使用乐观锁更新库存 <update id="reduceStockByStock"> UPDATE seckill_goods SET stock_count = stock_count - 1 WHERE id = #{seckillGoodsId} AND stock_count = #{stock_count} </update>
|
V3:最终优化实现

1 2 3 4 5 6 7 8 9 10 11 12
| @Override public boolean reduceStockWhenLeft(Long seckillGoodsId) { int affectedRows = goodsDao.reduceStockWhenLeft(seckillGoodsId); return affectedRows > 0; }
-- 原子更新:只在库存大于0时更新,单条 SQL 保证原子性 UPDATE seckill_goods SET stock_count = stock_count - 1 WHERE id = #{seckillGoodsId} AND stock_count > 0
|
这里的stock_count本质上也可也看作一种乐观锁,只是锁的条件没有那么严格,并不是进行等值条件的乐观,而是大于零都放行,使得大量的用户请求都可以执行,只有没有库存时才会执行失败。失败率远远小于v2.
在本质上,其实是基于mysql的行锁来实现排他的,SET stock_count = stock_count - 1
在mysql底层会获取一个排他锁
5. 总结
在本篇文章中,我们从零开始实现了秒杀系统的基础版本,重点探讨了如何利用MySQL的事务和锁机制来解决高并发场景下的数据一致性问题。我们对比了四种不同的库存扣减实现方案:
- 错误示范(V0):不加锁直接读取和更新库存,在并发环境下会导致超卖问题。
- 悲观锁实现(V1):使用
FOR UPDATE
语句在查询时加锁,保证同一时间只有一个事务可以操作库存,但会导致请求串行化,系统吞吐量受限。
- 乐观锁实现(V2):通过版本号机制实现无锁并发控制,避免了长时间的锁等待,但在高并发下成功率较低,可能导致大量请求失败。
- 优化实现(V3):使用单条带条件的UPDATE语句实现原子性操作,既保证了数据一致性,又提高了系统的并发处理能力。
这四种实现方案各有优缺点,在实际应用中可以根据业务场景和性能需求进行选择。其中,第四种实现是最为推荐的方案,它在保证数据一致性的同时,提供了较好的并发性能。
然而,在实际的秒杀系统中,仅依靠数据库层面的优化是不够的。当并发量达到一定程度时,数据库很容易成为系统的瓶颈,影响整体性能和用户体验。
6. 展望:Redis优化篇
在下一篇《从零开始实现秒杀系统(二):Redis优化篇》中,我们将继续深入探讨如何优化秒杀系统,重点介绍如何利用Redis来提升系统性能。主要内容将包括:
- Redis预减库存:使用Redis缓存库存信息,在Redis中预先扣减库存,减轻数据库压力。
- 异步下单:通过消息队列实现异步下单,提高系统吞吐量。
- Redis分布式锁:使用Redis实现分布式锁,保证分布式环境下的数据一致性。
通过引入Redis等中间件技术,我们将构建一个更加高效、稳定的秒杀系统,能够承受更高的并发压力,为用户提供更好的秒杀体验。敬请期待!
本文源码已开源在GitHub:Goinggoinggoing/seckill
如有疑问或建议,欢迎在评论区讨论!