你给 Agent 平台发了一条消息:「帮我查一下昨天长沙的天气,整理成一段话发到工作群。」
这句话落到后端,会触发一串连锁动作:解析意图、抽取参数、查工具配置、调第三方 API、把多个子任务并行跑完再汇总。这一串动作里,Redis 至少会被摸到五次------登录态的 token 存了一次,工具的配置缓存读了一次,调用第三方前的限流计数加了一次,并行分支的结果写了一次,汇总节点抢锁抢了一次。
这五次,正好对应五个经典场景。本文不讲「Redis 有哪些数据类型」这种开场白,而是顺着这一条真实请求的链路,把每个场景的应用设计、底层内核、以及只有踩过才会记得的坑,一件件摊开。代码会给出能直接跑的版本,并解释每一行为什么这么写。
一、先看全景:一条请求在 Redis 上划过的轨迹
先画整条链路,后面所有内容都挂在图上:
用户发起一条消息
│
▼
┌───────────────┐ ① 登录态校验 场景3
│ 网关/会话 │──── Redis: token:{token}
└───────────────┘ String + TTL 2h
│
▼
┌───────────────┐ ② 意图识别 & 工具解析 场景4
│ 工作流编排 │──── Redis: spark_bot:tool_schema:{toolId}
└───────────────┘ 热点配置缓存(读多写少)
│ 识别出要调工具
▼
┌───────────────┐ ③ 调第三方 API 前限流 场景5
│ 工具调用 │──── Redis: rate:limit:{toolId}:{秒戳}
└───────────────┘ INCR 秒级计数
│
▼
┌───────────────┐ ④ 并行分支跨机器传参 场景1
│ 并行子任务 │──── Redis: workflow:{wfId}:node:{nodeId}
└───────────────┘ 跨进程的"共享内存"
│ 所有分支完成
▼
┌───────────────┐ ⑤ 汇总节点只执行一次 场景2
│ 结果汇总 │──── Redis: workflow:lock:{wfId}:{nodeId}
└───────────────┘ 分布式锁
把这张图压缩成一张对照表,先有个整体印象:
| 场景 | 数据结构 | 核心命令 | TTL 策略 | key 设计 |
|---|---|---|---|---|
| 跨节点上下文传递 | String | SET / GET | 24h,随工作流生命周期 | workflow:{wfId}:node:{nodeId} |
| 分布式锁 | String(Redisson 用 Hash) | SET NX EX / Lua |
锁持有期 + 看门狗续期 | workflow:lock:{wfId}:{nodeId} |
| 会话 Token | String | SET / GET / DEL / EXPIRE | 2h,活跃时滑动续期 | token:{token} |
| 热点配置缓存 | String | GET / SET / DEL | 1 天 | spark_bot:tool_schema:{toolId} |
| 限流计数 | String / ZSet | INCR / Lua | 2s / 窗口长度 | rate:limit:{toolId}:{秒戳} |
注意到没有------五个场景,四个用的是 String,一个用了 ZSet。String 在 Redis 里的存在感就是这么强,因为绝大多数的缓存、计数、锁本质上都是"一个 key 存一个值"。
这一段的结论:Redis 不是五个独立的功能,是一张网。 读完整篇再回头看这张图,你会看到每个场景背后共享同一套内核机制------原子性、过期、序列化、高可用。这就是为什么很多坑是通用的。
二、打底:统一封装和序列化,先把这个坑填了
动手写场景之前,先做两件小事:配置序列化器,封装一个基础工具类。这两件事不做,后面每个场景都会踩同一个坑。
2.1 序列化:为什么你的 key 是一串乱码
Spring Boot 的 RedisTemplate 默认用 JDK 的 JdkSerializationRedisSerializer,不配置直接用,存进去的 key 长这样:
\xAC\xED\x00\x05t\x00\x07workflow...
一串以 \xAC\xED 开头的乱码。后果有三层:
- 用
redis-cli keys *根本看不出是什么业务数据,排查全靠猜; - Java 存的数据其他语言读不了,跨语言协作直接断;
- JDK 序列化体积大,同样的内容存进去比 JSON 多好几倍内存。
所以第一步,把序列化器换成「key 用 String,value 用 JSON」:
java
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer keySerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer();
// key 必须 String,否则上面那串 \xAC\xED 乱码就来了
template.setKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashKeySerializer(keySerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
}
小细节:
GenericJackson2JsonRedisSerializer会在 JSON 里额外存一个@class字段记录类型信息,反序列化时能自动还原成原来的对象。代价是每个值都多占一点字节。如果对内存敏感,可以退一步:value 序列化成纯 JSON 字符串,读取时把目标类型作为参数传进去------本文封装的工具类就走这条路。
2.2 统一封装:一个 RedisBaseUtil 就够了
把常用的几个操作收拢到一个类里,业务代码不直接碰 RedisTemplate。这样序列化方式、命令细节都只在一个地方维护:
java
import com.alibaba.fastjson2.JSON;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Component
public class RedisBaseUtil {
@Resource
private RedisTemplate<String, String> redisTemplate;
// 写入并带过期时间。value 统一 JSON 化,业务层不用关心序列化
public void setEx(String key, Object value, long expire, TimeUnit unit) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(value), expire, unit);
}
// 读取并按指定类型反序列化
public <T> T get(String key, Class<T> clazz) {
String str = redisTemplate.opsForValue().get(key);
if (str == null) return null;
return JSON.parseObject(str, clazz);
}
// 续期:刷新过期时间,token 滑动续期要用
public void expire(String key, long expire, TimeUnit unit) {
redisTemplate.expire(key, expire, unit);
}
public Boolean delete(String key) {
return redisTemplate.delete(key);
}
// 执行一段 Lua,返回 Long。限流、锁这些要求"原子"的操作统一走这里
public Long executeLua(String script, List<String> keys, Object... args) {
DefaultRedisScript<Long> scriptObj = new DefaultRedisScript<>(script, Long.class);
return redisTemplate.execute(scriptObj, keys, args);
}
}
这里留了个口子:executeLua。后面场景 2 和场景 5 会反复用到它,凡是"多个 Redis 命令必须作为一个整体执行"的场景,都交给 Lua,这是 Redis 保证原子性的标准姿势。
三、场景 1:并行分支的跨节点传参 ------ String + TTL
3.1 业务问题:内存不通,上下文怎么传
一个工作流拆成多个并行分支后,这些分支可能被调度到不同的机器上跑(尤其混合了 Python 和 Java 两种语言)。JVM 内存、Python 进程内存都是各管各的,上游节点算出的结果,下游节点看不到。
最简单的解法是传参时把结果带回,但工作流编排往往是异步的------上游跑完,下游还没被调度。需要一个两边都能随时读写的"共享内存"。
3.2 应用设计
- key 设计 :
workflow:{wfId}:node:{nodeId}。工作流 ID 加节点 ID,天然不冲突,还能按前缀定位一个工作流的所有节点数据。 - TTL 设计 :设 24 小时。工作流执行失败、被终止时,这些上下文没人清理,如果当初没设 TTL,就是一堆永远占着内存的僵尸 key。设了 TTL,最坏情况是留着过期,由 Redis 自己回收。
- value 设计:JSON 序列化。跨语言(Java / Python)都能解析。
3.3 内核:这个场景里,Redis 靠什么撑住
这个场景值得讲两层内核:
String 的底层是 SDS(Simple Dynamic String),不是 C 语言的 char\[\]。
- C 的字符串用
char[] + '\0'结尾,想知道长度要遍历一遍,O(n);追加内容要手动重新分配内存,一个不留神就崩。 - SDS 在结构体里存了一个
len字段,取长度 O(1);追加时按倍数预分配空间,减少 realloc 次数;因为是"长度 + 字节数组",中间出现\0也没关系------二进制安全。
这直接决定了你可以在 value 里放什么:JSON 文本、二进制序列化内容、压缩数据,都行。SDS 是 Redis 所有数据结构的底座,String 用得最多,它的性能就是整个 Redis 的基线。
过期不是"到点就消失",而是两套机制配合。
Redis 的过期靠"惰性删除 + 定期删除"两套组合:
- 惰性删除:访问一个 key 时,发现它过期了才删。省 CPU,但过期 key 会一直赖在内存里,直到被碰一下。
- 定期删除 :Redis 每隔 100ms(由
server.hz控制,默认 10 次/秒)随机抽一批带过期时间的 key,把过期的删掉。之所以是"随机抽一批"而不是"全扫一遍",是为了不让删除动作阻塞主线程。
所以你要记住一个事实:设了 TTL 的 key,不保证在 TTL 到达那一刻就消失。它在被访问时、或在某次定期抽查中被删掉。如果写入速度远超删除速度,内存还是会涨------这时候靠内存淘汰策略兜底(场景 3 会细讲)。
3.4 代码
java
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Service
public class WorkflowNodeContextService {
@Resource
private RedisBaseUtil redisBaseUtil;
// 上游节点保存执行结果。TTL 设 24 小时,防僵尸 key
public void saveNodeData(String workflowId, String nodeId, Object nodeResult) {
String key = String.format("workflow:%s:node:%s", workflowId, nodeId);
redisBaseUtil.setEx(key, nodeResult, 24, TimeUnit.HOURS);
}
// 下游节点读取上游结果,按目标类型反序列化
public <T> T getNodeData(String workflowId, String nodeId, Class<T> clazz) {
String key = String.format("workflow:%s:node:%s", workflowId, nodeId);
return redisBaseUtil.get(key, clazz);
}
}
使用:
java
// 上游 LLM 解析节点写完
workflowNodeContextService.saveNodeData(
"wf_001", "node_llm_parse",
Map.of("text", "查长沙天气", "tool", "weather_api", "cost", 100));
// 下游调用节点读回来
Map<String, Object> data = workflowNodeContextService.getNodeData(
"wf_001", "node_llm_parse", Map.class);
3.5 这个场景的坑
- 反序列化时的类型丢失 。
get(key, Map.class)反序列化出来的是LinkedHashMap,嵌套的复杂对象(比如 Map 里套 List 套对象)如果不带类型信息,读出来全是 Map/String,取字段时报ClassCastException。跨语言传输的上下文,建议约定成"纯 JSON 数据",别指望自动还原成强类型对象。 - value 别塞大对象。一个节点结果几 MB,五个节点就是几十 MB,还都是同一个工作流的。后面「大 key 的坑」一节会讲它为什么危险。
- TTL 和业务生命周期的错配。工作流最长可能跑几天,24h TTL 到了工作流还没结束,下游读不到数据。设计时要按业务真实周期定 TTL,而不是拍脑袋。
这一段的结论:TTL 是 Redis 里最容易顺手写、也最容易写出事的一个参数。 设了,防僵尸 key;不设,等 Redis 内存报警的时候再回头补,就要面对一堆不知道能不能删的 key。
四、场景 2:分布式锁 ------ 并行分支收敛时的"只执行一次"
4.1 业务问题:单机 synchronized 在集群里不灵了
工作流的汇总节点,要求并行分支全部完成后只执行一次。单机部署时 synchronized 就够了------JVM 内互斥。但多实例部署后,两个实例的 JVM 内存互不相通,synchronized 管不到另一个机器上的线程,两个实例可能同时把汇总逻辑跑一遍,破坏幂等。
需要一个"所有机器都认"的锁,Redis 就是那个所有机器都能访问到的地方。
4.2 应用设计
- 锁粒度 :
workflow:lock:{wfId}:{nodeId}。锁到"某个工作流的某个节点",而不是"所有工作流共用一把锁",避免无关请求互相阻塞。 - 自动释放:锁必须带过期时间。持有锁的进程如果崩了,没有过期时间的锁会永远锁死(死锁)。
- 可重入:同一线程在持锁期间再次加锁要能成功。Redisson 天然支持。
- 自动续期:业务执行时间可能超过锁的过期时间,需要在持有期间持续续期(看门狗)。这解决的是"锁提前过期,另一个请求进来,两个请求同时跑"的经典问题。
4.3 内核:Redis 凭什么能当分布式锁
核心是两条命令必须"原子"地执行。
加锁的本质是 SET key value NX EX:只有 key 不存在时才写入,并同时设置过期时间。关键点在于 NX 和 EX 必须放在同一条命令里 。拆成两条------先 SETNX 再 EXPIRE------中间进程一挂,锁就没有过期时间,直接死锁。SET key value NX EX 是一条命令,Redis 单线程执行命令,不存在被打断的窗口。
为什么 Redis 的命令天然原子? 因为 Redis 是单线程事件循环:所有命令在一个线程里串行执行,一条命令从头到尾跑完,才轮到下一条。没有并发,就没有竞态,所以 INCR、SETNX 这些操作天然线程安全。这也意味着------Lua 脚本里的一段逻辑,同样在整个执行期间不会被其他命令插入。这就是锁和解锁都敢交给 Lua 的底气。
解锁为什么必须用 Lua? 解锁是"比对 value 是不是自己的,再删"。两个操作必须原子,否则会出现这个竞态:
线程 A 持锁,业务跑太久,锁过期了
线程 B 加锁成功,开始执行
线程 A 跑完,直接 del ------ 把 B 的锁删了!
线程 C 趁机加锁成功 ------ 两个请求同时执行
正确做法是删除前先比对 value,但"比对 + 删除"是两步,中间依然会被插队。所以用 Lua 把两步合成一段原子脚本:
lua
-- 解锁:value 是自己的才删,否则不动
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
value 存什么?一个「客户端标识 + 线程 ID」的随机串,比如 UUID + ":" + threadId,保证只有持锁的人能解锁。
4.4 代码:先手写一版,看懂坑在哪,再换 Redisson
先看看手写版是什么样,这是理解 Redisson 干了什么的基础:
java
// 手写版 ------ 演示原理,生产环境直接换 Redisson
@Service
public class ManualRedisLock {
@Resource
private RedisBaseUtil redisBaseUtil;
// 加锁:SET key value NX EX 一条命令完成"不存在才写 + 设过期"
public boolean tryLock(String lockKey, String value, long expireSeconds) {
// setIfAbsent 就是 SET NX EX ------ 这两个参数必须同时给,否则有死锁风险
return redisBaseUtil.trySet(lockKey, value, expireSeconds, TimeUnit.SECONDS);
}
// 解锁:比对 + 删除,用 Lua 保证原子
public boolean unlock(String lockKey, String value) {
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) " +
"else return 0 end";
return redisBaseUtil.executeLua(lua, List.of(lockKey), value) == 1L;
}
}
RedisBaseUtil 里补一个方法:
java
// 等价于 SET key value NX EX ------ 原子地"不存在才设置 + 带过期"
public Boolean trySet(String key, String value, long expire, TimeUnit unit) {
return redisTemplate.opsForValue().setIfAbsent(key, value, expire, unit);
}
手写版能工作,但有三个"续命"问题没人管:
- 锁过期了业务还没跑完------需要看门狗自动续期;
- 可重入------要记录同一线程的重入次数;
- 释放时机------什么时候该停掉续期。
这三个正是 Redisson 替你做的事。看门狗的逻辑,说穿了就是一段定时任务:没指定 leaseTime 时,默认锁持有 30 秒,每 10 秒检查一次,只要锁还活着就续期到 30 秒,业务跑多久续多久;业务结束后显式释放,续期任务跟着停。
Redisson 实际加锁执行的是下面这段 Lua(简化版),它用 Hash 结构存「线程标识 → 重入次数」,一石二鸟地解决了可重入:
lua
-- KEYS[1] = 锁的 key
-- ARGV[1] = 过期时间(毫秒)
-- ARGV[2] = 线程标识,形如 "UUID:threadId"
if redis.call('exists', KEYS[1]) == 0 then
redis.call('hset', KEYS[1], ARGV[2], 1) -- 第一次加锁,次数记 1
redis.call('pexpire', KEYS[1], ARGV[1]) -- 带上过期时间
return nil
end
if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then
redis.call('hincrby', KEYS[1], ARGV[2], 1) -- 同一线程重入,次数 +1
redis.call('pexpire', KEYS[1], ARGV[1]) -- 顺带续期
return nil
end
return redis.call('pttl', KEYS[1]) -- 锁被别的线程持有,返回剩余时间
解锁时重入次数减到 0 才真正删 key------这就是可重入的实现。所以项目里别自己造锁,直接用 Redisson:
java
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Service
public class DistributedLockService {
@Resource
private RedissonClient redissonClient;
/**
* 抢锁。传入一个完整的锁 key,谁来用都行。
* @param lockKey 锁 key,调用方按业务设计好,如 "workflow:lock:{wfId}:{nodeId}"
* @param waitSeconds 抢锁等待时间,0 表示抢不到立刻返回
* @param leaseSeconds 锁持有时间;传 -1 则开启看门狗自动续期,业务跑多久续多久
*/
public boolean tryLock(String lockKey, long waitSeconds, long leaseSeconds) {
RLock lock = redissonClient.getLock(lockKey);
try {
return lock.tryLock(waitSeconds, leaseSeconds, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 中断状态要保留,别吞掉
return false;
}
}
public void unlock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
// 只释放当前线程持有的锁,防止误删别人的锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
业务调用------拿到锁才执行,拿不到就跳过,保证只跑一次:
java
// 多个上游并行完成后,并发进入这里
String wfId = "wf_001";
String nextNode = "node_end_summary";
String lockKey = String.format("workflow:lock:%s:%s", wfId, nextNode);
if (lockService.tryLock(lockKey, 0, 30)) {
try {
updateWorkflowNodeStatus(wfId, nextNode); // 唯一执行:汇总、更新状态
} finally {
lockService.unlock(lockKey); // 必须 finally,防止异常导致锁不释放
}
} else {
// 抢锁失败,说明别的实例已经处理过了,幂等跳过
log.info("节点已被其他实例执行,跳过");
}
4.5 这个场景的坑
- 忘记释放锁 = 死锁 。
unlock必须放在finally里。配合锁自带的过期时间,即使代码异常,锁最坏也会在过期后自动释放------过期时间是"进程崩溃"的最后一道防线,不是让你放心忘记释放的。 - 误删别人的锁。必须比对 value,必须用 Lua。Redisson 已经处理好了,手写版别漏。
- 业务执行时间超过锁过期时间。锁过期后另一个请求拿到锁,两个请求同时执行。解法是看门狗续期,或者业务上保证单次执行时间远小于锁持有时间。
- 主从切换丢锁 。这是 Redis 分布式锁最深的坑:主节点写入锁成功,但还没同步到从节点,主节点挂了,哨兵把从节点提升为主------新主上没有这把锁,另一个客户端就能拿到同一把锁。RedLock 算法试图解决这个问题,但业界对它一直有争议(Kleppmann 和 antirez 为此吵过一轮)。现实里的取舍是:绝大多数业务接受这个极小概率窗口(故障转移窗口内才可能发生),真正强一致、不能容忍任何并发的场景,会改用 etcd / ZooKeeper 这种基于 Raft / Paxos 的组件。用 Redis 锁之前,先想清楚你的业务在不在"绝大多数"里。
- 锁场景必须开持久化。如果 Redis 只存内存,进程重启锁就丢了,同样会出现"锁没了但业务以为锁还在"。
这一段的结论:分布式锁的难点不在加锁,而在"进程可能随时死掉"这件事上。 过期时间是防死锁的,看门狗是防提前过期的,value 比对是防误删的,持久化是防重启丢锁的。Redisson 把这四件事都做了,所以别重复造轮子。
五、场景 3:会话 Token / Session ------ 让服务无状态
5.1 业务问题:集群里登录态怎么共享
服务横向扩容成多个实例后,用户第一次请求打到实例 A,登录态存在实例 A 的内存里;第二次请求被负载均衡转发到实例 B,B 不认识这个用户------直接被踢下线。让服务无状态的办法是把登录态从 JVM 内存挪出来,放到所有实例都能访问的 Redis 里,实例只负责"查 Redis"。
5.2 应用设计
- key 设计 :
token:{token}。token 本身是随机串,前缀隔离业务。 - TTL 设计:2 小时。用户活跃时每次请求续期(滑动过期),不活跃 2 小时自动失效,不需要写定时任务去清过期 token。
- 强制下线 :直接
DEL。这是 Redis 存会话相比 JWT 的一个天然优势------JWT 签出去就收不回来(除非黑名单),Redis 的 token 删掉就立刻失效。
5.3 内核:内存淘汰策略,token 缓存场景的隐形规则
会话缓存是 Redis 内存压力的主要来源之一:用户量大,token 多,每个还带几小时 TTL。这里必须搞清楚 Redis 满了会发生什么,也就是 maxmemory-policy。
默认策略是 noeviction------内存满了,写入直接报错。对于生产环境,这意味着缓存雪崩之外,还有一层"写入失败"的隐患。
常见策略按场景挑:
| 策略 | 行为 | 适用 |
|---|---|---|
noeviction |
满了直接拒绝写入 | 默认值,不配置的后果 |
allkeys-lru |
所有 key 按最近最少使用淘汰 | 缓存类数据,最常用 |
allkeys-lfu |
按访问频率淘汰(LFU) | 访问分布极不均匀,热点特别集中 |
volatile-lru |
只淘汰设了 TTL 的 key | 想保证"没设 TTL 的 key 永不淘汰"时 |
这里有个隐蔽的坑:如果你的策略是 volatile-lru,那没设 TTL 的 key 永远不会被淘汰。一旦有代码忘了设 TTL(场景 1 说的僵尸 key),这些 key 就变成"不可淘汰"的常驻内存,策略失效。所以 token 缓存的 TTL 一定要写死,别留口子。
另外重申一遍场景 1 讲过的机制:过期 key 靠惰性删除 + 定期删除,它不是实时消失的 。大量 token 同时过期时,内存水位不会瞬间下降,要等定期删除慢慢回收。如果内存告急,宁可主动加 allkeys-lru 让 Redis 兜底,也别赌"TTL 到了内存就下来"。
5.4 代码
java
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Service
public class UserTokenSessionService {
// token 有效期:2 小时
private static final long TOKEN_TTL_HOURS = 2;
@Resource
private RedisBaseUtil redisBaseUtil;
// 登录成功,写入会话。key 带 token 前缀,避免和其他业务冲突
public void loginSaveToken(String token, UserLoginDTO user) {
redisBaseUtil.setEx("token:" + token, user, TOKEN_TTL_HOURS, TimeUnit.HOURS);
}
// 网关校验:根据 token 取用户。查不到就是未登录或已过期
public UserLoginDTO getUserByToken(String token) {
return redisBaseUtil.get("token:" + token, UserLoginDTO.class);
}
// 退出登录 / 强制下线:直接删 key,登录态立刻失效
public void logout(String token) {
redisBaseUtil.delete("token:" + token);
}
// 滑动续期:每次请求刷新过期时间,活跃用户不会被踢
public void refreshToken(String token) {
redisBaseUtil.expire("token:" + token, TOKEN_TTL_HOURS, TimeUnit.HOURS);
}
public static class UserLoginDTO {
private Long userId;
private String username;
private String role;
// getter / setter
}
}
网关侧校验一次,就完成了"无状态":
java
// 网关过滤器里:取 token → 查 Redis → 拿到用户就放行
UserLoginDTO user = tokenService.getUserByToken(request.getToken());
if (user == null) {
return unauthorized("登录已过期,请重新登录");
}
// 顺手刷新,活跃用户永远不过期
tokenService.refreshToken(request.getToken());
5.5 这个场景的坑
- 滑动续期做过头 :每次请求都
expire,活跃用户永不过期,等于没有过期。如果产品上需要"强制 2 小时重新登录",就不要滑动续期,或改成固定到期时间。 - key 前缀泄露到日志 :token 是敏感信息,打日志时别把整个
token:{token}打出来,脱敏成token:{token前8位}****。 - 查 key 别用
KEYS token:*:Redis 单线程,KEYS会全表扫描所有 key,命令执行期间整个 Redis 阻塞,所有请求排队------线上一个KEYS就能把服务打挂。需要按前缀找 key 时用SCAN(游标迭代,不阻塞),或者干脆维护一个「在线用户集合」单独记录。
六、场景 4:热点配置缓存 ------ 缓存一致性的那道坎
6.1 业务问题:工具的 schema 被高频读
工作流里要调工具,每个工具带一份 schema(参数定义、入参校验规则)。这份数据读多写少------每次调用工具前都要读一遍,但配置很少改。直接查数据库,高频下 DB 扛不住;把这部分数据放缓存,命中率接近 100%。
6.2 应用设计:缓存更新的经典姿势 Cache Aside
缓存的读写姿势要统一,业界最常用的叫 Cache Aside:
- 读:先查缓存,命中直接返回;未命中查 DB,回填缓存。
- 写 :先更 DB,再删缓存。注意是"删缓存",不是"更缓存"。
为什么写的时候删而不是更新?两个原因:
- 更新缓存有竞态:两个线程同时更新同一个 key,后写的覆盖先写的,最终可能和 DB 不一致。删掉让下次读重建,天然避开了"写写竞态"。
- 省事:删一个 key 是一次操作,更新缓存还要先序列化,删了重建的成本反而低。
但"先更 DB 再删缓存"有一个短暂的不一致窗口:DB 更新完、缓存还没删的瞬间,一个并发读会读到旧缓存。这个窗口极小(毫秒级),大部分业务可以接受。接受不了的,看后面的「延迟双删」和更重的手段。
6.3 内核:三个"穿",穿透 / 击穿 / 雪崩
缓存场景 90% 的线上事故,都能归到这三个词上。它们经常被混为一谈,实际是三种完全不同的故障模式:
| 穿透 | 击穿 | 雪崩 | |
|---|---|---|---|
| 本质 | 查了不存在的数据 | 一个热点 key 过期 | 大批 key 同时过期 |
| 后果 | 每次请求都打 DB | 瞬间请求全压在一个 key 的重建上 | 请求洪峰全打 DB |
| 解法 | 空值缓存 / 布隆过滤器 | 互斥锁重建 / 逻辑过期 | 过期时间加随机值 |
穿透的根源是缓存里根本没有这个 key(因为数据不存在),所以永远不会回填。解法一是查不到也缓存一个空值,设短 TTL;解法二是布隆过滤器,先把不存在的 key 拦在 DB 外面。
击穿是单点问题:某个热点 key 过期的那一瞬间,所有请求同时发现缓存没有,一起涌向 DB。解法是用分布式锁保证只有一个请求去重建,其他人等锁释放后读新缓存。
雪崩 是全局问题:一批 key 同一天、同一时刻过期,请求同时落 DB。解法是过期时间加随机抖动,把过期时间点打散------这也是场景 1 里「TTL 不是拍脑袋」的延伸。
6.4 代码
基础版(Cache Aside + 穿透兜底):
java
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
@Service
public class ToolSchemaCacheService {
@Resource
private RedisBaseUtil redisBaseUtil;
@Resource
private DistributedLockService lockService;
private static final String CACHE_PREFIX = "spark_bot:tool_schema:";
// 模拟 DB
// private String loadFromDb(Long toolId) { return toolMapper.selectSchema(toolId); }
// private void updateDb(Long toolId, String schema) { toolMapper.updateSchema(toolId, schema); }
// 读:缓存优先,未命中查 DB 回填
public String getToolSchema(Long toolId) {
String key = CACHE_PREFIX + toolId;
String cache = redisBaseUtil.get(key, String.class);
if (cache != null && !cache.isEmpty()) {
return cache;
}
String schema = loadFromDb(toolId);
if (schema == null) {
// 穿透兜底:查不到的 key 也缓存空值,短 TTL,防止恶意 key 反复打 DB
redisBaseUtil.setEx(key, "", 60, TimeUnit.SECONDS);
return null;
}
redisBaseUtil.setEx(key, schema, 1, TimeUnit.DAYS);
return schema;
}
// 写:先更 DB,再删缓存
public void updateToolSchema(Long toolId, String newSchema) {
updateDb(toolId, newSchema);
redisBaseUtil.delete(CACHE_PREFIX + toolId);
}
}
击穿防护版------热点 key 重建加互斥锁:
java
// 缓存击穿防护:热点 key 过期瞬间,只允许一个请求重建缓存
public String getToolSchemaAntiBreakdown(Long toolId) {
String key = CACHE_PREFIX + toolId;
String cache = redisBaseUtil.get(key, String.class);
if (cache != null && !cache.isEmpty()) {
return cache;
}
String lockKey = key + ":lock";
boolean locked = lockService.tryLock(lockKey, 0, 10);
if (!locked) {
// 没抢到锁:说明别的请求在重建,短暂休眠后读缓存
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return redisBaseUtil.get(key, String.class);
}
try {
// 拿到锁后二次检查缓存------排队期间可能别人已经重建了
cache = redisBaseUtil.get(key, String.class);
if (cache != null && !cache.isEmpty()) {
return cache;
}
String schema = loadFromDb(toolId);
redisBaseUtil.setEx(key, schema, 1, TimeUnit.DAYS);
return schema;
} finally {
lockService.unlock(lockKey);
}
}
雪崩防护版------TTL 加随机抖动:
java
// 雪崩防护:同一批 key 的过期时间打散,避免同时过期
long baseTtl = 24 * 3600;
// 每个 key 在 1 天基础上再加 0~600 秒随机值
long ttl = baseTtl + ThreadLocalRandom.current().nextLong(0, 600);
redisBaseUtil.setEx(key, schema, ttl, TimeUnit.SECONDS);
6.5 这个场景的坑
- "先更 DB 再删缓存"删失败了。缓存没删掉,DB 已是新值,之后所有读都是旧的。兜底方案:删缓存失败就重试,或者订阅 DB 的 binlog(Canal),binlog 变更事件里去删缓存,删失败进 MQ 重试。进阶团队会走到这一步。
- 延迟双删(可选)。为了消掉"DB 更新和缓存删除之间"的竞态窗口,有人在删缓存后隔几百毫秒再删一次。但延迟时间很难定准,属于"看着对,实践里经常不灵"的方案,别当成银弹。
- 空值缓存要有 TTL。穿透兜底的空值缓存如果不设短 TTL,一旦 DB 里真的写入了这个数据,缓存里永远是个空值,读不到新数据------这是穿透兜底的经典副作用。
- 别把所有 key 都当热点去加锁。互斥锁只该给真正的热点 key,普通 key 重建本来就很便宜,加锁反而多了抢锁开销。
这一段的结论:缓存一致性没有银弹,只有取舍。 先更 DB 再删缓存,接受一个毫秒级窗口;要消除窗口,就上 binlog 监听 + 重试。穿透、击穿、雪崩三个词背下来没用,关键是出事时能一眼认出是哪一种------它们的手段完全不同。
七、场景 5:限流计数 ------ INCR 和那个秒级毛刺
7.1 业务问题:第三方 API 每秒最多 10 次
工作流里要调第三方 API(LLM、天气、地图......),对方合同约定每秒最多 N 次,超了要么限我们、要么罚我们、要么把我们拉黑。需要在自家服务里把调用频率卡住。
7.2 应用设计
- key 设计 :
rate:limit:{toolId}:{秒级时间戳}。按"工具 × 秒"一个计数 key,天然按秒分割。 - 固定窗口 :同一秒内的所有请求
INCR同一个 key,超过上限就拒绝。实现简单,代价是窗口边界有毛刺------第 59 秒的第 10 次和第 0 秒的第 10 次,相隔 1 秒却各自计数,实际在边界瞬间可能放过去 2 倍流量。 - 滑动窗口:用 ZSet 记录请求时间戳,统计"过去 N 毫秒"内的请求数。精确但占内存更多(每个请求一条记录)。
取舍:第三方 API 的"每秒 N 次"通常允许瞬时抖动,固定窗口够用且便宜;如果对边界毛刺敏感(比如计费),再上滑动窗口。
7.3 内核:INCR 为什么原子,以及为什么这里要 Lua
INCR 是 Redis 内置的原子自增,单线程执行,不需要加锁。但限流这里有另一个隐患:"自增"和"设过期"是两条命令,中间有竞态。
常见的错误写法是先 INCR 再 EXPIRE:
java
Long count = redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, 2, TimeUnit.SECONDS); // ← 这里有个窗口
如果 INCR 执行完、EXPIRE 还没执行,进程就崩了,这个计数 key 就永远没有过期时间 ------第一次请求后它就常驻内存,后续每个新的秒窗口都继续 INCR 同一个 key,限流直接失效,内存还在涨。
解法有两个:
- 只在第一次自增时设过期 (
count == 1才expire),但"判断 + 设过期"依然是两条命令,还有竞态; - 把整个逻辑塞进 Lua,让"自增 + 首启设过期"在一个原子块里完成。
正解是 2。同时顺手解决一个小问题:不用每次请求都多调一次 expire,省一次网络往返。
7.4 代码:固定窗口(Lua 原子版)
java
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Service
public class ApiRateLimitService {
// 每秒最大调用次数
private static final int MAX_QPS_PER_SECOND = 10;
@Resource
private RedisBaseUtil redisBaseUtil;
// 把"自增 + 首启设过期"放进 Lua,避免两条命令的竞态
private static final String FIXED_WINDOW_LUA =
"local c = redis.call('incr', KEYS[1]) " + // 原子自增
"if c == 1 then " + // 第一次请求才设置过期
" redis.call('expire', KEYS[1], ARGV[1]) " +
"end " +
"return c";
/**
* 对单个工具做秒级限流
* @return true=放行 false=限流拒绝
*/
public boolean checkRateLimit(String toolId) {
// 当前秒级时间戳,同一秒内的请求落在同一个 key 上
long secondTs = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
String key = String.format("rate:limit:%s:%d", toolId, secondTs);
// 计数器每 2 秒过期,比窗口多留 1 秒余量
Long count = redisBaseUtil.executeLua(FIXED_WINDOW_LUA, List.of(key), "2");
return count <= MAX_QPS_PER_SECOND;
}
}
调用侧:
java
// 调第三方 API 之前
if (!rateLimitService.checkRateLimit("tool_llm_chat")) {
throw new RateLimitException("调用太频繁,每秒最多 10 次");
}
callThirdApi();
7.5 进阶:滑动窗口(Lua + ZSet)
如果秒级边界不能忍,用滑动窗口。思路:把每次请求的"当前毫秒时间戳"作为分数塞进 ZSet,member 存请求的唯一标识;统计时先清掉窗口外的记录,再数窗口内的数量。
lua
-- 滑动窗口限流
-- KEYS[1] = 窗口 key
-- ARGV[1] = 当前毫秒时间戳
-- ARGV[2] = 窗口长度(毫秒)
-- ARGV[3] = 窗口内请求上限
-- ARGV[4] = 本次请求唯一 id(调用方传入)
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2]) -- 清掉窗口外的旧记录
local count = redis.call('ZCARD', KEYS[1]) -- 数窗口内请求数
if count < tonumber(ARGV[3]) then
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4]) -- 记下本次请求
redis.call('PEXPIRE', KEYS[1], ARGV[2]) -- 顺手设过期
return 1
end
return 0
Java 调用:
java
private static final String SLIDING_WINDOW_LUA = "...上面那一段...";
public boolean checkRateLimitSliding(String toolId) {
long nowMs = System.currentTimeMillis();
String key = "rate:sliding:" + toolId;
// member 必须唯一------同一毫秒的请求分数相同,member 不唯一会被 ZSet 去重
String uniqueId = toolId + ":" + nowMs + ":" + UUID.randomUUID();
Long allowed = redisBaseUtil.executeLua(
SLIDING_WINDOW_LUA,
List.of(key),
String.valueOf(nowMs), // ARGV[1] 当前时间
String.valueOf(1000), // ARGV[2] 窗口 1000ms
String.valueOf(MAX_QPS_PER_SECOND), // ARGV[3] 上限
uniqueId); // ARGV[4] 唯一 id
return allowed == 1L;
}
7.6 这个场景的坑
- 固定窗口的边界毛刺。0~1 秒边界可能放行接近 2 倍流量。对第三方"每秒 N 次"通常能忍,对计费/强约束别用。
- 时间戳用谁的 。上面代码用了
LocalDateTime.now()(本机时间)。如果多台机器时钟不同步(没配 NTP),各台机器的"秒"就不一致,限流失真。要么统一用 Redis 的时间(TIME命令),要么保证全集群 NTP 同步。 expire和incr分开写 = 定时炸弹。就是 7.3 讲的,这个坑太经典,值得单独列一条。- 滑动窗口的 ZSet 内存 。每次请求一条记录,窗口越大记录越多。
rate:sliding:{toolId}这种 key 如果长期不清理,会膨胀成大 key------好在脚本里PEXPIRE给了它生命周期。 - 为什么 member 必须是唯一 id :ZSet 按 member 去重,两个请求落在同一毫秒、member 又相同,第二个会被吞掉,计数偏小。注意不要在 Lua 里用
math.random生成唯一 id------Redis 的 Lua 环境为了保证脚本可复制,移除了math.random这类非确定性函数,要在客户端生成好传进来。
这一段的结论:限流的本质是把"并发"翻译成"计数",而计数的每个环节都要原子。 INCR 本身原子,但"自增 + 设过期"这种组合动作不原子,就得靠 Lua。凡是多个 Redis 命令要作为一个整体,都先想想能不能 Lua。
八、内核:Redis 到底凭什么做到这些
五个场景都过完了,退一步看,支撑它们的是同一套内核。集中讲一遍,你会发现之前每段"内核"其实是同一台机器上的不同齿轮。
8.1 单线程 + 事件循环:快和原子的根源
Redis 的核心是一个单线程事件循环:所有命令进来排队,一条执行完才执行下一条。
这带来两个结果:
- 快。没有线程切换、没有锁竞争,单线程把并发开销省到了极致,配合纯内存访问,单实例轻松扛十万级 QPS。
- 原子 。因为没有并发执行,单条命令(
INCR、SET NX)天然不会被其他命令打断。Lua 脚本同理------脚本执行期间其他命令都得等着。
代价是什么?任何一条慢命令都会卡住整个 Redis 。这就是为什么前面反复强调:别用 KEYS(全量扫描)、别存大 key、别对大 key 做 O(n) 操作。一个 KEYS * 或者一个几十 MB 的 value 被 GET 返回,Redis 就冻结几百毫秒,所有业务的请求一起排队,表现成"整个服务变慢"。
8.2 数据结构的底层:场景映射
Redis 之所以能同时当缓存、当锁、当计数器,靠的是五种基础结构。各自底层是什么,决定了它们的适用边界:
| 结构 | 底层实现 | 常见场景 | 上一篇五个场景里的位置 |
|---|---|---|---|
| String | SDS | 缓存、计数、锁、token | 全部四个场景 |
| Hash | dict(渐进式 rehash) | 对象按字段存、可部分更新 | --- |
| List | quicklist | 消息队列、最新列表 | --- |
| Set | intset / hashtable | 去重、标签、抽奖 | --- |
| ZSet | 跳表 + dict | 排行榜、滑动窗口 | 限流(滑动窗口版) |
讲一个就够了:为什么 ZSet 是"跳表 + dict"双结构 。跳表负责按分数排序(支撑 ZRANGEBYSCORE、ZREMRANGEBYSCORE),dict 负责按 member 定位(支撑 ZSCORE 查某个元素的分数)。两个结构共用一份数据,换来的是"排序查询"和"定点查询"都很快。滑动窗口限流就是靠这两者:ZREMRANGEBYSCORE 清旧记录(走跳表),ZCARD 计数(dict 直接给 size)。
8.3 持久化:锁场景为什么离不开 AOF
Redis 是内存数据库,数据默认在内存里,进程一重启就全没了。持久化两条路:
- RDB:定时把内存打一个全量快照。恢复快、文件小,但可能丢"最后一次快照之后"的所有写入。
- AOF :把每条写命令追加进日志。恢复时重放日志。
appendfsync策略控制刷盘时机,默认everysec,最多丢 1 秒数据。
生产环境的标配是 AOF (或混合持久化)。为什么锁场景必须在意持久化?因为锁本质是"写进 Redis 的一条记录"。如果只开 RDB 且保存策略很保守,主节点进程一挂,最近几秒的写入全丢------包括那把锁。重启后锁没了,两个客户端都能加锁成功。用 Redis 当锁,就别关持久化。
8.4 高可用:哨兵和集群,以及锁的那道边界
单机 Redis 挂了,整个系统的缓存、锁、登录态全部失效。生产环境至少两级保护:
- 哨兵(Sentinel):监控主节点,主节点挂了自动把从节点提升为主,实现故障转移。解决的是"单点"。
- Cluster:数据分片到多个主节点,每个主节点配从节点。解决的是"内存不够、单实例吞吐不够"。
但主从是异步复制------主节点写入成功,不代表从节点已经同步到。这就回到场景 2 讲的锁丢失问题:主节点写锁成功、还没同步给从节点、主节点挂了、哨兵把从提升为主、新主没有这把锁 → 两个客户端同时拿到锁。
这是 Redis 分布式锁的理论边界 。业界回应有两派:antirez 提出 RedLock(向多个独立 Redis 实例依次加锁,过半成功才算拿到),Kleppmann 写长文论证它仍不严谨(时钟、阻塞、GC 停顿都会破坏论证前提)。结论很实在:绝大多数业务场景,这个故障转移窗口的概率低到可以接受,直接用 Redisson 就好;少数真正不能容忍任何并发的场景,用 etcd / ZooKeeper 这类强一致组件。 选型前想清楚自己在哪一边。
九、把一条请求完整串起来
代码都拆开讲完了,最后用一段代码把场景 3、4、5、1 串成一条真实的请求链路------一个用户调用工具接口,Redis 被连续摸了四次:
java
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
@RestController
@RequestMapping("/api/tools")
public class ToolCallController {
@Resource private UserTokenSessionService tokenService;
@Resource private ToolSchemaCacheService schemaCache;
@Resource private ApiRateLimitService rateLimit;
@Resource private WorkflowNodeContextService workflowContext;
@PostMapping("/{toolId}/call")
public Result callTool(@RequestHeader("token") String token,
@PathVariable Long toolId,
@RequestBody ToolCallReq req) {
// ① 登录态校验 ------ 场景3:token 查 Redis,查不到直接拒绝
UserLoginDTO user = tokenService.getUserByToken(token);
if (user == null) {
return Result.unauthorized("登录已过期");
}
// ② 工具 schema 走缓存 ------ 场景4:命中直接返回,未命中回填
String schema = schemaCache.getToolSchema(toolId);
if (schema == null) {
return Result.error("工具不存在或已下线");
}
// ③ 调用前限流 ------ 场景5:超了直接拒绝,别浪费第三方配额
if (!rateLimit.checkRateLimit(toolId)) {
return Result.error("调用太频繁,请稍后再试");
}
// ④ 调第三方 API,结果写回工作流上下文 ------ 场景1
ToolResult result = thirdPartyClient.call(toolId, schema, req);
workflowContext.saveNodeData(req.getWorkflowId(), "node_tool_" + toolId, result);
// ⑤ 下游汇总节点由编排器通过分布式锁保证只执行一次 ------ 场景2
// (这一步在异步编排器里,这里注释标明位置)
return Result.ok(result);
}
}
一条请求,四次 Redis 访问,四种不同的 TTL,四种不同的坑。它们不共享任何代码,却共享同一套内核------这就是前面说的"一张网"。
十、生产注意清单(踩过之后的汇总)
把分散在各场景的坑收敛成一张清单,上线前逐条核对:
设计层
| 检查项 | 为什么 | 对策 |
|---|---|---|
| 每个 key 都设了 TTL | 僵尸 key 占内存,volatile-lru 下还没法淘汰 |
写代码时强制 TTL,review 时逐条看 |
| key 命名带业务前缀 | 可读、可定位、可批量管理 | 业务:模块:实体:字段 冒号分层 |
| 序列化方案统一 | 默认 JDK 序列化是乱码 + 跨语言不通 | key 用 String,value 用 JSON |
明确 maxmemory-policy |
默认 noeviction,满了写入直接报错 |
缓存类数据用 allkeys-lru,别用默认 |
| 缓存一致性策略统一 | 更新 DB 后删缓存,删失败要有重试 | Cache Aside + 删缓存失败重试 / binlog 监听 |
故障模式对照
| 故障 | 现象 | 定位思路 | 解法 |
|---|---|---|---|
| 缓存穿透 | DB 慢查询飙升,缓存命中率低 | 看是不是大量不存在的 key | 空值缓存 / 布隆过滤器 |
| 缓存击穿 | 单个热点 key 过期瞬间 DB 打爆 | 日志里同一 key 大量 miss | 互斥锁重建 / 逻辑过期 |
| 缓存雪崩 | 大批 key 同时过期,DB 洪峰 | 内存里大量 key 时间戳相同 | 过期时间加随机值 |
| 固定窗口毛刺 | 秒边界流量超预期 | 秒级时间戳相邻两次都放行 | 换滑动窗口 |
| 锁提前释放 | 两个请求同时执行了业务 | 检查锁持有时间 vs 业务耗时 | 看门狗续期 / 延长持有时间 |
| 锁没释放 | 业务卡死、后续全部拿不到锁 | 看是否忘记 finally unlock | finally 释放 + 过期兜底 |
| Redis 变慢 | 所有请求延迟普遍升高 | 看是否有 KEYS、大 key 操作 |
换 SCAN、拆分大 key |
命令避坑
- 别用
KEYS *,用SCAN。 - 别对大 key(value 几 MB、hash 几万字段)做整体读写和删除。大 key 删除用
UNLINK(异步释放内存),别用DEL阻塞主线程。 - 多个命令组合成原子操作时,优先 Lua,而不是在客户端分两条命令发。
- 批量操作多用 pipeline / mget 减少网络往返;但 pipeline 里别混有依赖上一步结果的命令。
什么时候 Redis 救不了你
- 一致性要求极高(钱、库存的强一致):Redis 分布式锁不是强一致锁,上 etcd / ZooKeeper。
- 数据量远超内存:Redis 当主存储会贵到失去意义,该上真正的数据库 + Redis 只做缓存。
- 需要复杂查询 / 关系 / 事务:Redis 的结构是扁平的,关系型查询不是它的主场。
结尾:五张牌其实是同一副牌
回头看这条链路,五个场景看似独立,其实每一处都在用同一套机制回答同一个问题------"多个进程、多台机器之间,怎么安全地共享一份数据":
- 传参,靠的是 String + TTL + 序列化;
- 只执行一次,靠的是原子命令 + Lua + 看门狗;
- 登录态,靠的是过期机制 + 淘汰策略;
- 热点数据,靠的是缓存一致性那一套取舍;
- 限流,靠的是原子计数 + 单线程执行模型。
内核里单线程的原子性、SDS 的底层、过期与淘汰的配合、持久化的边界------它们不是某个场景的专属知识,而是所有场景共同的地基。把地基看清了,新场景来了不用背,直接设计就行。
最后留一个回顾问题:下一次新工作流要加一个"分布式计数器",你还会不会手忙脚乱地翻文档?如果你已经能脱口说出------用什么结构、设不设 TTL、要不要 Lua、内存满了怎么办------那这篇就写值了。