我在《金融支付架构实战指南》中重点关注支付数据分布式的一致性,本文监管一致性和性能。
一、前言
在秒杀、支付系统中,瞬时流量洪峰不能直接同步落库 MySQL,业界标准:前端限流 → Redis 预扣库存 → 消息削峰 → 异步批量落库 DB。
传统 Redis List 做队列三大硬伤
- RPOP弹出数据后服务宕机:Redis 已删数据、内存丢失、订单永久丢失;
- 用RPOPLPUSH+临时队列自研 ACK、消费位点,业务代码冗余复杂;
- 无原生 pending、死信机制,异常消息极易阻塞全链路消费。
Redis Stream 是 Redis 官方原生消息队列结构 ,内置递增 MsgId(天然消费 offset)、消费者组、Pending 待确认队列、XACK 确认机制,天生适配支付订单可靠异步落地。
本文落地规范:拉取到消息立刻入库、不做本地内存缓存、无定时 500ms 等待攒批,兼顾订单实时性与数据可靠性,全套可直接上线生产。
二、Redis Stream 核心概念
- Stream Key :消息流,stream:seckill:order存储正常秒杀订单;stream:seckill:dead存储入库失败死信订单。
- MsgId :时间戳-自增序号,全局唯一,充当消费位点 offset。
- ConsumerGroup 消费者组:同一分组下一条消息只会被集群中一个消费者消费,天然防重复消费。
- Pending 队列:XREADGROUP 拉取消息后未 XACK,消息存入 pending,服务宕机重启自动重读,杜绝丢单。
- XACK:数据库入库成功手动 ACK,标记消息消费完毕,移出 pending。
整体架构
用户下单→接口限流 + Redis 预扣库存→生产者 XADD 写入 Stream→多实例消费者组阻塞拉取消息→即时批量入库 MySQL→成功 ACK、失败迁移死信。
三、项目依赖 (SpringBoot+Lettuce)
pom.xml
|------------------------------------------------------------------------------------------------------------------------------------------------|
| xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> |
application.yml
|----------------------------------------------------------------------|
| yaml spring: redis: host: 127.0.0.1 port: 6379 database: 0 password: |
四、常量定义 StreamConstant.java
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import java.util.UUID; public class StreamConstant { // 正常订单主stream public static final String SECKILL_ORDER_STREAM = "stream:seckill:order"; // 入库失败死信stream public static final String SECKILL_DEAD_STREAM = "stream:seckill:dead"; // 消费者分组名称 public static final String CONSUMER_GROUP = "seckill_group"; // 单机消费者唯一名称,多实例自动不同 public static final String CONSUMER_NAME = "consumer_" + UUID.randomUUID().toString().substring(0,8); // 单次从Redis最大拉取条数,上限1000,有多少返回多少,不会等待凑满 public static final int MAX_FETCH = 1000; // 阻塞等待毫秒,无数据阻塞1s public static final long BLOCK_MS = 1000L; } |
五、订单 DTO OrderDTO.java
|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import lombok.Data; @Data public class OrderDTO { // 订单编号 private String orderNo; // 秒杀活动ID private Long activityId; // 订单完整JSON报文 private String orderJson; // Stream消息ID,用于ACK private String msgId; } |
六、生产者:SeckillOrderProducer.java(下单写入 Stream)
下单成功不直接落库,异步写入 Stream 削峰。
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StreamOperations; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.util.HashMap; import java.util.Map; @Component public class SeckillOrderProducer { @Resource private RedisTemplate<String,Object> redisTemplate; /** * 秒杀订单投递至Stream * @param orderNo 订单号 * @param activityId 活动ID * @param orderJson 订单详情json */ public void sendOrder(String orderNo, Long activityId, String orderJson){ StreamOperations<String,String,Object> streamOps = redisTemplate.opsForStream(); Map<String,Object> msgMap = new HashMap<>(); msgMap.put("orderNo",orderNo); msgMap.put("activityId",activityId); msgMap.put("orderJson",orderJson); // XADD * 自动生成全局唯一MsgId streamOps.add(StreamConstant.SECKILL_ORDER_STREAM,msgMap); } } |
七、DB 入库业务接口 OrderDbService.java
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import java.util.List; public interface OrderDbService { /** * 批量新增订单 * @param orderList 订单集合 * @return true入库成功 false失败 */ boolean batchInsert(List<OrderDTO> orderList); } |
八、核心消费者(即时入库、无本地缓冲、无超时等待)
|-------------------------------------------------------|
| 规则:拉取一批数据立刻入库,不缓存、不等 500ms;COUNT=1000 = 单次上限,有几条处理几条。 |
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import org.springframework.data.redis.connection.stream.*; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.annotation.Resource; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @Component public class SeckillOrderStreamConsumer { @Resource private RedisTemplate<String,Object> redisTemplate; @Resource private OrderDbService orderDbService; private StreamOperations<String,String,Object> streamOps; @PostConstruct public void init(){ streamOps = redisTemplate.opsForStream(); // 不存在消费组则创建,从第一条消息开始消费;已存在捕获异常跳过 try { streamOps.createGroup(StreamConstant.SECKILL_ORDER_STREAM, StreamConstant.CONSUMER_GROUP, ReadOffset.from("0")); } catch (Exception ignored) {} // 启动异步消费线程 startConsume(); } @Async public void startConsume(){ // 线程未中断持续循环消费 while (!Thread.currentThread().isInterrupted()){ try { // 配置:单次最多1000条、阻塞1s StreamReadOptions options = StreamReadOptions.empty() .count(StreamConstant.MAX_FETCH) .block(StreamConstant.BLOCK_MS); // lastConsumed:从当前消费者上次消费位点继续读(含pending未ACK消息) StreamOffset<String> offset = StreamOffset.create( StreamConstant.SECKILL_ORDER_STREAM, ReadOffset.lastConsumed() ); // 分组阻塞拉取消息 List<MapRecord<String, String, Object>> recordList = streamOps.read( Consumer.from(StreamConstant.CONSUMER_GROUP, StreamConstant.CONSUMER_NAME), options, offset ); // 无消息继续阻塞循环 if(recordList == null || recordList.isEmpty()){ continue; } // 消息转为订单DTO List<OrderDTO> batch = recordList.stream().map(record -> { Map<String,Object> map = record.getValue(); OrderDTO dto = new OrderDTO(); dto.setOrderNo(String.valueOf(map.get("orderNo"))); dto.setActivityId(Long.valueOf(map.get("activityId"))); dto.setOrderJson(String.valueOf(map.get("orderJson"))); dto.setMsgId(record.getId().getValue()); return dto; }).collect(Collectors.toList()); // 【重点:拿到数据直接入库,无本地缓存、无等待时间】 boolean dbSuccess; try { dbSuccess = orderDbService.batchInsert(batch); } catch (Exception e) { dbSuccess = false; } if(dbSuccess){ // 入库成功:批量ACK,消息移出pending List<RecordId> ackIdList = batch.stream() .map(dto -> RecordId.of(dto.getMsgId())) .collect(Collectors.toList()); streamOps.acknowledge(StreamConstant.SECKILL_ORDER_STREAM, StreamConstant.CONSUMER_GROUP, ackIdList.toArray(new RecordId0)); }else{ // DB异常:写入死信并ACK,避免pending堆积死循环重试 for(OrderDTO dto : batch){ Map<String,Object> deadMap = Map.of( "orderNo",dto.getOrderNo(), "originMsgId",dto.getMsgId(), "errMsg","数据库入库异常" ); streamOps.add(StreamConstant.SECKILL_DEAD_STREAM,deadMap); streamOps.acknowledge(StreamConstant.SECKILL_ORDER_STREAM, StreamConstant.CONSUMER_GROUP,RecordId.of(dto.getMsgId())); } } } catch (Exception e){ // 全局异常休眠,防止死循环空耗CPU try { Thread.sleep(300); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } } } } } |
九、死信定时补偿消费
|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| java import org.springframework.data.redis.connection.stream.Range; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StreamOperations; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.util.List; @Component @EnableScheduling public class DeadOrderConsumer { @Resource private RedisTemplate<String,Object> redisTemplate; @Resource private OrderDbService orderDbService; // 每日凌晨2点执行死信重试入库 @Scheduled(cron = "0 0 2 * * ?") public void retryDeadOrder(){ StreamOperations<String,String,Object> streamOps = redisTemplate.opsForStream(); // 全量读取死信,生产建议死信也创建消费组断点续消费 List<MapRecord<String,String,Object>> deadRecords = streamOps.range( StreamConstant.SECKILL_DEAD_STREAM, Range.unbounded() ); // 自行补充解析+重试入库逻辑 } } |
十、关键知识点 & 业务场景验证
1、COUNT=1000 误区澄清(高频踩坑)
XREADGROUP COUNT 1000 BLOCK 1000:
COUNT 是单次返回数据上限,Redis 不会阻塞等待凑满 1000 条 ;队列现有 5 条就返回 5 条、空队列阻塞 1s。
本方案:来多少处理多少,即时入库、无本地 Buffer、无 500ms 延时,满足支付订单实时落地要求。
2、宕机数据安全验证
- 场景:消息拉取成功、未入库、未 ACK,JVM 宕机
消息留存 pending 队列,重启ReadOffset.lastConsumed()自动重读 pending 消息,零丢单。
- 场景:DB 宕机入库失败
消息转入死信 Stream 并 ACK,原消息不再重复投递,不阻塞正常订单消费,凌晨定时任务重试死信。
3、集群多实例部署
同一个消费者组seckill_group,一条消息只会被集群其中一台机器消费,天然规避重复入库。
4、防止 Stream 无限膨胀
写入时配置自动裁剪,丢弃已 ACK 历史数据,避免 Stream 持续暴涨:
|--------------------------------------------------------------------------------|
| redis # 保留最近10w条有效数据 XADD stream:seckill:order MAXLEN ~ 100000 * orderNo xxx |
十一、Stream vs List 选型对比
|----------------|--------------------|-----------------|---------|-------------|
| 方案 | 丢单风险 | 消费位点 | 自研 ACK | 死信实现 |
| List+RPOPLPUSH | 极低(自研临时队列) | 无,手动维护 offset | 大量代码 | 手动迁移 |
| Redis Stream | 几乎 0(pending+XACK) | MsgId 天然 offset | 一行 XACK | 独立死信 Stream |
|-----------------------------------------|
| 小型秒杀:List 可用;支付订单、大促秒杀生产必选 Stream 。 |
十二、运维常用 Redis 排查命令
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| redis # 查看Stream详情、消息总量 XINFO STREAM stream:seckill:order # 查看消费组pending堆积未ACK消息 XPENDING stream:seckill:order seckill_group # 手动ACK测试 XACK stream:seckill:order seckill_group 1750123456789-0 |
十三、总结 & 生产拓展
- 本套代码:即时入库无等待、无内存攒批,适配支付订单高实时性需求;
- 小流量调小MAX_FETCH=10~50,大促峰值调到 500~1000 平衡 DBIO;
- 超大流量:拆分多个 Stream 分片,多消费组并行消费;
- 监控XPENDING堆积数量,阈值告警、动态扩容消费实例。