从零开始实现秒杀系统(一):MySQL行锁与事务篇

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. 秒杀核心流程设计

秒杀的核心流程包括:

  1. 查询商品信息:展示商品详情和秒杀状态
  2. 验证秒杀条件:判断用户是否合法、秒杀是否已开始、是否已结束、是否已参与过
  3. 扣减库存:使用MySQL行锁保证库存操作的原子性
  4. 生成订单:创建秒杀订单记录
  5. 返回结果:通知用户秒杀结果

整个流程的关键在于第3步和第4步,需要保证这两步的原子性,避免出现库存减了但订单没创建成功,或者订单创建了多次但库存只减了一次的情况。

4. 高并发下的数据一致性挑战

秒杀系统面临的最大挑战是如何在高并发情况下保证数据一致性。共包含两个问题:

  1. 生成订单和扣减库存操作一致:例如库存扣了10, 那么订单就要生成10个。
  2. 库存不超卖: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) {
// 1. 减少库存 这里减少的逻辑影响着并发量和正确性
boolean success = goodsService.reduceStock(goodsVo.getSeckillGoodsId());
if (success) {
// 2. 创建订单
return orderService.createOrder(userId, goodsVo);
}
// 库存减少失败,秒杀失败
return null;
}

通过@Transactional注解,Spring会自动管理事务,确保减库存和创建订单要么都成功,要么都失败。

4.1 超卖问题

超卖是指实际卖出的商品数量超过了库存数量。这通常是由于并发环境下,多个事务同时读取了当前库存,并同时进行了扣减,导致最终库存变成了负数。

核心代码为 boolean success = goodsService.reduceStock(goodsVo.getSeckillGoodsId());

v0错误示范

image-20250307140912254

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) {
// 1. 查询库存(未加锁)
SeckillGoods goods = goodsDao.getStock(seckillGoodsId);
Integer stock = goods.getStockCount();

// 同时大量请求到达(例如10000),大家在数据库中查询的数量都是同一个例如10,那么所有人都会认为库存充足,导致超卖
if (stock > 0) {
// 2. 扣减库存
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 悲观锁实现

image-20250307141148942

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) {
// 1. 查询时加锁(FOR UPDATE)
SeckillGoods goods = goodsDao.getSeckillGoodsForUpdate(seckillGoodsId);
if (goods != null && goods.getStockCount() > 0) {
// 2. 扣减库存
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 乐观锁实现(版本号方式)

image-20250307141400081

这个版本中没用使用锁,而是通过版本号的方式来实现扣减库存,第一步获得库存以及版本后,基于版本进行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) {
// 1. 获取当前版本号 以及库存
SeckillGoods goods = goodsDao.getStock(seckillGoodsId);
Integer stock = goods.getStockCount();
Integer version = goods.getVersion();

if (version == null || stock < 0) {
return false;
}
// 2. 扣减库存(CAS 更新)
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) {
// 1. 获取当前版本号 以及库存
SeckillGoods goods = goodsDao.getStock(seckillGoodsId);
Integer stock = goods.getStockCount();

if (stock < 0) {
return false;
}
// 2. 扣减库存(CAS 更新 基于stock)
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:最终优化实现

image-20250307141458066

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的事务和锁机制来解决高并发场景下的数据一致性问题。我们对比了四种不同的库存扣减实现方案:

  1. 错误示范(V0):不加锁直接读取和更新库存,在并发环境下会导致超卖问题。
  2. 悲观锁实现(V1):使用FOR UPDATE语句在查询时加锁,保证同一时间只有一个事务可以操作库存,但会导致请求串行化,系统吞吐量受限。
  3. 乐观锁实现(V2):通过版本号机制实现无锁并发控制,避免了长时间的锁等待,但在高并发下成功率较低,可能导致大量请求失败。
  4. 优化实现(V3):使用单条带条件的UPDATE语句实现原子性操作,既保证了数据一致性,又提高了系统的并发处理能力。

这四种实现方案各有优缺点,在实际应用中可以根据业务场景和性能需求进行选择。其中,第四种实现是最为推荐的方案,它在保证数据一致性的同时,提供了较好的并发性能。

然而,在实际的秒杀系统中,仅依靠数据库层面的优化是不够的。当并发量达到一定程度时,数据库很容易成为系统的瓶颈,影响整体性能和用户体验。

6. 展望:Redis优化篇

在下一篇《从零开始实现秒杀系统(二):Redis优化篇》中,我们将继续深入探讨如何优化秒杀系统,重点介绍如何利用Redis来提升系统性能。主要内容将包括:

  1. Redis预减库存:使用Redis缓存库存信息,在Redis中预先扣减库存,减轻数据库压力。
  2. 异步下单:通过消息队列实现异步下单,提高系统吞吐量。
  3. Redis分布式锁:使用Redis实现分布式锁,保证分布式环境下的数据一致性。

通过引入Redis等中间件技术,我们将构建一个更加高效、稳定的秒杀系统,能够承受更高的并发压力,为用户提供更好的秒杀体验。敬请期待!


本文源码已开源在GitHub:Goinggoinggoing/seckill

如有疑问或建议,欢迎在评论区讨论!