前言
本篇笔记聚焦于黑马点评项目中技术挑战最为密集、设计最为精妙的模块------优惠券秒杀 。秒杀业务作为电商系统的经典场景,其核心特征是在极短时间内面临巨大的并发请求,这对系统的高性能、高可用、数据一致性及安全性提出了严峻考验。本模块的实战,不仅是对Redis各项特性(计数器、分布式锁、Lua脚本、消息队列)的深度运用,更是一次完整的、从单体到分布式环境下解决高并发典型问题的思维训练。
我将从最基础的生成唯一订单号开始,逐步构建下单流程,随后暴露并解决超卖 、一人一单 等并发安全问题。在集群部署时,我们将发现传统锁的局限,从而引入分布式锁 ,并对其进行逐步优化。为了应对极致并发,我们将业务流程异步化 ,利用Redis的Stream消息队列 实现下单与处理的解耦,最终构建一个健壮、高效的秒杀系统。
今日完结任务
- 完成全局唯一ID生成器:设计并实现基于Redis的分布式ID生成服务,解决数据库自增ID的规律性明显和分库分表限制问题。
- 实现基础秒杀下单功能:完成优惠券查询、库存校验、库存扣减和订单创建的完整流程。
- 解决库存超卖问题:分析超卖原因,通过数据库乐观锁方案防止库存被扣减至负数。
- 实现一人一单限制:在乐观锁基础上,增加同一用户只能购买一次的限制,并处理其带来的并发安全问题。
- 引入并优化分布式锁:在集群环境下,使用Redis实现分布式锁,解决一人一单逻辑的并发问题,并处理锁误删、原子性等细节。
- 使用Redisson优化分布式锁:集成Redisson客户端,利用其提供的可重入锁、锁重试、看门狗自动续期等高级特性,简化开发。
- Redis优化秒杀流程:将秒杀资格判断(库存、一人一单)前移至Redis中,使用Lua脚本保证原子性,实现请求的快速拦截。
- 实现异步秒杀下单:基于阻塞队列和Redis Stream消息队列两种方案,将核心的下单逻辑异步化处理,提升系统吞吐量和响应速度。
今日核心知识点总结
1. 全局唯一ID生成器
为什么需要?
在分布式系统中,数据库自增ID存在两个主要问题:
- 规律性明显,容易暴露业务量信息,存在安全风险;
- 受单表数据量限制,在分库分表时难以保证全局唯一。
核心方案(Snowflake算法变体):
我们采用类似Twitter Snowflake的算法,使用64位Long型数字,结构如下:
- 符号位 (1 bit):恒为0,保证ID为正数。
- 时间戳 (31 bit) :以秒为单位的差值(当前时间 - 自定义起始时间),可使用约
2^31 / (365*24*3600) ≈ 69年。 - 序列号 (32 bit) :同一秒内的自增计数,支持每秒生成
2^32(约43亿)个不同ID。
Redis实现要点:
- 时间戳 :获取当前时间戳,减去固定的开始时间戳(如
2022-01-01 00:00:00)。 - 序列号 :使用Redis的
INCR命令实现自增。关键设计 :序列号的Key不固定,而是拼接了日期(如icr:order:2025:01:28),这样既能避免单个Key计数上限问题,又便于按天统计订单量。 - 拼接 :通过位运算将时间戳左移32位后,与序列号进行或运算(
|)合并。
java
@Component
public class RedisIdWorker {
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包含日期,便于统计和防溢出)
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long count = stringRedisTemplate.opsForValue()
.increment("icr:" + keyPrefix + ":" + date);
// 3. 拼接并返回 (位运算)
return timestamp << COUNT_BITS | count;
}
}
2. 数据库乐观锁解决超卖问题
问题根源 :

超卖是典型的"读-修改-写"并发安全问题。多个线程同时查询到库存充足,然后依次进行扣减,导致最终库存为负数。

解决方案对比 :
- 悲观锁 (如
synchronized,SELECT ... FOR UPDATE):假定并发冲突概率高,先加锁再操作。保证强一致性,但并发性能差。 - 乐观锁:假定并发冲突概率低,通过版本号机制在提交时检查数据是否被其他线程修改过。并发性能好。
乐观锁实现(CAS思想变体) :
我们采用"带版本号的CAS "和"条件判断式CAS"两种变体。
-
版本号法 :表中增加
version字段,更新时对比版本号。
sqlUPDATE seckill_voucher SET stock = stock - 1, version = version + 1 WHERE voucher_id = ? AND version = ?; -
条件判断法(本项目采用) :直接用业务字段(库存
stock)作为判断条件。sqlUPDATE seckill_voucher SET stock = stock - 1 WHERE voucher_id = ? AND stock > 0;为什么选择
stock > 0而非stock = 查询时的库存?因为后者成功率极低(100个线程同时查到库存100,只有1个能成功)。而
stock > 0在库存充足时能允许更多线程并发成功,只在库存临近耗尽时起到保护作用,更符合秒杀场景。
3. 分布式锁解决一人一单问题
为什么需要?
在单机服务中,使用synchronized或ReentrantLock可以保证"一人一单"等逻辑的线程安全。但在集群部署下,多个Tomcat实例拥有独立的JVM,其内部的锁互不干扰,无法实现跨JVM的互斥。
分布式锁的概念: 满足在分布式系统或集群模式下多进程可见且互斥的锁。
分布式锁的实现对比: 
分布式锁核心要求 :多进程可见、互斥、高可用、高性能、安全。
Redis实现方案(SETNX + EX):
- 获取锁 :
SET lock:order:userId uuid:threadId EX 10 NX,利用NX(不存在才设置)保证互斥,EX设置超时防止死锁。 - 释放锁 :先判断锁标识是否属于当前线程,是则删除。必须保证"判断+删除"的原子性 ,使用Lua脚本。

java
// 获取锁
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success); // 避免自动拆箱NPE
}
// 释放锁的Lua脚本
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
// unlock.lua
if (redis.call('get', KEYS[1]) == ARGV[1]) then
return redis.call('del', KEYS[1])
end
return 0
// 调用Lua脚本释放锁
public void unlock() {
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
- 误删问题 :若线程A阻塞导致锁超时释放,线程B获取锁,此时线程A恢复并执行删除,会误删B的锁。解决方案:在锁Value中存入唯一线程标识 (UUID + ThreadID),释放时先验证再删除。


- 原子性问题 :验证标识和删除锁是两个操作,非原子。解决方案:使用Lua脚本将多条命令合并为一个原子操作。


4. Redisson分布式锁
为什么需要?
Redis锁存在不可重入、不可重试、超时释放时间难设定、主从一致性问题 。
Redisson核心特性:
- 可重入锁:基于Redis Hash结构存储锁,Key为锁名,Field为线程标识,Value为重入次数。同一线程可多次获取锁。
- 看门狗机制 :如果未指定
leaseTime,Redisson会启动一个后台定时任务(看门狗),在锁过期前(默认过期时间的1/3)自动续期,避免业务未执行完锁已过期。 - 锁重试 :提供了
tryLock(long waitTime, ...)方法,在指定等待时间内循环尝试获取锁。 - MultiLock联锁:解决主从切换时的锁丢失问题。同时对多个独立的Redis节点加锁,全部成功才算加锁成功,可靠性极高但性能有损耗。
使用方式:
java
// 1. 配置RedissonClient
// 2. 使用锁
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS); // 等待1秒,锁持有10秒
try {
// 业务逻辑
} finally {
lock.unlock();
}
5. 异步秒杀与基于Stream结构的Redis消息队列
为什么需要?
同步下单流程(查询、校验、扣库存、创建订单)耗时,串行处理吞吐量低。需要将资格判断 和订单创建 解耦,实现快速响应结果 和异步处理。
Stream vs List vs PubSub :
Stream核心概念 :
- 消息:由Field-Value对组成,包含业务数据。
- 消费者组 (Consumer Group):将多个消费者编组,共同消费一个Stream,实现负载均衡。
- Pending-List :已读取但未确认(
ACK)的消息列表,用于处理消费失败的消息,保证至少消费一次。
基于Stream的异步秒杀流程:
- Lua脚本判断秒杀资格,若通过,则向Stream
stream.orders发送消息(包含userId,voucherId,orderId)。 - 独立线程作为消费者,从
stream.orders中读取消息。 - 消费者处理消息(完成数据库下单),成功后发送
ACK确认。 - 若消费失败,消息会留在Pending-List,由另一个线程专门处理。
遇到的问题
问题:异步秒杀中"处理订单异常"无限循环报错
现象:项目启动后,控制台持续、快速地打印"处理订单异常"或"处理pending订单异常"日志,形成死循环。
根因分析:
- 直接原因 :代码中使用了Redis Stream的
XREADGROUP命令,但对应的消费者组(g1)并未预先创建 。XREADGROUP命令要求消费者组必须已存在,否则会直接抛出NOGROUP错误,而不是返回空。 - 循环链条 :
- 主线程(
VoucherOrderHandler)尝试用XREADGROUP读取消息,因消费者组不存在而异常。 - 异常被捕获,调用
handlePendingList()方法。 handlePendingList()内部同样使用XREADGROUP(指定ID为0读取Pending-List),同样因为消费者组不存在而异常。- 该异常被内部
catch捕获,线程睡眠20ms后,继续while(true)循环,再次尝试,再次失败,形成无限循环。
- 主线程(
解决方案 :
在项目初始化时(如使用@PostConstruct),或在执行消费逻辑前,先判断并创建消费者组。
bash
# Redis CLI 手动创建
XGROUP CREATE stream.orders g1 0 MKSTREAM
或在Java代码中,使用stringRedisTemplate.opsForStream().createGroup()方法创建。
经验教训 :使用XREADGROUP前,务必确保消费者组已存在。Stream的"不存在即创建"特性(MKSTREAM)仅在使用XGROUP CREATE命令时有效,XREADGROUP本身没有这个功能。
今日实战收获
业务实现流程梳理
-
基础下单:
- 查询优惠券信息(时间、库存)。
- 校验通过后,使用乐观锁(
stock > 0)扣减数据库库存。 - 生成全局唯一ID,创建订单。
- 问题暴露:存在超卖风险。
-
一人一单:
- 在扣库存前,先查询该用户是否已存在订单。
- 并发问题:多个线程可能同时查询到"无订单",然后都去创建。需要使用锁。
- 单机锁失效 :在集群环境下,
synchronized锁不住不同JVM的线程。 - 引入分布式锁 :基于Redis
SETNX实现,锁Key为用户ID,实现用户维度的互斥。
-
分布式锁优化:
- 解决误删:锁Value中加入线程唯一标识(UUID+ThreadID)。
- 保证原子性:使用Lua脚本将"判断标识"和"删除锁"合并为一个原子操作。
-
性能优化:资格判断前置:
- 将"库存是否充足"和"是否一人一单"的逻辑提前到Redis中完成。
- 库存 :使用String类型,Key为
seckill:stock:{id},秒杀开始前从DB同步。 - 一人一单 :使用Set类型,Key为
seckill:order:{voucherId},用户下单成功后添加其userId。 - 原子性保证 :编写Lua脚本,将库存判断(
GET)、用户判断(SISMEMBER)、库存扣减(INCRBY)、用户记录(SADD)等多个操作原子执行。
-
架构优化:异步下单:
- 目标:将耗时的数据库订单创建操作异步化,使秒杀接口瞬间响应。
- 方案一:阻塞队列
- Lua脚本校验通过后,将订单信息(
VoucherOrder对象)放入JVM内存的BlockingQueue。 - 启动一个单线程池,不断从队列中获取任务并执行下单。
- 缺点:内存限制、数据丢失风险、无法持久化。
- Lua脚本校验通过后,将订单信息(
- 方案二:Redis Stream消息队列(最终采用)
- 修改Lua脚本,在最后一步,使用
XADD命令将订单信息发送到Streamstream.orders。 - 独立线程作为消费者组
g1的消费者,从Stream中读取消息并处理下单。 - 下单成功后,调用
XACK确认消息。 - 单独处理Pending-List中的异常消息,保证可靠性。
- 优点:解耦彻底、数据持久化、支持多实例消费、有消息确认机制。
- 修改Lua脚本,在最后一步,使用
关键代码片段
1. 解决超卖的乐观锁实现:
java
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
2. 一人一单的分布式锁应用:
java
Long userId = UserHolder.getUser().getId();
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
lock.unlock();
}
3. 秒杀资格判断的Lua脚本:
lua
local voucherId = ARGV[1]
local userId = ARGV[2]
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId
if(tonumber(redis.call('get', stockKey)) <= 0) then
return 1 -- 库存不足
end
if(redis.call('sismember', orderKey, userId) == 1) then
return 2 -- 重复下单
end
redis.call('incrby', stockKey, -1)
redis.call('sadd', orderKey, userId)
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
4. 基于Stream的消费者线程核心逻辑:
java
private class VoucherOrderHandler implements Runnable {
public void run() {
while (true) {
try {
// 1. 从stream.orders读取消息
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
// 2. 判断并处理消息
if (list != null && !list.isEmpty()) {
MapRecord<String, Object, Object> record = list.get(0);
// 3. 解析消息,创建订单
// ... (解析数据,调用createVoucherOrder)
// 4. 确认消息 ACK
stringRedisTemplate.opsForStream().acknowledge("stream.orders", "g1", record.getId());
}
} catch (Exception e) {
log.error("处理订单异常", e);
handlePendingList(); // 处理异常消息
}
}
}
}
小知识点总结
- Session原理 :依赖Cookie中的
JSESSIONID,Tomcat根据此ID在服务器内存中找到对应的Session对象。多Tomcat时Session不共享。 - ThreadLocal :用于线程隔离。在拦截器中,将从Redis/ Session获取的用户信息存入
ThreadLocal,在同一次请求的后续流程中(如Service层)可随时获取。 - BeanUtil工具类 :
BeanUtil.copyProperties(src, target)用于对象属性拷贝;BeanUtil.beanToMap可将对象转为Map,需注意配置CopyOptions将非String字段转为String,以便存入StringRedisTemplate。 - @Transactional失效场景 :在同一个类中,方法A调用方法B,即使B有
@Transactional注解,事务也不会生效。因为事务基于AOP代理,this.调用不走代理。解决方案:使用AopContext.currentProxy()获取代理对象再调用。 - 锁粒度控制 :加锁范围越小,并发性能越好。例如"一人一单"锁,应锁用户ID (
synchronized(userId.toString().intern())),而不是锁整个方法。 - intern()方法 :返回字符串在常量池中的引用。用于保证相同值的字符串
synchronized锁的是同一个对象。 - 位运算生成ID :
timestamp << COUNT_BITS | count,左移后低位补0,再与序列号按位或,实现高效拼接。 - Boolean判空 :
Boolean success = redisTemplate.opsForValue().setIfAbsent(...)。直接返回success可能因自动拆箱null导致NPE。安全写法:Boolean.TRUE.equals(success)。 - Stream的XREADGROUP :命令严格,要求消费者组必须预先存在,否则直接抛异常,不是返回空。务必在消费前创建好消费者组(
XGROUP CREATE ... MKSTREAM)。
总结
通过优惠券秒杀模块的完整实践,我们完成了一次从"单体并发"到"分布式高并发"系统设计的思维升级。核心线索是不断发现问题、分析原因、选择并实施解决方案:
- 从业务到并发安全 :我们首先实现了基础业务逻辑,随即遇到了超卖 和一人一单 这两个经典的并发安全问题。通过乐观锁 和悲观锁(synchronized) 我们解决了单机环境下的问题。
- 从单机到分布式 :集群部署打破了单机锁的屏障,我们引入了基于Redis的分布式锁 。在实现过程中,我们深入处理了锁误删、原子性等细节问题,认识到分布式编程的复杂性。
- 从复杂到优雅 :自研分布式锁繁琐且存在缺陷(不可重入、无自动续期)。通过集成Redisson,我们使用了生产级的分布式锁组件,极大地提升了开发效率和系统可靠性。
- 从同步到异步 :为了应对真正的高并发洪峰,我们将系统架构演进为异步化 。核心思想是判断与执行分离 :利用Redis和Lua脚本实现毫秒级的资格判断与快速响应,将耗时的订单创建操作通过消息队列(Stream) 异步处理。这不仅提升了吞吐量,也通过解耦增强了系统的可维护性和可扩展性。
最终,我们构建的秒杀系统具备了以下特点:快速拦截无效请求、无状态的资格校验、异步可靠的下单流程、以及完善的异常处理机制。这不仅是一个功能实现,更是一次对高并发系统设计理念、Redis深度应用及分布式问题解决方案的全面演练。