第三部分:核心业务实现:秒杀逻辑详解
3.1 三种秒杀方案对比
秒杀的核心是库存扣减。我们设计了三种方案,分别适用于不同并发量的场景。
方案一:数据库乐观锁(适合中等并发,约500-2000 QPS)
乐观锁的核心思想:不锁定资源,而是在更新时检查数据是否被修改过。
-- 乐观锁扣减库存的SQL
UPDATE seckill_goods
SET stock_count = stock_count - 1, version = version + 1
WHERE id = #{goodsId} AND stock_count > 0 AND version = #{oldVersion}
工作原理:
查询商品信息,获取当前version
扣减库存时,在WHERE条件中加入version = oldVersion
如果更新行数为0,说明版本已变化(有其他线程修改了库存),需要重试
优点:
不需要数据库锁,性能较高
实现简单
缺点:
高并发下重试次数多,CPU消耗大
不适合极高并发场景
方案二:Redis预减库存 + 异步下单(适合高并发,5000-20000 QPS)
Redis是内存数据库,单机QPS可达10万+。我们将库存存储在Redis中,秒杀请求先操作Redis,成功后再异步处理订单。
工作流程:
秒杀开始前,将库存预热到Redis
秒杀请求到达时,使用Redis的DECR命令原子扣减库存
扣减成功后,将用户ID存入Redis Set(用于防重复)
发送MQ消息,异步创建订单
优点:
性能极高,Redis单机可支撑10万QPS
通过异步处理削峰填谷
缺点:
实现复杂,需要处理缓存与数据库的一致性
Redis故障会影响秒杀
方案三:分布式锁 + Lua脚本(最安全,适合对一致性要求极高的场景)
使用Redisson分布式锁保证同一时间只有一个线程操作库存,使用Lua脚本保证操作的原子性。
优点:
数据一致性最强
避免超卖
缺点:
性能相对较低
需要维护分布式锁
3.2 统一响应结果:让前后端沟通更顺畅
在前后端分离的开发模式中,统一的响应格式至关重要。它让前端能统一处理成功和失败的情况。
public class Result<T> {
private Integer code; // 状态码:200成功,其他失败
private String message; // 提示信息
private T data; // 响应数据
private Long timestamp; // 时间戳
// 成功响应(无数据)
public static <T> Result<T> success() {
return new Result<>(200, "success", null);
}
// 成功响应(带数据)
public static <T> Result<T> success(T data) {
return new Result<>(200, "success", data);
}
// 失败响应
public static <T> Result<T> error(String message) {
return new Result<>(500, message, null);
}
}
为什么需要timestamp?
帮助前端判断响应是否过期
便于问题排查时定位时间点
防止重放攻击(可以检查时间戳是否在合理范围内)
3.3 全局异常处理:别让异常信息暴露给用户
在Web应用中,异常处理非常重要。如果让系统异常直接暴露给用户,不仅体验差,还可能泄露敏感信息。
@RestControllerAdvice
public class GlobalExceptionHandler {
// 处理业务异常
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
// 记录警告日志(不是错误,是预期内的业务失败)
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
// 处理参数校验异常
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
// 提取所有校验失败的信息
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return Result.error(400, message);
}
// 处理系统异常(兜底)
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
// 记录完整错误堆栈,便于排查
log.error("系统异常: {}", e.getMessage(), e);
// 返回通用错误信息,不暴露内部细节
return Result.error(500, "系统繁忙,请稍后再试");
}
}
为什么需要全局异常处理?
统一格式:所有错误响应格式一致,前端易于处理
信息过滤:防止敏感信息(如SQL语句、文件路径)泄露
日志记录:集中记录异常,便于监控和排查
代码简化:业务代码中只需抛出异常,不需要到处try-catch
3.4 秒杀核心服务详解
秒杀服务是整个系统的核心,我们重点分析三种方案的实现。
方案一实现:数据库乐观锁
@Transactional
public SeckillOrder seckillWithOptimisticLock(Long userId, Long goodsId) {
// 1. 校验商品状态(是否存在、是否在秒杀时间内)
SeckillGoods goods = validateSeckillGoods(goodsId);
// 2. 检查用户是否已秒杀(防止重复)
checkUserSeckillRecord(userId, goodsId);
// 3. 乐观锁扣减库存
// 关键点:WHERE条件中加入了stock_count > 0和version
int updateCount = seckillGoodsMapper.decreaseStockWithVersion(goodsId, 1);
if (updateCount == 0) {
// 更新失败说明库存不足或版本冲突
throw BusinessException.STOCK_NOT_ENOUGH;
}
// 4. 创建订单
SeckillOrder order = createOrder(userId, goods);
// 5. 记录秒杀记录
saveSeckillRecord(userId, goodsId, order.getId(), 1);
return order;
}
关键点解释:
@Transactional:保证库存扣减和订单创建在同一个事务中,要么都成功,要么都失败
decreaseStockWithVersion:使用乐观锁,避免行锁导致的性能问题
updateCount == 0:表示库存不足或版本冲突,需要告诉用户秒杀失败
方案二实现:Redis预减库存
public void seckillWithRedis(Long userId, Long goodsId) {
// 1. Redis原子扣减库存
String stockKey = "seckill:stock:" + goodsId;
Long stock = redisTemplate.opsForValue().decrement(stockKey);
// 如果库存小于0,说明已经卖完了
if (stock == null || stock < 0) {
// 恢复库存(decr后为负数,需要加回来)
if (stock != null && stock < 0) {
redisTemplate.opsForValue().increment(stockKey);
}
throw BusinessException.STOCK_NOT_ENOUGH;
}
// 2. 防止重复秒杀(使用Redis Set)
String userKey = "seckill:user:" + goodsId;
Boolean isSuccess = redisTemplate.opsForSet().add(userKey, userId.toString());
if (Boolean.FALSE.equals(isSuccess)) {
// 用户已参与,恢复库存
redisTemplate.opsForValue().increment(stockKey);
throw BusinessException.REPEAT_SECKILL;
}
// 3. 发送MQ消息,异步创建订单
seckillMessageSender.sendMessage(userId, goodsId);
}
为什么使用Redis DECR?
DECR是原子操作,不用担心并发问题
Redis单线程模型,天然保证原子性
性能极高,单机可达10万+ QPS
为什么需要恢复库存?
DECR操作后如果库存变为负数,说明超卖了
需要将库存加回,保证数据正确性
3.5 消息队列异步处理:削峰填谷
秒杀场景下,如果所有订单创建都同步进行,数据库会承受巨大压力。使用消息队列可以将请求“削峰填谷”。
@Component
public class SeckillMessageConsumer {
@RabbitListener(queues = "seckill.queue")
public void handleMessage(SeckillDTO dto) {
// 1. 再次校验库存(数据库层)
SeckillGoods goods = seckillGoodsMapper.selectById(dto.getGoodsId());
if (goods.getStockCount() <= 0) {
log.warn("库存不足,放弃处理");
return;
}
// 2. 乐观锁扣减数据库库存
int updateCount = seckillGoodsMapper.decreaseStockWithVersion(
dto.getGoodsId(), 1);
if (updateCount == 0) {
log.warn("扣减库存失败,放弃处理");
return;
}
// 3. 创建订单
SeckillOrder order = new SeckillOrder();
order.setOrderNo(generateOrderNo());
order.setUserId(dto.getUserId());
order.setGoodsId(goods.getId());
order.setGoodsName(goods.getGoodsName());
order.setGoodsPrice(goods.getSeckillPrice());
seckillOrderMapper.insert(order);
log.info("订单创建成功: {}", order.getOrderNo());
}
}
为什么需要异步处理?
秒杀请求只需要快速响应“已收到请求”,用户可以稍后查看结果
订单创建涉及数据库写入,耗时较长,不适合同步处理
消息队列可以缓冲请求,避免数据库被打垮
为什么不直接信任Redis库存?
Redis可能宕机或数据丢失
最终一致性:Redis库存只是“预扣减”,真正的扣减必须在数据库完成
数据库是最终权威数据源
来源:
http://oplhc.cn/category/software-apps.html