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/