session、限流计数、预算全塞进进程内存,扩到 4 个实例就开始全线告警。我们对比过 Caffeine 本地缓存和数据库原子操作,最后把分布式协调的活全部交给 Redis。评测平台日均调用 LLM 数十万次,session/限流/进度/价格/锁这些”分布式协调”诉求绕不开 Redis。平台用 Redis 替代了本地缓存和部分 DB 操作,性能提升一个数量级。下面是平台对 Redis 的全部用法盘点,每种场景都带实现要点和踩过的坑。
一、平台 Redis 用法总览
先把全部用法摆出来,后面每一节展开讲实现,这张表可以当索引图用:
| 场景 | 数据类型 | 典型 key | 价值 |
|---|---|---|---|
| 分布式 Session | String + Hash | session:{token} |
多实例共享登录态 |
| 接口限流 | ZSET | rate:{dim}:{key} |
防刷、保护下游 |
| 实时预算计数 | String | budget:{user}:{period} |
控成本 |
| 模型价格缓存 | Hash | price:{model_code} |
减少 DB 读 |
| WebSocket 跨实例推送 | Pub/Sub | ws:batch:{id} |
分布式协调 |
| 分布式锁 | String | lock:scene:{id} |
防并发冲突 |
| 在线状态 | ZSET | online:user |
看哪些用户在线 |
| 去重集合 | Set | dedup:output:{user} |
O(1) 查重 |
8 个场景覆盖了登录态、防刷、成本、性能、协调、并发六类问题,没有一个能用本地缓存替代。下面挑实现上有代表性的逐个展开。
二、分布式 Session
多实例部署后登录态必须共享,否则用户请求被负载均衡打到另一台实例就得重新登录,体验很割裂。我们第一个坑就踩在这:早期用本地 Session,用户反馈”登录一会儿就掉线”,排查半天才定位到是实例切换。后来统一走 Redis,所有实例共享同一份登录态:
@Service
public class SessionService {
public void create(String token, long userId) {
Session s = new Session(userId, Instant.now().plus(2, HOURS));
redisTemplate.opsForValue().set("session:" + token,
JsonUtil.toJson(s),
Duration.ofHours(2));
}
public Long getUserId(String token) {
String json = redisTemplate.opsForValue().get("session:" + token);
if (json == null) return null;
return JsonUtil.parse(json).getUserId();
}
}
配合 Spring Session + Redis,Controller 里直接 HttpSession.getAttribute("userId"),多实例自动共享。
这里注意 key 必须带过期时间,会话上限 2 小时,避免 key 无限堆积。我们一开始没设 TTL,一周后 session 内存涨了几个 GB,才补上过期策略,这也是后面所有 key 的通用约定。
三、接口限流(滑动窗口)
对外接口和 LLM 调用都要限流,限流计数必须全局共享,否则 4 个实例各计各的,实际放行量翻四倍。我们用 ZSET 存每个时间窗口的请求时间戳,配合滑动窗口判断:
public boolean tryPass(String key, int count, int windowSec) {
long now = System.currentTimeMillis();
Long ok = redisTemplate.execute(slidingWindowScript,
List.of(key), count, windowSec * 1000, now);
return ok != null && ok == 1L;
}
Redis 滑动窗口保证多实例限流精确(每实例都有部分请求也整体准)。
相比固定窗口,滑动窗口对突刺更敏感,不会出现”整点放量”的问题。代价是多一次 ZSET 写入,单 key 每秒几万完全扛得住。我们在 Lua 脚本里顺手清掉窗口外的旧时间戳,控制 key 体积,避免长期运行后 ZSET 越来越大。
四、实时预算计数
评测平台是 token 计费模式,用户充了预算就要实时扣减,超了就停。这里不能用 DB 行锁,并发高、延迟大,我们用 Redis 的 INCRBYFLOAT 原子累加:
@Service
public class BudgetService {
public BigDecimal accumulateAndCheck(long userId, BigDecimal fee) {
String dayKey = "budget:daily:" + userId + ":" + LocalDate.now();
String monthKey = "budget:monthly:" + userId + ":" + YearMonth.now();
// 累加
BigDecimal today = new BigDecimal(
redisTemplate.opsForValue().increment(dayKey, fee.doubleValue())
);
redisTemplate.expire(dayKey, Duration.ofDays(2));
// 同步累计月度
redisTemplate.opsForValue().increment(monthKey, fee.doubleValue());
redisTemplate.expire(monthKey, Duration.ofDays(35));
// 预算告警
BigDecimal dailyLimit = userService.getBudget(userId);
if (today.compareTo(dailyLimit) > 0) {
alertService.send(userId, "日预算已超", today);
}
return today;
}
}
INCRBYFLOAT 原子累加,O(1) 性能,每月清零通过 EXPIRE 实现。
这里有两个细节:日 key 过期时间设 2 天而不是 1 天,防止时区偏差导致统计中断;预算告警走异步消息,不在请求链路上同步推送,避免拖慢评测主流程。误删或重复扣费的场景用 Redis 原子命令天然规避,这也是比 DB 计数更省心的原因。
五、模型价格缓存
模型价格变更频率低(每月),但查询频率极高(每次 LLM 调用都查),属于典型的”读多写少”,必须缓存:
@Service
public class ModelPriceService {
@Autowired private StringRedisTemplate redis;
@Autowired private ModelPriceMapper mapper;
public ModelPrice get(String modelCode) {
// 1) Redis 查
String json = redis.opsForValue().get("price:" + modelCode);
if (json != null) return JsonUtil.parse(json, ModelPrice.class);
// 2) 回源 DB
ModelPrice p = mapper.selectByCode(modelCode);
if (p != null) {
redis.opsForValue().set("price:" + modelCode,
JsonUtil.toJson(p),
Duration.ofMinutes(10));
}
return p;
}
// 价格变更时主动失效
public void invalidate(String modelCode) {
redis.delete("price:" + modelCode);
}
}
命中率 99%+,DB 几乎没压力。
这是标准的 Cache-Aside 模式:先查缓存,未命中回源 DB 再回填,10 分钟 TTL 兜底。价格调整时调用 invalidate 主动失效,比只靠 TTL 更快生效,否则用户会按旧价跑几分钟。价格表每月变更一次,写失效的成本几乎可以忽略。
六、WebSocket 跨实例推送
批量测试的进度要靠 WebSocket 实时推给前端,但连接和任务消费可能落在不同的实例上。A 实例上用户连接,B 实例上 MQ 消费完任务要推给 A,最简单的方案是 Pub/Sub 广播:
@Service
public class WebSocketPushService {
@Autowired private StringRedisTemplate redis;
public void broadcast(long batchId, String msg) {
// 1) 本实例内推送
localPush(batchId, msg);
// 2) 跨实例广播
redis.convertAndSend("ws:batch:" + batchId, msg);
}
}
// 所有实例订阅
@EventListener
public void onMessage(Message msg) {
String body = new String(msg.getBody());
Long batchId = parseBatchId(msg.getChannel());
// 仅在本地有连接时才推
if (handler.hasLocalSession(batchId)) {
localPush(batchId, body);
}
}
Pub/Sub 是”即发即弃”,不持久化,适合实时通知。要控制消息体积在 KB 级,进度类消息本身很轻。如果对可靠性要求高,可以换 Stream 并配消费者组,但批量测试的进度推送允许少量丢消息,Pub/Sub 足够。
七、分布式锁
场景:管理员点”激活 v3 prompt”,要保证只有一个实例在执行激活:
public boolean activate(long promptId) {
String lockKey = "lock:prompt:" + promptId;
String token = UUID.randomUUID().toString();
// SETNX + EXPIRE 原子
Boolean ok = redis.opsForValue().setIfAbsent(lockKey, token,
Duration.ofSeconds(30));
if (!Boolean.TRUE.equals(ok)) {
throw new BusinessException("操作正在进行中");
}
try {
promptService.activate(promptId);
return true;
} finally {
// Lua 脚本保证只删自己的锁
redis.execute(unlockScript, List.of(lockKey), token);
}
}
分布式锁容易翻车的是”锁被误删”:A 持锁超时过期,B 抢到锁,A 结束后把 B 的锁删了。解法是锁值存一个随机 token,删除时用 Lua 比对 token,只删自己的锁。这里的 setIfAbsent 必须带过期时间,是原子操作,分开写 SETNX 和 EXPIRE 会留出间隙,锁可能永远不释放。
八、去重集合
批量测试里”同一用户同答案提交两次”要识别,用 Set 的 SADD 天然做幂等:
public boolean isDuplicate(long userId, String answerHash) {
String key = "dedup:answer:" + userId;
Boolean first = redis.opsForSet().add(key, answerHash) > 0;
redis.expire(key, Duration.ofDays(7));
return !first; // 已存在则是重复
}
SADD 返回 1 表示首次写入,0 表示已存在。这里没用布隆过滤器,是因为业务上需要 100% 精确,布隆过滤器的假阳性会漏掉用户合法提交,代价比省那点内存高得多。
九、为什么不用本地缓存(Caffeine/Guava)
平台在选型时认真对比过本地缓存和 Redis,结论是”单实例可以用本地,多实例必须 Redis”:
| 维度 | 本地缓存 | Redis |
|---|---|---|
| 容量 | 受限于单实例堆(GB 级) | 受限于总内存(TB 级) |
| 一致性 | 多实例各自缓存,可能不一致 | 天然一致 |
| 持久化 | 进程退出丢失 | 可持久化(AOF/RDB) |
| 跨实例 | 不支持 | 原生支持 |
| 性能 | 纳秒级 | 亚毫秒级 |
平台用 Redis 的根本原因是”多实例部署”——本地缓存在 4 个实例上命中率只有 25%,4 倍 DB 压力。Redis 命中率 99%+,单实例 RT < 1ms,性能差异可忽略。
本地缓存不是不能用,而是用错了位置。我们保留了 Caffeine 做单实例内的热数据缓存(比如高频小字典),跨实例共享的状态全部走 Redis,两层各司其职。纯用 Redis 对读多写少的场景反而多一次网络往返,这一层取舍要按场景判断。
十、Redis 选型与配置
集群部署时的连接配置如下:
spring:
redis:
cluster:
nodes: redis-1:6379,redis-2:6379,redis-3:6379
max-redirects: 3
timeout: 200ms
lettuce:
pool:
max-active: 200
max-idle: 50
平台用 Redis Cluster 6 节点(3 主 3 从),单 key QPS > 5 万。
选 Redis Cluster 而不是单点,是因为单点故障影响面太大,评测主链路不能断。Cluster 模式下 key 要注意加哈希标签,把同一用户的 key 落到同一 slot,跨 slot 的 MULTI 命令不支持,这个细节在 FAQ 里也有说明。
十一、踩过的坑
- 大 Key 阻塞:某用户 session 存了 50MB 数据,DEL 时阻塞 5 秒。规范:单 key < 1MB,大数据分片。
- 缓存穿透:不存在的 modelCode 每次回源 DB。加布隆过滤器或
redis.set(key, "null", 1min)占位。 - 缓存雪崩:大量 key 同时过期。给过期时间加 ±10% jitter。
- Redis 内存爆:价格缓存本应 100 条,监控发现 1 万条,是旧 modelCode 没清理。每月巡检清理
price:*集合。 - 分布式锁误删:A 持锁 30s 没释放过期,B 抢到锁,A 醒来后
DEL把 B 的锁删了。Lua 脚本加 token 校验。
这五个坑几乎每个团队都会踩一遍。大 Key 和锁误删属于设计层面要规避的,穿透和雪崩是缓存标配问题,内存膨胀靠监控巡检兜底。建议把这几条写进团队 Redis 使用规范,代码 review 时逐条对照,能挡掉大部分线上事故。排查线上 Redis 问题时我一般按固定步骤走,避免漏查:
- 先看监控大盘:内存、连接数、命中率三个指标,确认是容量问题还是访问问题;
- 再查慢日志,定位慢命令与慢 key;
- 接着扫描大 Key,确认是否存在超过阈值的单 key;
- 最后回到业务代码,核对缓存键是否泄漏、过期时间是否合理。
这套流程跑下来,绝大多数问题都能在一小时内定位。
十二、Redis 监控
平台对 Redis 关键指标埋点:
metrics.gauge("redis.connections.active", connectionFactory.getActive());
metrics.gauge("redis.memory.used", connectionFactory.getMemoryUsed());
metrics.counter("redis.cmd", "name", "GET", "result", "hit").increment();
Grafana 看板:连接数、内存使用、命中率、慢命令、阻塞数。
监控的意义在于把”看不见的故障”变成”提前的告警”。内存涨到 70% 就开始预警,命中率跌到 90% 以下大概率是 key 设计出了问题,这些阈值都是实际调过坑得出的。慢命令和阻塞数直接关联大 Key 问题,告警后立刻查最近的大 key 就能定位。
常见问题(FAQ)
Q1:Redis 和 DB 怎么保证一致性?
平台用 Cache-Aside 模式:先 Redis 后 DB。更新时先写 DB 再删缓存(不更新缓存,避免并发写穿)。容忍短时不一致(下次读会回源)。
Q2:限流计数为什么不放本地?
本地计数 4 实例各自计 60/分钟,实际是 240/分钟。Redis 计数是”全局共享”。
Q3:Redis Cluster 怎么选 key?
所有 key 加 {} 哈希标签,强制同 slot。如 session:{userId}:token,保证用户的所有 session 在同一节点。