root c40f153642 fix(cache): exists 排除空值标记,与 get 口径一致
exists 原用 hasKey,会把持有空值标记的 key 判为"存在"(true),而 get 对同一 key
返回 null,二者口径打架。改为读值判断:空标记/缺失均返回 false,真实值返回 true。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:00:06 +08:00

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++ 等异构端互通)

一、引入依赖

<dependency>
    <groupId>com.njcn</groupId>
    <artifactId>redis-stream-springboot-starter</artifactId>
    <version>1.0.0-SNAPSHOT</version>
</dependency>

制品发布在内网 Nexus,若拉不到依赖请确认 settings.xml / pom.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:

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):

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):

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 起一条消费线程:

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)。

⚠️ saveExceptionMsgLogmessage 入参:不重试失败死信(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 等写法等价。

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=trueenc=gzipbody=base64(gzip(json));否则 enc=plainbody 为明文 json。消费侧仅按 enc 解码,与 topic 无关,可与 C++ 等异构端互通。
  • Stream 裁剪:框架按 trim.maxlen 精确裁剪 stream 防止无界增长(详见「七、配置项 · Stream 长度裁剪」)。请确保 maxlen 足够大,避免重试中的消息在达到 max-retry 前被裁掉(被裁掉的 PEL 消息会被静默强制 ACK)。
  • 无 handler 时容器空转:未注册任何 EnhanceStreamConsumerHandler Bean 时,监听/认领/裁剪线程均不启动,仅 RedisStreamEnhanceTemplate 可用于纯发送场景。

九、本地验证(可选)

集成测试需要本地 Redis;不可达时整类自动 skip(不影响 mvn test 全绿):

docker run -d -p 6379:6379 redis:6.2
mvn test

十、Redis 缓存(RedisCacheEnhanceTemplate)

除 Stream 收发外,starter 同样提供开箱即用的编程式缓存,把 TTL、序列化、穿透/雪崩/击穿三防封装进一个 getOrLoad。注入即用,无需额外注解。

发送/读取

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,均可选)

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 也能工作;反之亦然。

Description
redis消息队列的starter
Readme 181 KiB
Languages
Java 100%