# redis-stream-springboot-starter 基于 Redis Streams 的消息收发 SDK,对标 `rocket-mq-springboot-starter`,**零业务依赖**、开箱即用。 引入依赖即自动装配:配好 Redis 连接,注入 `RedisStreamEnhanceTemplate` 即可发送;注册一个 `EnhanceStreamConsumerHandler` Bean 即可消费。框架负责消费组创建、阻塞拉取、ACK、崩溃恢复(autoclaim)、死信淘汰、Stream 定时裁剪。 - Spring Boot `2.3.12.RELEASE` / JDK `1.8` - 底层 `spring-data-redis 2.3.x` + Lettuce - 消息体可选 gzip 压缩;XADD 字段契约固定为 `key/tag/enc/body/ts`(可与 C++ 等异构端互通) --- ## 一、引入依赖 ```xml com.njcn redis-stream-springboot-starter 1.0.0-SNAPSHOT ``` 制品发布在内网 Nexus,若拉不到依赖请确认 `settings.xml` / `pom.xml` 配置了仓库: ```xml nexus-snapshots http://192.168.1.22:8001/nexus/content/repositories/snapshots/ ``` > 无需 `@EnableXxx` 注解。starter 通过 `spring.factories` 自动装配,且在 `RedisAutoConfiguration` 之后生效(确保 `StringRedisTemplate` 已就绪)。 --- ## 二、最小配置 连接信息走标准 Spring Redis 配置,starter 自身配置前缀为 `redis-stream`: ```yaml spring: redis: host: 127.0.0.1 port: 6379 # password: ... # database: 0 redis-stream: # 以下均为可选,默认值见「配置项」一节 read-count: 10 block-ms: 2000 max-retry: 3 ``` --- ## 三、定义消息 业务消息继承 `StreamBaseMessage`(starter 自带,**不依赖任何业务 api**): ```java import com.njcn.middle.stream.domain.StreamBaseMessage; import lombok.Data; import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) public class OrderMessage extends StreamBaseMessage { private Long orderId; private String status; } ``` 基类已内置以下字段,业务无需自己维护: | 字段 | 默认值 | 说明 | |---|---|---| | `key` | 雪花 id | 消息业务 key,全局单例 Snowflake 生成,保证唯一 | | `tag` | `""` | 下行路由 / 过滤标识 | | `source` | `""` | 消息来源标识 | | `sendTime` | `now()` | 发送时间 | | `retryTimes` | `0` | 预留字段(重试计数实际由 Redis PEL 维护,见下文) | --- ## 四、发送消息 注入 `RedisStreamEnhanceTemplate`,调用 `send(topic, message, compress)`: ```java import com.njcn.middle.stream.template.RedisStreamEnhanceTemplate; import org.springframework.data.redis.connection.stream.RecordId; import org.springframework.stereotype.Service; @Service public class OrderProducer { private final RedisStreamEnhanceTemplate streamTemplate; public OrderProducer(RedisStreamEnhanceTemplate streamTemplate) { this.streamTemplate = streamTemplate; } public void sendOrder(Long orderId) { OrderMessage msg = new OrderMessage(); msg.setOrderId(orderId); msg.setStatus("CREATED"); // 需要按 tag 路由时,发送前先 setTag(...) msg.setTag("node-1"); // 第三参 compress=true 时对 body 做 gzip+base64;false 为明文 json RecordId id = streamTemplate.send("order-topic", msg, false); } } ``` 要点: - `topic` 会经 `StreamKeyBuilder` 转为实际 stream 名(开启环境隔离时追加 `_env` 后缀)。 - `key` / `tag` **从 message 对象取**,不单独传参。需要自定义 tag 时,**发送前** `message.setTag(...)`。 - 返回 `RecordId` 即 XADD 生成的消息 id。 --- ## 五、消费消息 继承 `EnhanceStreamConsumerHandler` 并**注册为 Spring Bean**,框架会为每个 handler 起一条消费线程: ```java import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler; import org.springframework.stereotype.Component; @Component public class OrderConsumer extends EnhanceStreamConsumerHandler { @Override public String topic() { return "order-topic"; // 与发送方一致 } @Override public String group() { return "order-consumer-group"; // 消费组名,启动时自动 XGROUP CREATE } @Override public Class messageType() { return OrderMessage.class; // 用于反序列化 } @Override public void handleMessage(OrderMessage message) throws Exception { // 业务处理。抛异常时按 isRetry() 决定 RETRY 或落异常 System.out.println("consume order = " + message.getOrderId()); } @Override public boolean isRetry() { return true; // 失败是否重试(留 PEL 由 autoclaim 重投) } @Override public boolean throwException() { return false; // Redis 模式下为 no-op,固定返回 false 即可 } } ``` ### 必须实现的方法 | 方法 | 说明 | |---|---| | `topic()` | 监听的 topic | | `group()` | 消费组名(自动幂等建组) | | `messageType()` | 消息类型,用于反序列化 | | `handleMessage(T)` | 业务处理逻辑 | | `isRetry()` | 处理失败是否重试 | | `throwException()` | **Redis 模式 no-op**,返回 `false` 即可(仅为与 rocket starter 共用接口而保留) | ### 可选覆写的钩子 | 方法 | 默认 | 说明 | |---|---|---| | `filter(T)` | `false` | 返回 `true` 则直接 ACK 丢弃,不进入 `handleMessage` | | `consumeSuccess(T)` | 空 | 消费成功回调 | | `saveExceptionMsgLog(T message, body, e)` | 空 | 异常落库 / 告警回调(毒消息 / 不重试失败 / 死信触发);**`message` 可能为 `null`,实现需判空** | | `getMaxRetryTimes()` | 沿用全局 | 覆写为正整数即**只对本 handler** 单独设定最大投递次数,优先级高于全局 `redis-stream.max-retry` | > 💡 不覆写 `getMaxRetryTimes()` 时,该 handler 沿用全局配置 `redis-stream.max-retry`;需要某个 handler 单独控制重试次数时,覆写它返回一个正整数即可(如重投较多的关键 topic 返回 `10`)。 > > ⚠️ `saveExceptionMsgLog` 的 `message` 入参:**不重试失败**与**死信**(autoclaim 超 `max-retry`)场景下为**反序列化后的业务对象**;仅**毒消息**(`body` 本身无法解析)场景下为 `null`(此时只能用原始 `body`)。回调实现务必对 `message == null` 做判空,避免 NPE。 --- ## 六、消费语义:成功 / 重试 / 毒消息 / 死信 一条消息进入 `handleMessage` 后的处理结果: ``` 解码(enc,body) → 反序列化 → filter → handleMessage → consumeSuccess → XACK ``` | 场景 | 结果 | |---|---| | 处理成功 | `XACK` 确认 | | `filter()` 返回 true | `XACK` 丢弃,不处理 | | 解码 / 反序列化失败(**毒消息**) | `saveExceptionMsgLog` + `XACK`,**不卡队列** | | `handleMessage` 抛异常 且 `isRetry()=true` | **RETRY**:不确认,留 PEL | | `handleMessage` 抛异常 且 `isRetry()=false` | `saveExceptionMsgLog` + `XACK` | **重试与死信(崩溃恢复)** 由后台 `StreamAutoClaimer` 兜底: 1. RETRY 的消息留在 PEL(Pending Entries List)中未确认。 2. autoclaim 周期性扫描 PEL,对 idle 超过 `autoclaim.min-idle-ms` 的消息执行 `XCLAIM` 认领并重投。 3. 每次 `XCLAIM` 使投递计数 +1;当投递次数达到 `max-retry`(含首投)仍失败 → 触发 `saveExceptionMsgLog`(可拿到反序列化后的业务消息,异常信息含 `max retry exceeded`)+ 强制 `XACK`,**作为死信淘汰**,避免无限滞留。 > 即:`max-retry=3` ⇒ 最多投递 3 次(首投 1 次 + autoclaim 重投 2 次),第 3 次仍失败即落死信。 > 单个 handler 可覆写 `getMaxRetryTimes()` 返回正整数,**对该 handler 单独设定**最大投递次数,优先级高于全局 `redis-stream.max-retry`。 > 实例宕机导致的 PEL 残留消息,也会被其它存活实例的 autoclaim 接管重投。 --- ## 七、配置项 > 全部可配项如下,前缀 `redis-stream`,**均为可选**——不配即用注释中的默认值。Spring 宽松绑定,`read-count` / `readCount` 等写法等价。 ```yaml redis-stream: # ===== 基础 ===== env-isolation: false # 是否按环境隔离 stream 名:开启后名字追加 _env 后缀,多环境共用一套 Redis 时互不串扰 env: "" # 环境标识,env-isolation=true 且非空时作为 stream 名后缀(topic_env);收发双方须配一致 consumer-name: "" # 消费者名,留空自动生成唯一名。多实例务必留空,写死会被当作同一消费者互相争抢、破坏负载均衡(见「八、注意事项」) read-count: 10 # 单次 XREADGROUP 拉取条数(一次最多取多少条来处理) block-ms: 2000 # XREADGROUP 阻塞等待毫秒:无新消息时阻塞这么久再返回,影响拉取实时性与空转开销 max-retry: 3 # 全局最大投递次数(含首投),超限落死信;可被 handler 的 getMaxRetryTimes() 单独覆写 # ===== 崩溃恢复 · 自动认领:消费者宕机/超时未 ACK 的消息滞留 PEL,由后台定时认领重投(机制见「六、消费语义」) ===== autoclaim: min-idle-ms: 60000 # 消息 idle(距上次投递)超过此值(ms)才可被其它消费者认领重投。太小会与正常重试争抢,太大则故障恢复慢 interval-ms: 30000 # autoclaim 扫描 PEL 的周期(ms) # ===== Stream 长度裁剪:防无界增长,后台定时 XTRIM MAXLEN (精确裁剪),保留最新 maxlen 条 ===== trim: maxlen: 100000 # 保留的最大条数(精确裁剪)。默认 10万;要 5万就设 50000,超出部分删最老条目 interval-ms: 60000 # 裁剪执行周期(ms)。调小可让实际长度更贴近 maxlen(代价是 XTRIM 执行更频繁) ``` Stream 裁剪有两点**必须知道**: 1. **软上限,非实时硬卡。** 两次裁剪之间 stream 会短暂超过 `maxlen`,峰值 ≈ `maxlen + 生产速率 × interval-ms`。例:`maxlen=50000`、每秒进 1000 条、周期 60s ⇒ 下次裁剪前最高可达 `11 万`,随后拉回 5 万。要更紧就调小 `interval-ms`。 2. **按条数裁剪,不区分是否已消费。** `XTRIM MAXLEN` 只删最老条目,不管是否已 ACK。`maxlen` 设太小且消费积压超过它时,**未消费的老消息会被直接裁掉 = 丢消息**(被裁的 PEL 消息会被静默强制 ACK)。⚠️ `maxlen` 要留足:至少覆盖「峰值生产速率 × 消费者最长停机/滞后时间」。5 万 / 10 万对一般业务够用,单条消费慢或实例可能长停时需上调。 > 注:`spring-data-redis 2.3.x` 高级 API 仅支持精确裁剪,无近似裁剪(`MAXLEN ~`)重载,故走精确裁剪。 --- ## 八、注意事项 - **多实例部署不要写死 `consumer-name`**。留空时框架会为每个实例生成唯一消费者名(`consumer-xxx` / `claimer-xxx`),符合消费组负载均衡模型。若多个实例配置了相同的固定 `consumer-name`,它们会被视为同一个消费者而互相争抢,破坏负载均衡 —— `consumer-name` 仅建议用于单实例或本地调试。 - **环境隔离**:多环境(dev/test/prod)共用一套 Redis 时,开启 `env-isolation` 并设置 `env`,各环境 stream 名互不串扰。**收发双方必须配置一致**,否则 topic 对不上。 - **压缩契约**:仅 `body` 字段可压缩。`compress=true` ⇒ `enc=gzip`、`body=base64(gzip(json))`;否则 `enc=plain`、`body` 为明文 json。消费侧仅按 `enc` 解码,与 topic 无关,可与 C++ 等异构端互通。 - **Stream 裁剪**:框架按 `trim.maxlen` 精确裁剪 stream 防止无界增长(详见「七、配置项 · Stream 长度裁剪」)。请确保 `maxlen` 足够大,避免重试中的消息在达到 `max-retry` 前被裁掉(被裁掉的 PEL 消息会被静默强制 ACK)。 - **无 handler 时容器空转**:未注册任何 `EnhanceStreamConsumerHandler` Bean 时,监听/认领/裁剪线程均不启动,仅 `RedisStreamEnhanceTemplate` 可用于纯发送场景。 --- ## 九、本地验证(可选) 集成测试需要本地 Redis;不可达时整类自动 skip(不影响 `mvn test` 全绿): ```bash docker run -d -p 6379:6379 redis:6.2 mvn test ```