Redis6.2+ Stream 安全清理:避免内存爆炸的最佳实践

Redis Stream 安全清理指南:避免内存爆炸的最佳实践

摘要 :Redis Stream 是轻量级消息队列的理想选择,但若不主动清理已消费消息,内存将持续增长直至 OOM。本文深入探讨如何安全、高效、自动化地清理 Redis Stream,涵盖消费者组管理、全局最小已处理 ID 计算、兼容性处理及 Spring Boot 集成方案。


一、为什么需要清理 Redis Stream?

Redis Stream 默认永不删除消息。即使所有消费者都已 ACK,消息仍保留在内存中。在高吞吐场景下(如每秒数千消息),几天内就可能占用数 GB 内存。

bash 复制代码
# 查看 Stream 内存占用
127.0.0.1:6379> MEMORY USAGE mystream
(integer) 1258291200  # 1.2 GB!

不清理的后果

  • 内存耗尽,Redis 被 OOM Killer 杀死
  • 主从复制延迟增大
  • RDB/AOF 文件膨胀

二、安全清理的核心原则

只删除"所有消费者组都已确认消费"的消息

这是唯一不会导致数据丢失的策略。关键挑战在于:如何确定"所有组都已处理到哪个 ID"?

消费者组状态解析

  • 每个消费者组维护 last_delivered_id(最新派发 ID)
  • 但真正已处理的 ID = pending 最小 ID 的前一个
  • 全局安全删除点 = 所有组中最小已处理 ID

三、清理策略:MINID vs MAXLEN

✅ 首选:XTRIM ... MINID(Redis 6.2+)

bash 复制代码
# 删除 1698765432-0 之前的所有消息
XTRIM mystream MINID ~ 1698765432-0
  • 精准:按 ID 删除,不多删不少删
  • 高效:即使删除百万条,延迟仅几十毫秒

⚠️ 兜底:XTRIM ... MAXLEN(兼容所有版本)

bash 复制代码
# 保留最近 10,000 条
XTRIM mystream MAXLEN ~ 10000
  • 简单:无需计算 ID
  • 风险高:可能误删未消费消息(若某个消费者落后)

💡 生产建议 :同时使用两者------优先 MINID,失败则 MAXLEN 兜底。


四、Spring Boot 2.7 实战实现

java 复制代码
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.stream.PendingMessagesSummary;
import org.springframework.data.redis.connection.stream.StreamInfo;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

@Slf4j
@AllArgsConstructor
public class RedisStreamCleanupService {

    private final StringRedisTemplate stringRedisTemplate;
    /**
     * 限制 trim 一次删除的数量
     */
    private static final byte[] LIMIT_COUNT = "10000".getBytes();
    private static final byte[] MINID_BYTES = "MINID".getBytes();
    private static final byte[] LIMIT_BYTES = "LIMIT".getBytes();
    private static final byte[] TILDE_BYTES = "~".getBytes();

    /**
     * 执行安全清理
     */
    public void safeTrim(String streamKey) {
        try {
            String safeMinId = computeSafeMinId(streamKey);
            if (safeMinId == null) {
                log.warn("无法计算 safeMinId,跳过清理: {}", streamKey);
                return;
            }
            Long deleted = null;

            // 执行: XTRIM mystream MINID ~ 1698765432-0 LIMIT 10000
            // MINID 需要redis 6.2+ 才支持
            deleted = stringRedisTemplate.execute((RedisCallback<Long>) connection -> {
                byte[] streamBytes = streamKey.getBytes();
                byte[] minIdBytes = safeMinId.getBytes();
                // 命令: XTRIM key MINID ~ id LIMIT 10000
                return (Long) connection.execute("XTRIM", streamBytes, MINID_BYTES, TILDE_BYTES, minIdBytes, LIMIT_BYTES, LIMIT_COUNT);
            });
            log.info("安全清理 Stream {}: 删除 ID <= {} 的消息,共 {} 条", streamKey, safeMinId, deleted);
        } catch (Exception e) {
            log.error("执行 Redis Stream {}安全清理失败", streamKey, e);
        }
    }

    /**
     * 计算所有消费者组的全局最小 pending ID
     * @return 可安全删除的 ID(此 ID 之前的消息都可删),若无法计算返回 null
     */
    private String computeSafeMinId(String streamKey) {
        // 获取所有消费者组信息
        StreamInfo.XInfoGroups groups = stringRedisTemplate.opsForStream().groups(streamKey);

        if (groups == null || groups.isEmpty()) {
            log.warn("Stream {} 无消费者组,无法计算 safeMinId", streamKey);
            return null;
        }

        List<String> pendingMinIds = new ArrayList<>();

        groups.forEach(group -> {
            String groupName = group.groupName();
            Long pendingCount = group.pendingCount();
            String minPendingId = null;
            if (pendingCount > 0) {
                // 有 pending,取该组最小 pending ID
                PendingMessagesSummary pendingMessagesSummary = stringRedisTemplate.opsForStream().pending(streamKey, groupName);
                if (pendingMessagesSummary != null) {
                    minPendingId = pendingMessagesSummary.minMessageId();
                }
            }

            if (minPendingId == null) {
                // 无 pending,取该组最后投递的 ID
                minPendingId = group.lastDeliveredId();
            }

            if (minPendingId != null && !"-".equals(minPendingId)) {
                pendingMinIds.add(minPendingId);
            }
        });

        if (pendingMinIds.isEmpty()) {
            return null;
        }

        // 找出全局最小 ID
        String globalMinId = Collections.min(pendingMinIds, this::compareStreamIds);
        return getPreviousId(globalMinId);
    }

    /**
     * 比较两个 Stream ID(格式: timestamp-sequence)
     */
    private int compareStreamIds(String id1, String id2) {
        String[] parts1 = id1.split("-");
        String[] parts2 = id2.split("-");
        long ts1 = Long.parseLong(parts1[0]);
        long ts2 = Long.parseLong(parts2[0]);
        if (ts1 != ts2) return Long.compare(ts1, ts2);
        return Long.compare(Long.parseLong(parts1[1]), Long.parseLong(parts2[1]));
    }

    /**
     * 获取前一个 ID(用于 XTRIM MINID)
     */
    private String getPreviousId(String id) {
        String[] parts = id.split("-");
        if (parts.length != 2) return null;
        long ts = Long.parseLong(parts[0]);
        long seq = Long.parseLong(parts[1]);
        return (ts - 1) + "-" + seq;
    }
}

Redis 官方 Stream 文档:https://redis.io/docs/latest/develop/data-types/streams/

相关推荐
GHL2842710902 小时前
redis编译调试(linux)
linux·数据库·redis
CodeAmaz3 小时前
Redis 持久化策略(RDB / AOF / 混合持久化)详解(含选型与线上实践)
redis·持久化·aof·rdb
就叫飞六吧4 小时前
三步搭建“钉钉待办推送” (curl版)+工作通知
数据库·redis·钉钉
佳瑞Jarrett4 小时前
我用 Vue + SpringBoot + Redis 写了个「文件快取柜」
vue.js·spring boot·redis
橘子真甜~4 小时前
Reids命令原理与应用2 - Redis网络层与优化,pipeline,发布订阅与事务
数据库·redis·缓存·事务·发布订阅·lua脚本·acid特性
召田最帅boy13 小时前
centos7安装Redis6并设置密码
redis·centos
cui_win14 小时前
Prometheus实战教程 - Redis 监控
数据库·redis·prometheus
@淡 定17 小时前
Redis持久化机制
数据库·redis·缓存
@淡 定19 小时前
主流缓存中间件对比:Redis vs Memcached
redis·缓存·中间件