秒杀(Seckill)是电商系统中最具挑战性的业务场景之一,其核心特征是瞬时高并发。在短短几秒钟内,流量峰值可能达到平时的成百上千倍。这种场景下,系统不仅要抗住流量,更要保证数据的绝对准确(不能超卖)和业务规则的严格执行(一人一单)。

本文将作为《黑马点评》秒杀篇分析的第一章,从最基础的超卖问题入手,一步步构建全局唯一 ID,并深度辨析乐观锁与悲观锁

一、 灾难前夜:超卖问题的诞生

1. 什么是超卖?

简单来说,就是卖出的商品数量超过了实际库存。例如秒杀库存只有 100 件,最终却产生了 105 个订单。这在电商业务中是严重的生产事故,可能导致平台亏损和用户投诉。

2. 为什么会超卖?

从技术角度看,超卖的本质是竞态条件 (Race Condition)。在传统的多线程环境下,扣减库存通常分为两步:

  1. 查询库存SELECT stock FROM table WHERE id = 1
  2. 扣减库存UPDATE table SET stock = stock - 1 WHERE id = 1

这看似逻辑正确,但在高并发下:

  • 线程 A 查询库存,发现 stock = 1。
  • 线程 B 同时查询库存,发现 stock = 1(此时线程 A 还没来得及扣减)。
  • 线程 A 执行扣减,stock 变为 0,下单成功。
  • 线程 B 执行扣减,stock 变为 -1,下单成功。
    于是,库存瞬间变成了 -1,超卖发生了。

二、 基础设施:全局唯一 ID 生成器

在设计秒杀订单表时,我们面临第一个抉择:订单 ID 用什么?
MySQL 的自增 ID 虽然简单,但在分库分表、安全性(避免销量被爬虫推算)等方面存在天然缺陷。因此,我们需要一个高性能、全局唯一、递增的 ID 生成器。

Redis 自增方案 (RedisIDWorker)

利用 Redis 的原子性递增命令 INCR,我们可以轻松实现 ID 的分配。为了增加安全性并支持时间排序,我们设计了 64 位 的 ID 结构:

结构 位数 作用
符号位 1 bit 永远为 0,保证 ID 为正数
时间戳 31 bits 记录 ID 生成时间(秒级),可支持约 69 年
序列号 32 bits 每秒内的自增计数器,支持每秒 $2^{32}$ 并发

核心代码实现 (RedisIDWorker.java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Component
public class RedisIDWorker {
@Resource
private StringRedisTemplate stringRedisTemplate;

private static final long BEGIN_TIMESTAMP = 1640995200L; // 2022-01-01 00:00:00
private static final int COUNT_BITS = 32; // 序列号位数

public Long nextId(String keyPrefix) {
// 1. 生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;

// 2. 生成序列号
// key 策略:inc:业务前缀:日期 (如 inc:order:20251219)
// 加上日期是为了防止 key 自增数值无限增长超过 Redis 上限,同时也方便统计每日单量
String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

// 3. 拼接并返回:时间戳左移 32 位 + 序列号
return (timestamp << COUNT_BITS) | count;
}
}

三、 攻克超卖:乐观锁 vs 悲观锁

面对超卖,核心解决思路是加锁。但加什么锁?加在哪?

1. 悲观锁 (Pessimistic Lock)

理念:总有刁民想害朕。假设并发冲突一定会发生,所以操作前必须先拿到锁。
实现SynchronizedReentrantLock、数据库 SELECT ... FOR UPDATE
弊端:性能杀手。所有请求串行化,高并发下大量线程阻塞,系统吞吐量极低。

2. 乐观锁 (Optimistic Lock)

理念:天下太平。假设由于并发导致的冲突是极少数,所以不加锁。只在更新数据的那一刻,检测一下数据是否被别人修改过。
实现:CAS (Compare And Swap) 思想,通常配合版本号 (Version)状态校验
优势:无锁编程,性能极高。

方案进化:从版本号到库存校验

阶段一:传统的版本号法

1
2
UPDATE voucher SET stock = stock - 1, version = version + 1 
WHERE id = 1 AND version = old_version

问题:在高并发秒杀中,100 个线程同时抢,只有 1 个能成功更新,其他 99 个因为 version 变了而失败。这会导致大量失败请求,用户体验极差(明明有库存却提示抢失败了)。

阶段二:库存校验法 (CAS 变种)
我们需要的是“不超卖”,而不是“库存值必须完全一致”。只要库存 > 0,就允许扣减!

1
2
3
4
5
6
// 利用 MyBatis-Plus
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // update set stock = stock - 1
.eq("voucher_id", voucherId) // where id = ?
.gt("stock", 0) // AND stock > 0 (关键优化!)
.update();

效果:只要库存充足,所有并发请求都能成功扣减,既保证了数据安全,又大大提升了并发成功率。


四、 业务升级:一人一单与并发安全

秒杀不仅要解决技术上的超卖,还要解决业务上的公平性——限制同一用户只能买一单

1. 逻辑实现

在扣减库存前,先去订单表查询一下:

1
2
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) return "您已购买过";

2. 并发漏洞再现

这又是一个典型的检查-然后再执行 (Check-Then-Act) 模式。
当同一个用户开启多线程(黄牛脚本)并发请求时:

  1. 线程 A 查订单,count = 0(未购买)。
  2. 线程 B 查订单,count = 0(未购买)。
  3. 线程 A 扣库存、创建订单。
  4. 线程 B 扣库存、创建订单。
    结果:一个用户下了两单,“一人一单”限制失效。

3. 悲观锁的回归

这次能用乐观锁吗?不能。因为主要操作是 INSERT(创建订单),数据还不存在,无法判断“版本号”。这里必须使用悲观锁来强制串行化。

代码细节 (VoucherOrderServiceImpl.java):

1
2
3
4
5
6
7
8
Long userId = UserHolder.getUser().getId();
// 锁粒度优化:只锁当前用户,而不是锁整个方法 (this)
// userId.toString().intern():强制从常量池获取字符串引用,确保同一用户的 userId 对象是同一个
synchronized (userId.toString().intern()) {
// 事务代理对象获取:防止事务失效(见知识小窗)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}

:::tip 知识小窗 | Java 并发与 Spring 事务陷阱

1. 为什么锁 userId 而不是 this?

如果 synchronized(this),锁的是整个 Service 对象。这意味着全站所有用户秒杀都要排队,性能会由 10000 TPS 掉到 10 TPS。锁 userId 可以实现分段锁的效果——同一个用户排队,不同用户并行。

2. 为什么需要 userId.toString().intern()

每次请求 new 出来的 Long 对象即使值一样,也是不同的对象地址。intern() 方法强转去字符串常量池拿引用,保证了只要 ID 值一样,拿到的锁对象就是同一个。

3. 事务失效与 AopContext

Spring 的 @Transactional 是基于 AOP 动态代理实现的。

  • 如果我们直接调用 this.createVoucherOrder(),相当于类内部自调用,绕过了代理对象,事务增强逻辑不会执行。
  • 必须使用 AopContext.currentProxy() 获取当前代理对象,再调用方法,事务才会生效。
  • 注意:需要在 pom.xml 引入 aspectjweaver 并在启动类添加 @EnableAspectJAutoProxy(exposeProxy = true)
    :::

五、 新的危机:单体锁在集群下的失效

到目前为止,在单体部署(单个 Tomcat)的环境下,我们的代码是完美的:

  • 超卖问题:用 SQL 乐观锁解决 (stock > 0)。
  • 一人一单:用 synchronized(userId) 悲观锁解决。

但是,为了应对更高的并发,我们通常会把后端项目部署多份(Tomcat集群),前面加一个 Nginx 做负载均衡。

思考一下:当项目部署成集群(例如 2 个节点:Node A 和 Node B)时,synchronized 还有用吗?

  • synchronized 是 JVM 级别的锁,它的锁监视器对象存在于当前 JVM 的堆内存中。
  • Node A 这里的 synchronized 锁住了,只能拦住打到 Node A 的请求。
  • 如果黄牛开启脚本,一半请求打到 Node A,一半请求打到 Node B。
    • Node A 的线程获取了 userId 锁,正在执行。
    • Node B 的线程在自己的 JVM 里,完全看不到 Node A 的锁,它也能获取到自己 JVM 里的 userId 锁。
  • 结果:两个节点的线程同时通过了校验,一人一单再次失效!

六、 压力测试工具初步学习:JMeter

在完成秒杀逻辑的优化(超卖处理 + 一人一单本地锁)后,我们需要验证代码在并发环境下的稳定性。这里我们引入了 Apache JMeter

1. 为什么选择 JMeter?

JMeter 是一款纯 Java 编写的开源压力测试工具,它能够模拟大量并发用户请求,是开发人员进行性能调优和排查并发 Bug 的利器。在秒杀场景中,我们主要关注以下指标:

  • Sample Count(样本量):总请求数。
  • Error %(异常率):失败请求的比例。
  • Throughput(吞吐量):QPS(每秒处理请求数)。

2. 测试场景模拟

我们模拟了多个线程(不同用户 Token)同时请求“一人一单”抢购接口:

  • 超卖验证:确保库存扣减不为负数。
  • 一人一单验证:确保同一个用户 ID 只有第一次请求能成功下单。

3. 测试结果观察

如图,经过测试,只有第一个线程能买,后续的都被 pass,说明一人一单和超卖问题都被基本解决:

JMeter测试结果

通过 JMeter 的聚合报告和结果树,我们可以清晰地看到分布式环境前夕本地锁的最后“余辉”。

结论:在分布式/集群环境下,Java 本地锁(Synchronized/Lock)会失去作用。我们需要一种能跨越多个 JVM、跨越多个进程的全局锁机制——这就是下一篇的核心主角:分布式锁

下回预告:《从 Redis SETNX 到 Redisson:分布式锁的进化之路》