Redis 主要用在选型对比(AI 大模型评测平台的分布式缓存与协调)

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 问题时我一般按固定步骤走,避免漏查:

  1. 先看监控大盘:内存、连接数、命中率三个指标,确认是容量问题还是访问问题;
  2. 再查慢日志,定位慢命令与慢 key;
  3. 接着扫描大 Key,确认是否存在超过阈值的单 key;
  4. 最后回到业务代码,核对缓存键是否泄漏、过期时间是否合理。

这套流程跑下来,绝大多数问题都能在一小时内定位。

十二、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 在同一节点。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部