必须显式创建消费组并手动xack,否则消息堆积pel反复重投;xreadgroup需先creategroup;$起始id首次会阻塞;xack须同步调用且不可遗漏;推荐存json而非java序列化;trimbyminid不清理pel,需监控pending长度。

直接结论:用 StringRedisTemplate.opsForStream() + 显式消费组 + 手动 XACK,否则消息会堆积在 PEL 里反复重投,根本不算“持久化队列”。
为什么 XREADGROUP 不加 GROUP 参数就报 NOGROUP 错误
Spring Data Redis 不会自动帮你创建消费组。哪怕 Stream 已存在,XREADGROUP 命令也要求消费组必须提前初始化,否则 Redis 直接返回 NOGROUP No such consumer group。
- 必须显式调用
redisTemplate.opsForStream().createGroup("mystream", "mygroup"),且只执行一次(重复执行会报错) - 推荐在 Spring Boot 启动时通过
@PostConstruct或ApplicationRunner初始化,避免多实例并发创建冲突 - 如果用
$作为起始 ID(即只读新消息),首次调用XREADGROUP时 Stream 还没写入任何消息,会阻塞等待——这不是错误,是正常行为
消费者必须调用 XACK,否则消息永远不“真正完成”
PEL(Pending Entries List)是 Stream 可靠性的核心机制,但也是最容易被忽略的陷阱。只要没调 XACK,消息就一直留在 PEL 里,下次 XREADGROUP 还会拉到它,哪怕消费者已经重启过三次。
- 业务逻辑处理成功后,必须同步调用
redisTemplate.opsForStream().acknowledge("mystream", "mygroup", recordId) - 不要把
XACK放在异步线程或 finally 块里——如果消费者进程崩溃,finally根本不会执行 - 异常路径也要考虑:失败时可选择
XACK(丢弃)、XAUTOCLAIM(移交重试)、或转入死信 stream,不能放任不管
Stream 消息体该存 JSON 还是 Java 序列化对象
存 JSON 字符串是唯一稳妥的选择。Java 序列化看似方便,但在微服务场景下极易因 class 版本不一致、包路径变更、JDK 升级导致反序列化失败,且无法跨语言消费。
- 用
ObjectMapper将业务对象转成Map<string object></string>,再传给StreamRecords.newRecord().withFields(map) - 字段名统一用小写字母+下划线(如
order_id),避免大小写敏感问题 - 避免嵌套过深的 JSON;Stream 单条消息建议控制在 10KB 以内,超大消息会影响网络传输和内存 GC
Stream 长度控制和 trimByMinId 容易被当成“删除”
XTRIM 或 MAXLEN 并不是实时物理删除,而是标记为可回收。Redis 在后续 AOF 重写或内存清理时才真正释放空间。如果你依赖 trimByMinId 控制积压,得注意它只影响新写入的消息范围,不影响已存在的 PEL 中的老消息。
- 不要指望
trimByMinId能清掉 PEL 里的待确认消息——PEL 是独立结构,不受 trim 影响 - 监控 PEL 长度比监控 Stream 总长度更重要:
redisTemplate.opsForStream().pending("mystream", "mygroup").size() - 长期不
XACK的消费者会让 PEL 越积越多,最终撑爆内存,这不是配置问题,是逻辑漏洞
最常被绕过的点:PEL 不是“后台自动清理”的东西,它完全依赖你每次消费后的 XACK 调用。哪怕只漏掉一次,这条消息就会卡住整个消费组的位点推进。别信“等下次自动重试”,Redis Stream 不会替你做这个决定。











