Redis实战:一个AI工作流系统里的五个应用场景,从传参到限流的完整链路

你给 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 开头的乱码。后果有三层:

  1. redis-cli keys * 根本看不出是什么业务数据,排查全靠猜;
  2. Java 存的数据其他语言读不了,跨语言协作直接断;
  3. 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 这个场景的坑

  1. 反序列化时的类型丢失get(key, Map.class) 反序列化出来的是 LinkedHashMap,嵌套的复杂对象(比如 Map 里套 List 套对象)如果不带类型信息,读出来全是 Map/String,取字段时报 ClassCastException。跨语言传输的上下文,建议约定成"纯 JSON 数据",别指望自动还原成强类型对象。
  2. value 别塞大对象。一个节点结果几 MB,五个节点就是几十 MB,还都是同一个工作流的。后面「大 key 的坑」一节会讲它为什么危险。
  3. 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 必须放在同一条命令里 。拆成两条------先 SETNXEXPIRE------中间进程一挂,锁就没有过期时间,直接死锁。SET key value NX EX 是一条命令,Redis 单线程执行命令,不存在被打断的窗口。

为什么 Redis 的命令天然原子? 因为 Redis 是单线程事件循环:所有命令在一个线程里串行执行,一条命令从头到尾跑完,才轮到下一条。没有并发,就没有竞态,所以 INCRSETNX 这些操作天然线程安全。这也意味着------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);
}

手写版能工作,但有三个"续命"问题没人管:

  1. 锁过期了业务还没跑完------需要看门狗自动续期;
  2. 可重入------要记录同一线程的重入次数;
  3. 释放时机------什么时候该停掉续期。

这三个正是 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 这个场景的坑

  1. 忘记释放锁 = 死锁unlock 必须放在 finally 里。配合锁自带的过期时间,即使代码异常,锁最坏也会在过期后自动释放------过期时间是"进程崩溃"的最后一道防线,不是让你放心忘记释放的。
  2. 误删别人的锁。必须比对 value,必须用 Lua。Redisson 已经处理好了,手写版别漏。
  3. 业务执行时间超过锁过期时间。锁过期后另一个请求拿到锁,两个请求同时执行。解法是看门狗续期,或者业务上保证单次执行时间远小于锁持有时间。
  4. 主从切换丢锁 。这是 Redis 分布式锁最深的坑:主节点写入锁成功,但还没同步到从节点,主节点挂了,哨兵把从节点提升为主------新主上没有这把锁,另一个客户端就能拿到同一把锁。RedLock 算法试图解决这个问题,但业界对它一直有争议(Kleppmann 和 antirez 为此吵过一轮)。现实里的取舍是:绝大多数业务接受这个极小概率窗口(故障转移窗口内才可能发生),真正强一致、不能容忍任何并发的场景,会改用 etcd / ZooKeeper 这种基于 Raft / Paxos 的组件。用 Redis 锁之前,先想清楚你的业务在不在"绝大多数"里。
  5. 锁场景必须开持久化。如果 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 这个场景的坑

  1. 滑动续期做过头 :每次请求都 expire,活跃用户永不过期,等于没有过期。如果产品上需要"强制 2 小时重新登录",就不要滑动续期,或改成固定到期时间。
  2. key 前缀泄露到日志 :token 是敏感信息,打日志时别把整个 token:{token} 打出来,脱敏成 token:{token前8位}****
  3. 查 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,再删缓存。注意是"删缓存",不是"更缓存"。

为什么写的时候删而不是更新?两个原因:

  1. 更新缓存有竞态:两个线程同时更新同一个 key,后写的覆盖先写的,最终可能和 DB 不一致。删掉让下次读重建,天然避开了"写写竞态"。
  2. 省事:删一个 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 这个场景的坑

  1. "先更 DB 再删缓存"删失败了。缓存没删掉,DB 已是新值,之后所有读都是旧的。兜底方案:删缓存失败就重试,或者订阅 DB 的 binlog(Canal),binlog 变更事件里去删缓存,删失败进 MQ 重试。进阶团队会走到这一步。
  2. 延迟双删(可选)。为了消掉"DB 更新和缓存删除之间"的竞态窗口,有人在删缓存后隔几百毫秒再删一次。但延迟时间很难定准,属于"看着对,实践里经常不灵"的方案,别当成银弹。
  3. 空值缓存要有 TTL。穿透兜底的空值缓存如果不设短 TTL,一旦 DB 里真的写入了这个数据,缓存里永远是个空值,读不到新数据------这是穿透兜底的经典副作用。
  4. 别把所有 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 内置的原子自增,单线程执行,不需要加锁。但限流这里有另一个隐患:"自增"和"设过期"是两条命令,中间有竞态。

常见的错误写法是先 INCREXPIRE

java 复制代码
Long count = redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, 2, TimeUnit.SECONDS);  // ← 这里有个窗口

如果 INCR 执行完、EXPIRE 还没执行,进程就崩了,这个计数 key 就永远没有过期时间 ------第一次请求后它就常驻内存,后续每个新的秒窗口都继续 INCR 同一个 key,限流直接失效,内存还在涨。

解法有两个:

  1. 只在第一次自增时设过期count == 1expire),但"判断 + 设过期"依然是两条命令,还有竞态;
  2. 把整个逻辑塞进 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 这个场景的坑

  1. 固定窗口的边界毛刺。0~1 秒边界可能放行接近 2 倍流量。对第三方"每秒 N 次"通常能忍,对计费/强约束别用。
  2. 时间戳用谁的 。上面代码用了 LocalDateTime.now()(本机时间)。如果多台机器时钟不同步(没配 NTP),各台机器的"秒"就不一致,限流失真。要么统一用 Redis 的时间(TIME 命令),要么保证全集群 NTP 同步。
  3. expireincr 分开写 = 定时炸弹。就是 7.3 讲的,这个坑太经典,值得单独列一条。
  4. 滑动窗口的 ZSet 内存 。每次请求一条记录,窗口越大记录越多。rate:sliding:{toolId} 这种 key 如果长期不清理,会膨胀成大 key------好在脚本里 PEXPIRE 给了它生命周期。
  5. 为什么 member 必须是唯一 id :ZSet 按 member 去重,两个请求落在同一毫秒、member 又相同,第二个会被吞掉,计数偏小。注意不要在 Lua 里用 math.random 生成唯一 id------Redis 的 Lua 环境为了保证脚本可复制,移除了 math.random 这类非确定性函数,要在客户端生成好传进来。

这一段的结论:限流的本质是把"并发"翻译成"计数",而计数的每个环节都要原子。 INCR 本身原子,但"自增 + 设过期"这种组合动作不原子,就得靠 Lua。凡是多个 Redis 命令要作为一个整体,都先想想能不能 Lua。


八、内核:Redis 到底凭什么做到这些

五个场景都过完了,退一步看,支撑它们的是同一套内核。集中讲一遍,你会发现之前每段"内核"其实是同一台机器上的不同齿轮。

8.1 单线程 + 事件循环:快和原子的根源

Redis 的核心是一个单线程事件循环:所有命令进来排队,一条执行完才执行下一条。

这带来两个结果:

  • 。没有线程切换、没有锁竞争,单线程把并发开销省到了极致,配合纯内存访问,单实例轻松扛十万级 QPS。
  • 原子 。因为没有并发执行,单条命令(INCRSET 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"双结构 。跳表负责按分数排序(支撑 ZRANGEBYSCOREZREMRANGEBYSCORE),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、内存满了怎么办------那这篇就写值了。

相关推荐
Lethehong2 小时前
双擎并驱·全链路并行:KFS让TB级异构增量同步秒级到达
数据库
MC皮蛋侠客2 小时前
SQLAlchemy 系列(八):AsyncIO、并发与 Web 生命周期——让每个并发任务持有自己的 Session
数据库·python
枫叶v.4 小时前
Prompt Injection 防不住怎么办?从 Source-Sink 模型设计 Agent 安全边界
数据库·安全·prompt
Lucky_Turtle4 小时前
【Milvus】向量数据库
数据库
吴声子夜歌4 小时前
MongoDB 8.0——安全性
数据库·mongodb
xcLeigh4 小时前
KingbaseES 的卢智能运维体架构深度拆解
运维·数据库·人工智能·ai·架构·ffmpeg·智能体
weixin_397574095 小时前
按行业定制分析视角
大数据·数据库·人工智能·企业数据治理·数据驱动决策·本体语义·企业经营分析
鹿角片ljp5 小时前
Redis 深度复习:从入门到面试通关
数据库·redis·面试
Ming_studying6 小时前
Python + SQLite FTS5 构建本地文档全文搜索器:增量索引、中文检索与高亮
jvm·数据库·python·sqlite