从零开始实现秒杀系统(二):Redis优化篇
引言
在上一篇文章中,我们探讨了基于MySQL实现秒杀系统的几种方案,包括无锁实现、悲观锁、乐观锁以及最优的单SQL原子更新方案。这些方案都直接操作数据库,在高并发场景下仍然存在性能瓶颈。
本文作为系列的第二篇,将介绍如何利用Redis来优化秒杀系统,大幅提升系统的并发处理能力。在V2版本中,我们引入了Redis作为缓存和预减库存的工具,并结合异步处理技术,实现了一个更高性能的秒杀系统。
1. 高并发秒杀系统的挑战
回顾V1版本,我们虽然解决了超卖问题,但仍然面临以下挑战:
- 数据库压力大:所有请求都直接访问数据库,导致数据库成为瓶颈
- 系统吞吐量有限:MySQL的行锁机制虽然保证了数据一致性,但限制了并发处理能力
- 用户体验差:高并发下,大量请求等待数据库锁释放,导致响应时间长
V2版本就是为了解决这些问题而设计的, 于引入了以下关键技术:
- Redis预减库存:使用Redis原子操作减少数据库访问
- 库存快速失败检查:通过Redis标记售罄商品,避免无效请求
- 异步下单:将下单操作异步化,提高系统吞吐量
- 分布式锁:保证Redis缓存初始化的线程安全
- 内存队列:使用内存线程池处理订单创建,减轻数据库压力
这些创新点共同构成了一个高性能、高可用的秒杀系统。下面我们将详细介绍每个创新点的实现。
2. V1V2架构对比
V1架构(基于MySQL):
用户请求 -> 应用服务器 -> MySQL(减库存+创建订单)

V2架构(Redis优化):
用户请求 -> 应用服务器 -> Redis预减库存 -> 快速返回结果给用户
-> 异步队列 -> MySQL(最终减库存+创建订单)
-> 用户查询结果 -> Redis查询状态
3. Redis预减库存
在V1版本中,每个秒杀请求都需要访问数据库查询和更新库存,这是系统的主要瓶颈。V2版本引入了Redis预减库存机制,将库存信息缓存在Redis中,使用Redis的原子操作来处理库存扣减。
// Redis预减库存,减少对数据库的访问
Long stock = redisService.decr(SeckillKey.goodsStock, "" + goodsVo.getId());
// 判断库存是否充足
if (stock < 0) {
// 库存不足,回滚Redis库存,设置商品已售罄标识
redisService.incr(SeckillKey.goodsStock, "" + goodsVo.getId());
setGoodsOver(goodsVo.getId());
return null;
}
这种方式的优势在于:
- 高性能:Redis的内存操作速度远快于数据库磁盘操作
- 原子性:Redis的incr/decr操作是原子的,无需额外加锁
- 减轻数据库压力:只有通过Redis预减库存的请求才会访问数据库
3.1 库存初始化的优化
为了避免每次启动服务或处理请求时重复初始化Redis库存,V2版本使用了分布式锁来保证线程安全:
private void initStockIfNeeded(GoodsVo goodsVo) {
Long goodsId = goodsVo.getId();
// 检查库存是否已在Redis中初始化
if (!redisService.exists(SeckillKey.goodsStock, "" + goodsId)) {
// 创建分布式锁,设置过期时间和唯一标识符
String lockKey = LOCK_PREFIX + goodsId;
RedisDistributedLock lock = new RedisDistributedLock(redisService, lockKey, LOCK_EXPIRE_SECONDS);
boolean lockAcquired = lock.tryLock(LOCK_TIMEOUT_MS);
if (lockAcquired) {
try {
// 双重检查避免重复初始化
if (!redisService.exists(SeckillKey.goodsStock, "" + goodsId)) {
log.info("Initializing stock for goods: {}", goodsId);
// 从数据库获取最新库存
GoodsVo freshGoodsInfo = goodsService.getGoodsVoByGoodsId(goodsId);
if (freshGoodsInfo != null) {
redisService.set(SeckillKey.goodsStock, "" + goodsId, freshGoodsInfo.getStockCount());
log.info("Stock initialized for goods {}: {}", goodsId, freshGoodsInfo.getStockCount());
}
}
} finally {
// 释放锁
lock.unlock();
}
}
}
}
这段代码使用了双重检查锁(DCL)模式和Redis分布式锁来确保在多实例、多线程环境下,库存只被初始化一次,避免了缓存重复初始化的问题。
4. 库存快速失败检查
在高并发秒杀场景中,大部分请求都是在商品已售罄后到达的。为了避免这些请求无谓地消耗系统资源,V2版本引入了库存快速失败检查机制:
// 快速失败检查:商品是否已标记为售罄
if (isGoodsOver(goodsVo.getId())) {
return null;
}
// 标记商品已售罄
private void setGoodsOver(Long goodsId) {
redisService.set(SeckillKey.isGoodsOver, "" + goodsId, true);
}
// 判断商品是否售罄
private boolean isGoodsOver(Long goodsId) {
return redisService.exists(SeckillKey.isGoodsOver, "" + goodsId);
}
这一优化使得系统能够快速拒绝无效请求,将宝贵的系统资源用于处理有效请求,极大地提高了系统的吞吐能力。
5. 异步下单处理
秒杀过程中最耗时的操作是创建订单和更新数据库,为了进一步提升系统性能,V2版本将下单操作异步化:
// 使用线程池异步处理订单创建
private static final ExecutorService orderExecutor = Executors.newFixedThreadPool(5000);
// 标记订单为处理中状态
redisService.set(SeckillKey.seckillPending, finalUserId + "_" + finalGoodsVo.getId(), System.currentTimeMillis());
// 异步下单
orderExecutor.submit(() -> {
try {
SeckillOrder order = createSeckillOrder(finalUserId, finalGoodsVo);
} catch (Exception e) {
log.error("Create order async error: ", e);
// 出错时回滚Redis库存
redisService.incr(SeckillKey.goodsStock, "" + finalGoodsVo.getId());
} finally {
// 清理处理中状态
redisService.delete(SeckillKey.seckillPending, finalUserId + "_" + finalGoodsVo.getId());
}
});
异步下单的优势:
- 提高响应速度:用户无需等待下单完成即可获得响应
- 控制数据库并发:通过线程池限制并发数量,避免数据库压力过大
- 失败自动回滚:异常情况下自动回滚Redis库存,保证数据一致性
6. 订单状态查询
由于订单处理是异步的,用户需要一种机制来查询秒杀结果。V2版本实现了一个专门的结果查询接口:
/**
* 获取秒杀结果
* @return orderId: 成功, -1: 秒杀失败, 0: 排队中, -2: 处理超时
*/
@Override
public Long getSeckillResult(Long userId, Long goodsId) {
String pendingKey = userId + "_" + goodsId;
// 1. 优先检查处理中状态
Long startTime = redisService.get(SeckillKey.seckillPending, pendingKey);
if (startTime != null) {
long diff = System.currentTimeMillis() - startTime;
if (diff > 30000) { // 超时30秒
redisService.delete(SeckillKey.seckillPending, pendingKey);
return -2L; // 处理超时
}
return 0L; // 正在处理
}
// 2. 查询订单信息
SeckillOrder order = orderService.getOrderByUserIdGoodsId(userId, goodsId);
if (order != null) {
return order.getId(); // 秒杀成功
}
// 3. 查询商品是否已售罄
boolean isOver = isGoodsOver(goodsId);
if (isOver) {
return -1L; // 已售罄,秒杀失败
}
return -1L; // 秒杀失败
}
这个接口设计考虑了多种状态:
- 订单处理中(排队)
- 处理超时
- 秒杀成功
- 秒杀失败(商品售罄)
这种设计极大地提升了用户体验,让用户能够及时了解秒杀结果。
7. V2版本的完整秒杀流程
结合上述优化,V2版本的秒杀流程如下:
- 判断商品是否售罄(快速失败)
- 初始化Redis库存(如果需要)
- Redis预减库存
- 异步创建订单
- 返回临时结果
- 用户查询秒杀结果
这一流程不仅保证了系统的高性能,还确保了数据的一致性和良好的用户体验。
8. V1与V2性能对比
单个 MySQL 的每秒写入在 4000 QPS,读取如果记录超过千万级别效率会大大降低。而Redis单分片写入瓶颈在 2w 左右,读瓶颈在 10w 左右
性能指标 | V1(MySQL实现) | V2(Redis优化) | 提升 |
---|---|---|---|
QPS | 约500-1000 | 约10,000-20,000 | 20倍左右 |
响应时间 | 平均200-500ms | 平均100ms以下 | 10倍左右 |
并发连接数 | 约1,000 | 约50,000 | 50倍 |
数据库压力 | 所有请求直接访问数据库 | 只有成功减库存的请求访问数据库 | 显著降低 |
系统资源利用 | CPU和数据库IO高负荷 | 内存使用率高,CPU和IO负载分散 | 更均衡 |
可扩展性 | 受限于数据库性能,难以水平扩展 | 可通过增加Redis和应用节点水平扩展 | 大幅提升 |
9. 潜在问题
尽管V2版本在性能上有了显著提升,但我们仍然面临一些挑战:
- 服务可靠性与一致性:内存线程池处理订单可能因服务重启或宕机导致任务丢失,同时异步处理增加了保证数据一致性的难度
- 单机瓶颈:线程池在单机内存中运行,难以实现真正的分布式横向扩展
- 峰值处理:在极端高并发下,内存队列可能迅速堆积,导致系统内存压力增大
- 监控与重试:缺乏完善的监控和失败任务重试机制
- 性能问题:在库存扣减和订单入库依旧是一个数据库事务处理的,库存扣减依旧是行锁导致系统性能不佳,而事务也加剧了性能的下降。如果秒杀场景持续进行会导致待处理的请求挤压
这些挑战将在系列的第三篇文章中通过引入RocketMQ等技术得到解决。
10. 总结
V2版本秒杀系统通过引入Redis预减库存、快速失败检查、异步下单等创新技术,成功解决了V1版本中的性能瓶颈,实现了一个高性能、高可靠的秒杀系统。
Redis作为高性能内存数据库,与MySQL行锁机制相比,具有显著的性能优势。通过将热点数据(库存)放在Redis中,系统能够承受更高的并发压力,同时保证数据的一致性。
在系列的下一篇文章《从零开始实现秒杀系统(三):RocketMQ消息队列篇》中,我们将引入RocketMQ消息队列,进一步优化秒杀系统,解决V2版本中存在的剩余问题
本文源码已开源在GitHub:Goinggoinggoing/seckill
如有疑问或建议,欢迎在评论区讨论!