秒杀团源码拆解:3个新手避坑点,彻底解决配置卡壳难题
刚拿到一个基于 Spring Cloud 的秒杀系统源码,想跑起来看看底层逻辑?别急,先看看你是不是也卡在 git clone 之后,Maven 报错一堆,Redis 连接超时,前端页面打不开。这种“配置环境就卡半天”的经历,90% 的新手都逃不掉。很多人以为是自己网络不行,或者电脑配置低,其实大概率是踩了依赖版本不兼容或者本地环境缺失的坑。今天不讲虚的,直接拆官方源码仓库里最核心的 SeckillService 和 RedisLock 部分,带你用源码视角看懂“秒杀团”背后的并发控制逻辑。这不仅是为了跑通项目,更是为了在面试里能说出点东西。记住,新手避坑的核心不是盲目复制教程,而是理解每一行代码在干什么,为什么这么干。
入口定位:从 Controller 到核心服务
打开 SeckillController,这是 HTTP 请求的入口。新手最容易犯的错误是,一上来就盯着 Controller 里的参数校验看,觉得那里很复杂。其实,真正的业务逻辑都在 Service 层。我们看一个典型的 seckill 方法:
/*** 秒杀接口* @param skuId 商品ID* @return 秒杀结果*/
@PostMapping(/seckill)
public ResultLong seckill(@RequestParam(skuId) Long skuId) {// 1. 参数校验if (skuId == null || skuId = 0) {return Result.error(参数错误);}// 2. 核心秒杀逻辑Long orderId = seckillService.seckill(skuId);// 3. 返回结果if (orderId != null) {return Result.success(orderId);} else {return Result.error(秒杀失败);}
}这段代码很直白,但关键在于 seckillService.seckill(skuId) 这一行。很多新手在这里会困惑:为什么不用 @Transactional 注解直接加在 Controller 上?因为 Controller 层不应该处理业务逻辑,更不应该管理事务。事务应该由 Service 层统一管理。如果你在这里加了事务,一旦底层 Redis 操作抛出异常,Spring 的事务回滚机制可能会因为异常类型不匹配而失效,导致数据不一致。这是新手避坑的第一条铁律:分层职责清晰,事务下沉到 Service。
接下来,我们进入 SeckillService 的 seckill 方法。这里才是真正“刀光剑影”的地方。
核心片段:Redis 预减与 Lua 脚本原子性
秒杀的核心痛点是什么?高并发下,库存超卖。传统的 if (stock 0) { stock--; } 在多线程环境下是灾难性的。源码中,作者使用了 Redis 的 Lua 脚本来保证原子性。我们来看 RedisLock 工具类中的核心片段:
/*** 执行 Lua 脚本,原子性地检查并扣减库存* @param skuId 商品ID* @return 1: 成功, -1: 库存不足, -2: 重复请求*/
public Long deductStock(Long skuId) {// 定义 Lua 脚本String script = local stock = tonumber(redis.call('get', KEYS[1])) +if stock == nil then + return -1 +elseif stock = 0 then + return -1 +else + local result = redis.call('decr', KEYS[1]) + return result +end;// 执行脚本Long result = (Long) redisTemplate.execute(new DefaultRedisScript(script, Long.class),Collections.singletonList(seckill:stock: + skuId));return result;
}逐行解析:String script = ...:这里定义了一段 Lua 代码。注意,Redis 执行 Lua 脚本时,整个脚本是一个原子操作,中间不会被其他客户端插入。这解决了“检查库存”和“扣减库存”之间的竞态条件。
tonumber(redis.call('get', KEYS[1])):从 Redis 中获取库存。KEYS[1] 是我们在调用时传入的 Key。
if stock == nil then ...:如果 Key 不存在(比如初始化失败),直接返回 -1。这比在 Java 层判断 null 更安全,因为避免了网络延迟导致的数据不一致。
elseif stock = 0 then ...:库存不足,返回 -1。
redis.call('decr', KEYS[1]):原子性地减一。decr 命令本身是原子的,但放在 Lua 里是为了和前面的 get 绑定,确保“看到的库存”和“扣的库存”是同一时刻的。
redisTemplate.execute(...):Java 层调用 Redis 执行脚本。DefaultRedisScript 是 Spring Data Redis 提供的脚本执行器。新手避坑点: 很多新手在本地调试时,发现库存扣减正常,但一上压力测试就超卖。原因往往是没有在启动时正确初始化 Redis 库存。源码中有一个 StockInitTask,它在应用启动时会将数据库中的库存同步到 Redis。如果你手动改了数据库,但没重启应用或没触发同步任务,Redis 里的库存就是旧的。务必检查 application.yml 中的 seckill.init 配置项。
设计思想:为什么是“预减”而不是“直减”?
看到这里,你可能会问:为什么不直接在数据库里 UPDATE stock SET count = count - 1 WHERE count 0?这就是源码设计的精妙之处。
1. 数据库扛不住高并发
秒杀流量是瞬时的,可能是平时的 100 倍甚至 1000 倍。数据库的连接池是有限的(通常配置在 20-50 个),而 Redis 的连接池可以开到几百甚至上千。如果所有请求都打到数据库,数据库瞬间就会因为连接耗尽而崩溃。
2. 缓存是“挡箭牌”
源码采用“缓存预减”策略。所有请求先打到 Redis,只有 Redis 扣减成功的请求,才会继续走到数据库层去创建订单。这样,99% 的请求会在 Redis 层被拦截(因为库存很快被扣完),只有极少数(等于库存数量)的请求能穿透到数据库。这极大地保护了数据库。
3. 异步落库
注意,Redis 扣减成功后,并不是同步地写数据库。源码中,SeckillService 在扣减成功后,会将订单信息发送到 MQ(消息队列,如 RabbitMQ 或 RocketMQ)。消费者异步地消费消息,再写入数据库。这样,用户前端看到的“秒杀成功”是立即返回的(基于 Redis 状态),而数据库的写入是异步的。
这里有一个巨大的坑: 如果 MQ 消息丢失怎么办?源码中采用了“本地消息表”或者“事务消息”来保证最终一致性。但在新手项目中,很多人忽略了这一点,导致 Redis 扣减了,但数据库没订单,用户投诉“我付款了却没货”。新手避坑:务必检查 MQ 的可靠性配置,或者至少实现一个重试机制。
手写简化版:理解核心逻辑
为了让你真正吃透,我手写一个简化版的 SeckillService,去掉了复杂的分布式锁和 MQ,只保留核心逻辑。你可以把这个代码放在自己的项目里跑一跑:
@Service
public class SimpleSeckillService {@Autowiredprivate RedisTemplateString, Object redisTemplate;@Autowiredprivate OrderMapper orderMapper;/*** 简化版秒杀逻辑*/public String seckill(Long skuId, Long userId) {String stockKey = seckill:stock: + skuId;String userKey = seckill:user: + skuId + : + userId;// 1. 检查用户是否已购买 (防重)if (redisTemplate.hasKey(userKey)) {return 用户已购买;}// 2. 尝试扣减库存 (使用 Lua 脚本保证原子性)String script = local stock = tonumber(redis.call('get', KEYS[1])) +if stock == nil or stock = 0 then + return -1 +else + redis.call('decr', KEYS[1]) + return 1 +end;Long result = (Long) redisTemplate.execute(new DefaultRedisScript(script, Long.class),Collections.singletonList(stockKey));if (result == -1) {return 库存不足;}// 3. 标记用户已购买 (防止并发下同一用户多次请求)redisTemplate.opsForValue().set(userKey, 1, 24, TimeUnit.HOURS);// 4. 异步创建订单 (这里简化为同步,实际应使用 MQ)try {Order order = new Order();order.setUserId(userId);order.setSkuId(skuId);order.setStatus(1); // 1: 待支付orderMapper.insert(order);return 秒杀成功,订单ID: + order.getId();} catch (Exception e) {// 5. 异常处理:如果数据库插入失败,需要回滚 Redis 库存redisTemplate.opsForValue().increment(stockKey);redisTemplate.delete(userKey);return 系统繁忙,请稍后重试;}}
}逐行关键点:redisTemplate.hasKey(userKey):这是第一道防线。在扣库存之前,先看看这个用户是不是已经买过了。这比在数据库里查 SELECT 要快得多。
set(userKey, 1, 24, TimeUnit.HOURS):设置过期时间为 24 小时。这是为了防止用户长期占用名额。但注意,如果用户秒杀成功后不支付,24 小时后名额会释放。实际业务中,可能需要更复杂的“超时未支付自动释放”逻辑。
catch (Exception e):这是最容易被新手忽略的部分。如果数据库插入失败(比如死锁、连接超时),必须回滚 Redis 库存,否则就会出现“Redis 库存扣了,但数据库没订单”的情况,导致库存少卖。很多开源项目在这里处理得不够严谨,你需要仔细检查源码中的异常处理逻辑。应用场景与面试延伸
理解了这套源码,你就能回答很多面试问题。比如:Q: 如何防止超卖?A: 使用 Redis Lua 脚本原子性地检查和扣减库存,确保高并发下的数据一致性。Q: 如何防止同一用户重复秒杀?A: 在 Redis 中设置用户维度的 Key,利用 Redis 的单线程特性保证原子性,或者使用布隆过滤器(Bloom Filter)进行初步过滤。Q: 如果 Redis 宕机了怎么办?A: 这是一个进阶问题。通常采用主从复制 + Sentinel 哨兵模式,或者使用 Redis Cluster。更高级的做法是,在 Redis 宕机时,降级到数据库限流,或者直接返回“系统维护中”,避免数据库被击穿。实战建议:本地环境准备:确保你安装了 Redis 6.0+,并配置了 maxmemory 和 eviction-policy。
压测工具:不要只手动点按钮。使用 JMeter 或 Locust 进行压测,观察 Redis 的 CPU 使用率和数据库的连接数变化。
监控告警:在 SeckillService 中加入日志记录,记录每次秒杀的请求耗时、库存变化。使用 ELK 栈进行日志收集,方便排查问题。最后,回到那个让你头疼的“配置环境就卡半天”的问题。 现在你应该明白,卡住你的不是环境,而是你对底层原理的无知。当你看懂了 Lua 脚本的原子性,看懂了 Redis 预减的保护作用,你再去看那些配置文件,就会发现它们不过是这些逻辑的“参数化”体现。
这个知识点你面试被问过吗?留言说说,特别是关于“Redis 宕机后的降级策略”和“MQ 消息丢失的补偿机制”,这两个点往往是区分初级和中级工程师的分水岭。
