1. Redis消息队列在秒杀场景中的核心价值
在电商秒杀等高并发场景中,传统数据库直接处理订单会遇到两个致命问题:一是数据库连接池容易被撑爆,二是响应时间无法满足秒级响应的需求。黑马点评项目采用Redis消息队列实现异步削峰,将瞬间的流量洪峰转化为平稳的消息流处理。
我亲历过一个促销活动,没有引入消息队列时数据库QPS冲到8000+导致服务雪崩。后来改造为消息队列架构后,虽然前端请求量不变,但数据库压力始终稳定在1200QPS左右。这就是消息队列的"削峰填谷"效应。
2. 技术选型:为什么选择Redis Stream
2.1 消息队列方案对比
在Redis生态中,主要有三种消息队列实现方式:
| 方案 | 可靠性 | 持久化 | 消费者组 | 适用场景 |
|---|---|---|---|---|
| List | 低 | 可选 | 不支持 | 简单任务队列 |
| Pub/Sub | 最低 | 不支持 | 不支持 | 实时通知 |
| Stream | 高 | 支持 | 支持 | 可靠消息队列 |
2.2 Stream的核心优势
- 消息持久化:所有消息默认持久化,重启不丢失
- 消费者组:支持多消费者协同消费,自动负载均衡
- 消息回溯:通过消息ID可以重新消费历史消息
- ACK机制:确保消息至少被消费一次
// 创建消费者组示例 stringRedisTemplate.opsForStream().createGroup( "stream.orders", ReadOffset.from("0-0"), "g1" );3. 秒杀场景下的Stream实战
3.1 整体架构设计
前端请求 → Lua脚本(校验+写Stream) → 后台线程消费 → 数据库落库关键点在于:
- 请求线程只做快速校验
- 耗时操作异步化处理
- 通过订单ID实现异步结果查询
3.2 Lua脚本核心逻辑
-- 校验库存 local stock = tonumber(redis.call('get', stockKey)) if stock <= 0 then return 1 end -- 校验一人一单 local member = redis.call('sismember', orderKey, userId) if member == 1 then return 2 end -- 扣减库存 redis.call('incrby', stockKey, -1) -- 记录用户 redis.call('sadd', orderKey, userId) -- 写入Stream redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId) return 0关键技巧:所有Redis操作要在同一个Lua脚本中完成,保证原子性。我曾遇到脚本拆分导致的数据不一致问题,最终通过合并操作解决。
3.3 消费者组实现
private class VoucherOrderHandler implements Runnable { @Override public void run() { while (true) { try { // 1.获取消息 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()) ); if (list == null || list.isEmpty()) { continue; } // 2.解析消息 MapRecord<String, Object, Object> record = list.get(0); Map<Object, Object> values = record.getValue(); VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap( values, new VoucherOrder(), true); // 3.处理订单 handleVoucherOrder(voucherOrder); // 4.ACK确认 stringRedisTemplate.opsForStream().acknowledge( "stream.orders", "g1", record.getId()); } catch (Exception e) { log.error("处理订单异常", e); handlePendingList(); } } } }4. 异常处理与可靠性保障
4.1 Pending-List处理机制
当消息消费失败时,消息会进入pending-list。我们需要定时检查并重新处理:
private void handlePendingList() { while (true) { try { // 从pending-list读取 List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read( Consumer.from("g1", "c1"), StreamReadOptions.empty().count(1), StreamOffset.create("stream.orders", ReadOffset.from("0")) ); if (list == null || list.isEmpty()) { break; } // 处理逻辑与正常消费相同 MapRecord<String, Object, Object> record = list.get(0); Map<Object, Object> values = record.getValue(); VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap( values, new VoucherOrder(), true); handleVoucherOrder(voucherOrder); // ACK确认 stringRedisTemplate.opsForStream().acknowledge( "stream.orders", "g1", record.getId()); } catch (Exception e) { log.error("处理pending-list异常", e); try { Thread.sleep(20); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } } } }4.2 必须注意的四个要点
- 消费者组预创建:应用启动时要确保消费者组存在
- ACK顺序:必须业务成功后再ACK,否则可能丢失消息
- 幂等设计:数据库操作要能处理重复消息
- 异常重试:合理设置重试间隔,避免雪崩
5. 性能优化实践
5.1 批量消费优化
// 修改count参数实现批量消费 StreamReadOptions.empty() .count(10) // 一次读取10条 .block(Duration.ofSeconds(2))实测数据对比:
- 单条消费:800TPS
- 批量(10条):2100TPS
5.2 消费者并行度
通过多个消费者实例提升吞吐量:
// 启动多个消费者线程 @PostConstruct private void init() { for (int i = 0; i < 3; i++) { SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); } }经验:消费者数量建议与CPU核心数相当。过多会导致频繁上下文切换,反而降低性能。
6. 生产环境踩坑记录
6.1 消息堆积问题
曾遇到消息消费速度跟不上生产速度,导致Stream长度暴涨。解决方案:
- 增加消费者实例
- 优化数据库批量插入
- 监控Stream长度,超过阈值报警
# Redis监控命令 XINFO STREAM stream.orders6.2 内存占用优化
Stream默认不限制长度,长期运行可能占用大量内存。可以通过以下配置控制:
# redis.conf stream-node-max-entries 10000 # 每个stream节点最大条目数7. 与专业消息队列对比
虽然Redis Stream能满足大部分场景,但与专业MQ相比仍有差距:
| 特性 | Redis Stream | RabbitMQ | Kafka |
|---|---|---|---|
| 消息堆积能力 | 一般 | 强 | 极强 |
| 吞吐量 | 10万级 | 万级 | 百万级 |
| 功能丰富度 | 基础 | 丰富 | 非常丰富 |
| 运维复杂度 | 低 | 中 | 高 |
建议在以下情况选择Redis Stream:
- 已经使用Redis作为主要存储
- 消息量在10万级以下
- 希望简化技术栈
8. 监控与运维建议
8.1 关键监控指标
- Stream长度:反映消息堆积情况
- 消费者延迟:last-delivered-id与当前消息ID差值
- Pending-list长度:未确认消息数量
8.2 常用运维命令
# 查看Stream信息 XINFO STREAM stream.orders # 查看消费者组 XINFO GROUPS stream.orders # 查看消费者状态 XINFO CONSUMERS stream.orders g19. 扩展应用场景
除了秒杀,Redis Stream还适用于:
- 异步日志处理:将日志写入Stream后异步处理
- 事件溯源:作为事件存储中间件
- 消息广播:通过多个消费者组实现发布/订阅
- 延迟队列:配合ZSET实现延迟消息
// 延迟队列实现示例 // 1. 写入带score的ZSET stringRedisTemplate.opsForZSet().add( "delay:queue", message, System.currentTimeMillis() + 5000 ); // 2. 定时任务获取到期消息 Set<String> messages = stringRedisTemplate.opsForZSet().rangeByScore( "delay:queue", 0, System.currentTimeMillis() );10. 最佳实践总结
经过多个项目实践,我总结出Redis Stream的七个黄金法则:
- Lua脚本要原子:所有相关操作必须放在同一个脚本中
- 消费者要幂等:消息可能重复消费,业务逻辑要支持重试
- ACK要在最后:确保业务成功后再确认消息
- 监控不能少:Stream长度、pending-list、消费者延迟都要监控
- 批量是王道:合理设置count参数提升吞吐量
- 异常要处理:pending-list机制要用好
- 评估量级:超10万级消息考虑专业MQ