Files
redis-stream-springboot-sta…/README.md

337 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<dependency>
<groupId>com.njcn</groupId>
<artifactId>redis-stream-springboot-starter</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
```
制品发布在内网 Nexus,若拉不到依赖请确认 `settings.xml` / `pom.xml` 配置了仓库:
```xml
<repository>
<id>nexus-snapshots</id>
<url>http://192.168.1.22:8001/nexus/content/repositories/snapshots/</url>
</repository>
```
> 无需 `@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<T>` 并**注册为 Spring Bean**,框架会为每个 handler 起一条消费线程:
```java
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
import org.springframework.stereotype.Component;
@Component
public class OrderConsumer extends EnhanceStreamConsumerHandler<OrderMessage> {
@Override
public String topic() {
return "order-topic"; // 与发送方一致
}
@Override
public String group() {
return "order-consumer-group"; // 消费组名,启动时自动 XGROUP CREATE
}
@Override
public Class<OrderMessage> 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 <stream> MAXLEN <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
```
---
## 十、Redis 缓存(`RedisCacheEnhanceTemplate`)
除 Stream 收发外,starter 同样提供开箱即用的**编程式缓存**,把 TTL、序列化、穿透/雪崩/击穿三防封装进一个 `getOrLoad`。注入即用,无需额外注解。
### 发送/读取
```java
import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
import org.springframework.stereotype.Service;
@Service
public class UserCache {
private final RedisCacheEnhanceTemplate cache;
public UserCache(RedisCacheEnhanceTemplate cache) {
this.cache = cache;
}
public User getUser(Long id) {
// 命中即返回;未命中则回源 loadFromDb 并写缓存,自动防穿透/雪崩/击穿
return cache.getOrLoad("user:" + id, User.class, 3600, () -> loadFromDb(id));
}
public void evict(Long id) {
cache.delete("user:" + id);
}
}
```
核心方法:
| 方法 | 说明 |
|---|---|
| `set(key, value[, ttlSeconds])` | 写缓存,TTL 叠加随机抖动(防雪崩);不传 TTL 用 `default-ttl-seconds` |
| `get(key, Class<T>)` / `get(key, TypeReference<T>)` | 读缓存,支持泛型;未命中或命中空值标记返回 `null`;脏数据抛 `IllegalStateException` |
| `delete(key)` / `exists(key)` | 删除 / 判存 |
| `expire(key, ttlSeconds)` | 显式续期(走抖动);key 不存在返回 `false` |
| `getOrLoad(key, type[, ttlSeconds], loader)` | 读取或回源;内置穿透(空值标记)+ 雪崩(TTL 抖动)+ 击穿(互斥锁)三防 |
### 配置项(前缀 `redis-cache`,均可选)
```yaml
redis-cache:
key-prefix: "cache:" # 统一 key 前缀
env-isolation: false # 按环境隔离 key(开启且 env 非空 → cache:env:key)
env: ""
default-ttl-seconds: 3600 # 默认 TTL(秒)
null-ttl-seconds: 60 # 空值标记 TTL(秒,穿透防护)
jitter-ratio: 0.1 # TTL 随机抖动比例(雪崩防护;0 关闭)
sliding-expire-enabled: false # 读命中真实值自动续 default-ttl(滑动过期,空值标记不续)
breakdown: # 击穿:互斥锁
enabled: true # 关闭则退化为无锁直接回源
lock-ttl-ms: 10000 # 锁持有 TTL
wait-timeout-ms: 3000 # 未抢到锁的最长等待,超时降级直接回源
wait-interval-ms: 50 # 未抢到锁的自旋重读间隔
```
> 缓存模块与 Stream 完全独立:只用缓存不注册任何 handler 也能工作;反之亦然。