秒杀库存扣减失败抛出SeckillStockEmptyException是预期业务拒绝,需通过原子操作(MySQL单SQL或Redis Lua脚本)实现“判断+扣减”,DAO层不抛异常,Service层根据返回值决策并记录日志,前端、网关前置拦截,Controller返回200及明确业务码。

秒杀场景下库存扣减失败并抛出 SeckillStockEmptyException,本质是业务层面的库存耗尽信号,不是系统异常,而是**预期中的业务拒绝**。关键不在于“怎么捕获它”,而在于“如何正确设计扣减逻辑,让这个异常只在真正无库存时才抛出,且不被并发穿透”。
库存扣减必须走原子操作
单纯查库存再扣减(先 select 再 update)必然导致超卖。哪怕加了 synchronized 或 Redis 分布式锁,若锁粒度粗(如锁整个商品 ID),仍可能因请求排队造成“查到有库存 → 被其他线程抢先扣完 → 当前线程还按旧值扣”的问题。
正确做法是:用一条 SQL 或 Lua 脚本一次性完成「判断 + 扣减」:
- MySQL:用
UPDATE seckill_item SET stock = stock - 1 WHERE item_id = ? AND stock > 0,检查affectedRows == 1再决定是否继续;没影响行数就直接抛SeckillStockEmptyException - Redis:用 Lua 脚本读取当前库存、判断是否 > 0、原子性地 decr,返回结果。脚本执行期间不会被中断
异常该在哪一层抛?不要在 DAO 层硬抛
DAO 层只负责执行扣减动作并返回结果(比如 boolean 或 int)。是否抛异常,应由 Service 层根据返回值决策:
立即学习“Java免费学习笔记(深入)”;
- 如果扣减成功(SQL 影响行数为 1 / Lua 返回新库存 ≥ 0),继续后续流程(生成订单、发 MQ 等)
- 如果扣减失败(影响行数为 0 / Lua 返回 -1),Service 层明确抛出
SeckillStockEmptyException,并记录日志(含商品 ID、用户 ID、时间戳)便于对账 - 避免在 MyBatis 的 mapper.xml 里写
<if test="stock < 1">throw new SeckillStockEmptyException()</if>—— 这种写法既不安全也不可测
前端和网关要配合做快速失败
不能等请求走到 Service 层才抛异常。高并发下,大量请求涌入会压垮应用。需前置拦截:
- Redis 预减库存:活动开始前把库存加载到 Redis(如
seckill:stock:1001),每次请求先decr,为负则立即返回“库存已抢光”,不进后端 - 网关限流:对秒杀接口按用户/IP/令牌桶限流,防止恶意刷量
- 前端按钮置灰 + 倒计时结束禁用提交,降低无效请求量
异常处理要有业务语义,别吞掉或转成 500
Controller 层捕获 SeckillStockEmptyException 后,应返回明确的业务码和提示,例如:
- HTTP 状态码:200(不是 400 或 500),因为这是正常业务流终点
- 响应体:
{"code": 1002, "msg": "手慢了,库存已抢光"} - 绝对不要打印 ERROR 级日志(它不是错误),也不要包装成 RuntimeException 吞掉堆栈


















