【黑马点评项目笔记 | 优惠券秒杀篇】构建高并发秒杀系统

前言

本篇笔记聚焦于黑马点评项目中技术挑战最为密集、设计最为精妙的模块------优惠券秒杀 。秒杀业务作为电商系统的经典场景,其核心特征是在极短时间内面临巨大的并发请求,这对系统的高性能、高可用、数据一致性及安全性提出了严峻考验。本模块的实战,不仅是对Redis各项特性(计数器、分布式锁、Lua脚本、消息队列)的深度运用,更是一次完整的、从单体到分布式环境下解决高并发典型问题的思维训练。

我将从最基础的生成唯一订单号开始,逐步构建下单流程,随后暴露并解决超卖一人一单 等并发安全问题。在集群部署时,我们将发现传统锁的局限,从而引入分布式锁 ,并对其进行逐步优化。为了应对极致并发,我们将业务流程异步化 ,利用Redis的Stream消息队列 实现下单与处理的解耦,最终构建一个健壮、高效的秒杀系统。


今日完结任务

  1. 完成全局唯一ID生成器:设计并实现基于Redis的分布式ID生成服务,解决数据库自增ID的规律性明显和分库分表限制问题。
  2. 实现基础秒杀下单功能:完成优惠券查询、库存校验、库存扣减和订单创建的完整流程。
  3. 解决库存超卖问题:分析超卖原因,通过数据库乐观锁方案防止库存被扣减至负数。
  4. 实现一人一单限制:在乐观锁基础上,增加同一用户只能购买一次的限制,并处理其带来的并发安全问题。
  5. 引入并优化分布式锁:在集群环境下,使用Redis实现分布式锁,解决一人一单逻辑的并发问题,并处理锁误删、原子性等细节。
  6. 使用Redisson优化分布式锁:集成Redisson客户端,利用其提供的可重入锁、锁重试、看门狗自动续期等高级特性,简化开发。
  7. Redis优化秒杀流程:将秒杀资格判断(库存、一人一单)前移至Redis中,使用Lua脚本保证原子性,实现请求的快速拦截。
  8. 实现异步秒杀下单:基于阻塞队列和Redis Stream消息队列两种方案,将核心的下单逻辑异步化处理,提升系统吞吐量和响应速度。

今日核心知识点总结

1. 全局唯一ID生成器

为什么需要?

在分布式系统中,数据库自增ID存在两个主要问题:

  1. 规律性明显,容易暴露业务量信息,存在安全风险;
  2. 受单表数据量限制,在分库分表时难以保证全局唯一。

核心方案(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"两种变体。

  1. 版本号法 :表中增加version字段,更新时对比版本号。

    sql 复制代码
    UPDATE seckill_voucher SET stock = stock - 1, version = version + 1 
    WHERE voucher_id = ? AND version = ?;
  2. 条件判断法(本项目采用) :直接用业务字段(库存stock)作为判断条件。

    sql 复制代码
    UPDATE seckill_voucher SET stock = stock - 1 
    WHERE voucher_id = ? AND stock > 0;

    为什么选择stock > 0而非stock = 查询时的库存

    因为后者成功率极低(100个线程同时查到库存100,只有1个能成功)。而stock > 0在库存充足时能允许更多线程并发成功,只在库存临近耗尽时起到保护作用,更符合秒杀场景。

3. 分布式锁解决一人一单问题

为什么需要?

在单机服务中,使用synchronizedReentrantLock可以保证"一人一单"等逻辑的线程安全。但在集群部署下,多个Tomcat实例拥有独立的JVM,其内部的锁互不干扰,无法实现跨JVM的互斥。
分布式锁的概念: 满足在分布式系统或集群模式下多进程可见且互斥的锁。
分布式锁的实现对比:

分布式锁核心要求 :多进程可见、互斥、高可用、高性能、安全。

Redis实现方案(SETNX + EX)

  1. 获取锁SET lock:order:userId uuid:threadId EX 10 NX,利用NX(不存在才设置)保证互斥,EX设置超时防止死锁。
  2. 释放锁 :先判断锁标识是否属于当前线程,是则删除。必须保证"判断+删除"的原子性 ,使用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());
}
  1. 误删问题 :若线程A阻塞导致锁超时释放,线程B获取锁,此时线程A恢复并执行删除,会误删B的锁。解决方案:在锁Value中存入唯一线程标识 (UUID + ThreadID),释放时先验证再删除。
  2. 原子性问题 :验证标识和删除锁是两个操作,非原子。解决方案:使用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的异步秒杀流程

  1. Lua脚本判断秒杀资格,若通过,则向Stream stream.orders 发送消息(包含userId, voucherId, orderId)。
  2. 独立线程作为消费者,从stream.orders中读取消息。
  3. 消费者处理消息(完成数据库下单),成功后发送ACK确认。
  4. 若消费失败,消息会留在Pending-List,由另一个线程专门处理。

遇到的问题

问题:异步秒杀中"处理订单异常"无限循环报错

现象:项目启动后,控制台持续、快速地打印"处理订单异常"或"处理pending订单异常"日志,形成死循环。

根因分析

  1. 直接原因 :代码中使用了Redis Stream的XREADGROUP命令,但对应的消费者组(g1)并未预先创建XREADGROUP命令要求消费者组必须已存在,否则会直接抛出NOGROUP错误,而不是返回空。
  2. 循环链条
    • 主线程(VoucherOrderHandler)尝试用XREADGROUP读取消息,因消费者组不存在而异常。
    • 异常被捕获,调用handlePendingList()方法。
    • handlePendingList()内部同样使用XREADGROUP(指定ID0读取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本身没有这个功能。


今日实战收获

业务实现流程梳理

  1. 基础下单

    • 查询优惠券信息(时间、库存)。
    • 校验通过后,使用乐观锁(stock > 0)扣减数据库库存。
    • 生成全局唯一ID,创建订单。
    • 问题暴露:存在超卖风险。
  2. 一人一单

    • 在扣库存前,先查询该用户是否已存在订单。
    • 并发问题:多个线程可能同时查询到"无订单",然后都去创建。需要使用锁。
    • 单机锁失效 :在集群环境下,synchronized锁不住不同JVM的线程。
    • 引入分布式锁 :基于Redis SETNX实现,锁Key为用户ID,实现用户维度的互斥。
  3. 分布式锁优化

    • 解决误删:锁Value中加入线程唯一标识(UUID+ThreadID)。
    • 保证原子性:使用Lua脚本将"判断标识"和"删除锁"合并为一个原子操作。
  4. 性能优化:资格判断前置

    • 将"库存是否充足"和"是否一人一单"的逻辑提前到Redis中完成。
    • 库存 :使用String类型,Key为seckill:stock:{id},秒杀开始前从DB同步。
    • 一人一单 :使用Set类型,Key为seckill:order:{voucherId},用户下单成功后添加其userId
    • 原子性保证 :编写Lua脚本,将库存判断(GET)、用户判断(SISMEMBER)、库存扣减(INCRBY)、用户记录(SADD)等多个操作原子执行。
  5. 架构优化:异步下单

    • 目标:将耗时的数据库订单创建操作异步化,使秒杀接口瞬间响应。
    • 方案一:阻塞队列
      • Lua脚本校验通过后,将订单信息(VoucherOrder对象)放入JVM内存的BlockingQueue
      • 启动一个单线程池,不断从队列中获取任务并执行下单。
      • 缺点:内存限制、数据丢失风险、无法持久化。
    • 方案二:Redis Stream消息队列(最终采用)
      • 修改Lua脚本,在最后一步,使用XADD命令将订单信息发送到Stream stream.orders
      • 独立线程作为消费者组g1的消费者,从Stream中读取消息并处理下单。
      • 下单成功后,调用XACK确认消息。
      • 单独处理Pending-List中的异常消息,保证可靠性。
      • 优点:解耦彻底、数据持久化、支持多实例消费、有消息确认机制。

关键代码片段

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(); // 处理异常消息
            }
        }
    }
}

小知识点总结

  1. Session原理 :依赖Cookie中的JSESSIONID,Tomcat根据此ID在服务器内存中找到对应的Session对象。多Tomcat时Session不共享。
  2. ThreadLocal :用于线程隔离。在拦截器中,将从Redis/ Session获取的用户信息存入ThreadLocal,在同一次请求的后续流程中(如Service层)可随时获取。
  3. BeanUtil工具类BeanUtil.copyProperties(src, target)用于对象属性拷贝;BeanUtil.beanToMap可将对象转为Map,需注意配置CopyOptions将非String字段转为String,以便存入StringRedisTemplate
  4. @Transactional失效场景 :在同一个类中,方法A调用方法B,即使B有@Transactional注解,事务也不会生效。因为事务基于AOP代理,this.调用不走代理。解决方案:使用AopContext.currentProxy()获取代理对象再调用。
  5. 锁粒度控制 :加锁范围越小,并发性能越好。例如"一人一单"锁,应锁用户ID (synchronized(userId.toString().intern())),而不是锁整个方法。
  6. intern()方法 :返回字符串在常量池中的引用。用于保证相同值的字符串synchronized锁的是同一个对象。
  7. 位运算生成IDtimestamp << COUNT_BITS | count,左移后低位补0,再与序列号按位或,实现高效拼接。
  8. Boolean判空Boolean success = redisTemplate.opsForValue().setIfAbsent(...)。直接返回success可能因自动拆箱null导致NPE。安全写法:Boolean.TRUE.equals(success)
  9. Stream的XREADGROUP :命令严格,要求消费者组必须预先存在,否则直接抛异常,不是返回空。务必在消费前创建好消费者组(XGROUP CREATE ... MKSTREAM)。

总结

通过优惠券秒杀模块的完整实践,我们完成了一次从"单体并发"到"分布式高并发"系统设计的思维升级。核心线索是不断发现问题、分析原因、选择并实施解决方案

  1. 从业务到并发安全 :我们首先实现了基础业务逻辑,随即遇到了超卖一人一单 这两个经典的并发安全问题。通过乐观锁悲观锁(synchronized) 我们解决了单机环境下的问题。
  2. 从单机到分布式 :集群部署打破了单机锁的屏障,我们引入了基于Redis的分布式锁 。在实现过程中,我们深入处理了锁误删、原子性等细节问题,认识到分布式编程的复杂性。
  3. 从复杂到优雅 :自研分布式锁繁琐且存在缺陷(不可重入、无自动续期)。通过集成Redisson,我们使用了生产级的分布式锁组件,极大地提升了开发效率和系统可靠性。
  4. 从同步到异步 :为了应对真正的高并发洪峰,我们将系统架构演进为异步化 。核心思想是判断与执行分离 :利用Redis和Lua脚本实现毫秒级的资格判断与快速响应,将耗时的订单创建操作通过消息队列(Stream) 异步处理。这不仅提升了吞吐量,也通过解耦增强了系统的可维护性和可扩展性。

最终,我们构建的秒杀系统具备了以下特点:快速拦截无效请求、无状态的资格校验、异步可靠的下单流程、以及完善的异常处理机制。这不仅是一个功能实现,更是一次对高并发系统设计理念、Redis深度应用及分布式问题解决方案的全面演练。

相关推荐
梦梦代码精2 小时前
Gitee 年度人工智能竞赛开源项目评选揭晓!!!
开发语言·数据库·人工智能·架构·gitee·前端框架·开源
ruleslol2 小时前
普通流(Stream<T>)和原始类型特化流(IntStream, LongStream, DoubleStream)的区别
java
隐退山林2 小时前
JavaEE初阶:文件操作和IO
java·java-ee
2501_907136822 小时前
PDF增效工具 Quite imposing plus6
java·开发语言
l1t2 小时前
DeepSeek总结的postgresql扩展方案文章
数据库·postgresql
潇冉沐晴2 小时前
div2 1064补题笔记(A~E)
笔记·算法
常利兵2 小时前
Android Gradle 构建脚本现代化:Kotlin DSL (.kts) 与 Groovy DSL 深度对比与实战指南
android·开发语言·kotlin
Jaxson Lin2 小时前
Java编程进阶:智能仿真无人机项目3.0
java·笔记·无人机
crossaspeed2 小时前
MySQL-锁
数据库·mysql