上周和同事讨论 Redis 使用,起因是新服务拆成多进程之后,session、配置、任务状态到底存哪里,大家在评审会上各执一词。吵到后半场,话题自然收拢到三个关键词:进程间共享数据、数据快照、RPC。实际上这也是很多后端团队在微服务化过程中都会撞上的典型问题——Redis 看起来什么都能干,可真要把它放到架构决策里,每个点都值得掰开揉碎了聊。这篇文章就把当时的讨论结论和我事后做的补测记录整理出来,适合后端开发、准备拿 Redis 做中间件的团队,以及被 RPC 超时和慢查询坑过的人参考。
我尽量不写教科书式的原理,重点放在"我们当时为什么这么选"和"实操中到底怎么配才不翻车"。
1. 进程间共享数据:为什么 Redis 成了最优解
1.1 多进程部署下的共享矛盾
先说背景。我们的服务从单进程单体开始演进,拆成多个 worker 之后,第一个撞墙的问题就是共享数据没地方放了。A 进程写入的配置,B 进程必须立即可见;C 进程处理任务的进度,D 进程要能实时查询。这是进程间共享数据的典型场景——不是两个进程在同一台机器上通过管道传消息那种 IPC,而是跨机器、跨网络、语言可能还不一致的共享。
聊方案的时候,大家列了几个候选:
- 文件共享:NFS 挂载,配置简单,但性能差、并发锁难控制,写坏了影响所有进程;
- 共享内存:快是真快,可只能单机用,进程一多、机器一多就废了;
- 消息队列:适合异步流转,不适合"我要马上读到这个值"的场景;
- 关系数据库:能持久化、能事务,但连接数、磁盘 IO 很快会成为瓶颈,特别是高并发读多写少的场景;
- Redis:内存级读写,天然支持网络访问,数据类型丰富,很多进程间共享需求都不需要自己造协议。
我们最终选了 Redis,不是因为它最完美,而是因为它在"共享读多、写频率高、要求低延迟"这个组合上,成本最低。一个 Redis 实例,不同的业务用不同的 key 前缀隔离,数据自带过期时间,还天然支持多种数据结构,省去了自己设计 IPC 协议的麻烦。这里要强调一点:Redis 做共享数据时不建议当唯一数据源,它更多是作为"中间层"或者"加速层"存在,真正要强一致的数据,还是需要有数据库兜底。
1.2 用哪种数据类型,取决于你要共享的语义
和同事讨论时最容易吵起来的,其实是"同一个 key,我到底该用 String 还是 Hash"这类问题。我分享一个我的判断标准:
- 配置型数据(比如开关、阈值、版本号):优先用 String,
SET和GET最简单,配 TTL 即可; - 对象型数据(比如用户信息、订单摘要、RPC 返回的结果体):优先用 Hash 或者 JSON 序列化后的 String。如果只要求整体读写,String + JSON 最省事;如果多个字段需要独立更新,Hash 更合适,避免每次改一个字段都要序列化整个对象;
- 队列型共享(比如待处理任务、日志采集列表):用 List,
LPUSH+BRPOP可以做成一个清单一消费的简单队列; - 有评分/需排序的共享(比如排行榜、优先级任务):用 ZSet,按 score 排序,取 Top N 非常方便;
- 需要发布订阅的共享通知:用 Pub/Sub 或 Stream。
一个很容易踩的坑是:把 Redis 当成万能数据库,什么数据都往里塞,而且不设过期时间。我们在讨论中定了一条红线——进程间共享的数据,必须明确谁是"生产者"、谁是"消费者"、数据生命周期多长,必须显式设置 TTL。没有 TTL 的共享数据,磁盘和内存迟早会被拖垮。
2. 数据快照:Redis 持久化不只是"存个盘"
2.1 RDB:快照就是"某一刻的样子"
聊到共享数据,同事问了一句:"那 Redis 挂了,我们这些共享 key 不都没了吗?"这就引出了数据快照。
Redis 的 RDB(Redis Database Backup)持久化,本质上是把内存中的全量数据,在一个时间点打成二进制快照写入磁盘。触发方式有三种:手动执行SAVE(阻塞主进程)或BGSAVE(后台异步执行),或者按配置条件自动触发,比如默认配置里的save 900 1(900 秒内有一个 key 变化就触发)、save 300 10(300 秒内 10 个 key 变化触发)、save 60 10000(60 秒内 1 万个 key 变化触发)。
RDB 最核心的是**写时复制(Copy On Write)**机制。BGSAVE触发时,Redis 主进程会fork()一个子进程,子进程负责将内存数据写入临时文件。在 fork 出来的那一瞬间,父子进程共享同一份内存页;之后主进程继续接收写请求,如果修改了某个内存页,操作系统会把被修改的页复制一份给主进程使用,而子进程看到的一直是 fork 瞬间的旧数据。所以 RDB 是"某一刻的快照"——它不保证 fork 之后的新数据,但保证了 fork 瞬间数据的完整性。
RDB 的最大优点是恢复快、文件紧凑,适合做备份和灾难恢复。缺点是数据丢失窗口不可控:假设你触发了save 900 1,在 900 秒内 Redis 宕机,这 900 秒内写入的数据全部丢失。如果你把共享数据只放在 RDB 里,那就必须考虑业务是否能承受这个丢失窗口。
2.2 AOF:追加日志比快照更抗丢
AOF(Append Only File)解决的就是 RDB 的丢失窗口问题。它的原理很好理解:每来一条写命令,就追加到日志文件末尾。恢复时从头到尾重放一遍日志,内存数据就回来了。
AOF 有三种刷盘策略,由appendfsync控制:
| appendfsync配置 | 行为 | 性能 | 最多可能丢数据 |
|---|---|---|---|
| always | 每次写命令都同步刷盘 | 最慢 | 0 |
| everysec | 每秒刷一次盘 | 折中 | 1秒数据 |
| no | 交给操作系统决定刷盘时机 | 最快 | 可能丢最后几秒到几十秒数据 |
我们当时在生产环境选的是everysec,理由是:单条写命令强制fsync的开销太大,高并发下吞吐会明显下降;而 one秒的数据丢失对绝大多数共享数据场景是可以接受的。如果你的业务对数据安全要求极其苛刻(比如财务流水),才需要上always,但代价是写入吞吐大幅降低。
AOF 还有一个隐藏问题——日志会无限增长。所以 Redis 提供了重写机制(BGREWRITEAOF),把当前内存数据压缩重写成一个最小的命令序列。Redis 4.0 之后还支持混合持久化,重写时把 RDB 格式的二进制快照写到 AOF 文件头部,之后再用增量命令追加,这样同时兼顾了重写时的紧凑和恢复时的速度。这里要吐槽一个热词里的常见笔误:网上很多人把 AOF 打成 "AOP",第一次看到还以为要引入面向切面编程,其实根本不是一回事。
2.3 快照与共享数据的配合建议
回到"和同事讨论"的场景,我们最后形成了一套简单可行的配合规则:
- 如果 Redis 只做进程间共享的临时缓存,比如保存验证码、临时 token、任务短时状态,那其实可以关掉任何持久化,
appendonly no,反正丢了再重建即可。这样做能省掉大量不必要的磁盘 IO; - 如果 Redis 里保存了不能轻易重建的共享数据(比如跨服务的分布式任务进度、规则引擎的编译结果缓存),建议至少开启 AOF +
everysec,避免宕机丢失窗口过大; - 如果 Redis 还要承担类似消息队列的职责(用 Stream 或 List 做任务流转),那持久化一定要开,因为队列里堆积的任务丢了就不是"重新请求一下"能补回来的。
很多人问:RDB 和 AOF 能不能同时开?能,而且默认就是。Redis 重启时会优先使用 AOF 恢复数据,因为 AOF 的数据更全。如果对数据敏感度不高,你甚至可以只开 RDB 做日常备份,AOF 作为最后一道保险。但要注意:AOF 文件损坏或过大时,重放速度可能非常慢,建议配合auto-aof-rewrite-percentage 100和auto-aof-rewrite-min-size 64mb做自动重写,避免日志文件膨胀到可怕的程度。
3. RPC 场景里 Redis 到底扮演什么角色
3.1 第一个角色:RPC 结果缓存
讨论到 RPC 时,同事问了一个很实际的问题:"我们内部服务之间互相调用,Redis 到底能帮上什么忙?" 这个问题的标准答案,第一个是结果缓存。
我们的业务里,A 服务要通过 RPC 调 B 服务拿用户标签,而 B 服务的计算逻辑比较重,数据库查询加规则匹配,平均耗时 80ms。在高并发下,同样的参数被反复调用,浪费了大量 RPC 连接和计算资源。这时候把结果按入参 hash 成 key,在 Redis 里缓存最近一段时间的结果,A 服务先查缓存,命中就直接返回,不再发 RPC 请求。这个方案最核心的两个参数是过期时间和缓存 key 的粒度。过期时间太短缓解不了压力,太长又可能拿到陈旧结果。我们当时的经验是:结果允许 5 分钟陈旧,就把 TTL 设为 300 秒;如果业务要求强一致,那这个思路就不适用,你应该直接走 RPC 而不是碰缓存。
还要小心 RPC 结果缓存引发的穿透问题。如果某个入参对应的结果在 B 服务里就是不存在,Redis 里没缓存,所有请求都会穿透到 B 服务。解决办法有两种:一是对空结果也做短 TTL 缓存(比如 60 秒);二是在 Redis 前加一层布隆过滤器,把"一定不存在"的参数挡在大部分请求之外。这个我们在第三节还会细说。
3.2 第二个角色:分布式锁与并发控制
除了结果缓存,Redis 在 RPC 场景里另一个高频用途是分布式锁。一个典型的场景:多个服务实例同时监听同一个定时任务,RPC 触发某个下游接口,但该接口要求同一时间只能有一个实例执行,否则会造成重复扣款、重复发券。这时候就需要在 Redis 里抢一把锁。
正确的分布式锁写法不是SETNX然后EXPIRE两条命令,而是用一条原子命令:
SET lock_key unique_identifier NX PX 30000NX保证只有 key 不存在时才能设置成功,PX 30000让锁自动过期,防止持有锁的进程崩溃后死锁。而unique_identifier这个随机值很重要——释放锁时必须先校验持有者是自己,再DEL。如果用 Lua 脚本:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end这个写法能避免"自己加的锁被别人释放掉"的问题。热词里提到agent rpc error (-1): empty sid and service name,这类错误大概率不是 Redis 锁的问题,而是 RPC 元信息缺失,别把排查方向带偏。
如果你用 Redisson 客户端,它的看门狗机制会自动续期锁的过期时间,默认每 10 秒续到 30 秒,省去了自己设计续期逻辑的麻烦。但 Redisson 也是基于 Redis 主从复制做的,极端情况下主节点宕机而锁还没来得及同步到从节点,另一个实例可能拿同一把锁。这在绝大多数内部系统里可以接受,但如果你做的是扣款、下单这类强资金一致性场景,建议谨慎评估,甚至可以明确禁止用 Redis 锁,改用 Zookeeper 或数据库乐观锁。
3.3 第三个角色:注册中心与状态上报
第三个应用场景也是 RPC 经常遇到的——把 Redis 当注册中心用。很多轻量级 RPC 框架(包括老的 Dubbo 实现)支持用 Redis 做服务的注册与发现:服务提供者启动时把自己的地址写入 Redis 的 Set 或 Hash 中,服务消费者启动时从 Redis 读取可用地址列表,并订阅变更。
这种做法的优点是轻量,不用额外引入 Zookeeper/Consul/Nacos 等注册中心组件,一套 Redis 就能跑通;缺点也很明确:缺少健康检查、临时节点失效自动剔除等机制。我们当时讨论的结论是:内部小规模服务,用 Redis 做注册中心是可以接受的,但一定要加 TTL 和心跳续约,不然服务实例下线后,地址还残留在 Redis 里,RPC 调用就会打到黑洞里,出现热词里那种cannot finish rpc call in 30 seconds的超时错误。
4. 实操配置与常见参数选型
4.1 序列化方案:这一个环节最容易翻车
进程间共享数据看起来只是SET/GET的事,真正让团队踩坑的是序列化。热词里高频出现"redis序列化",说明大家在这上面栽过跟头。
我见过最典型的问题:用 Spring RedisTemplate 默认的JdkSerializationRedisSerializer存对象,key 形如\xac\xed\x00\x05t\x00\x0euser:1001,肉眼根本看不出是哪个业务。更麻烦的是,Java 序列化的对象如果类结构升级(字段名变更、删除字段),反序列化直接抛异常,老缓存全部失效,RPC 调用瞬间打到下游。
我们的建议很明确:
- 共享数据的 value 统一走 JSON,用 Fastjson2/Jackson 或 Gson 序列化,跨语言友好,排障时也能直接读懂;
- key 用 String 序列化器,肉眼可读,按业务前缀组织,例如
rpc:result:{service}:{method}:{hash}; - 如果需要极致性能且数据量大,再考虑 Kryo、Protobuf、Hessian 这类二进制序列化,但要配套类的 schema 管理,复杂度更高。
记住一个原则:序列化方案是共享协议的一部分。换个客户端、换个语言就要能解出来,不然"共享"无从谈起。
4.2 超时、连接池与慢查询排查
热词里有一条特别典型的报错:command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException,这个我们遇到过太多次了。出现这个异常的第一反应很多人会去看网络,但实际排查步骤应该按下面这个顺序来:
- 先看慢查询:
SLOWLOG GET 20,如果 Redis 里出现了大量耗时超过 100ms 的命令(比如大 key 的HGETALL、KEYS *、SMEMBERS全量返回),那问题在 Redis 本身; - 再看网络延迟:用
redis-cli --latency -h <host> -p 6379连续测几十秒,如果平均延迟超过 5ms,先排查网络和网卡; - 再看客户端连接池:Spring Boot 2.x 默认用 Lettuce,它默认是不带连接池的,高并发下等待连接就会超时。你可以引入
commons-pool2并开启连接池:
spring: data: redis: lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 200ms如果用的是 Jedis,连接池配置逻辑类似。这里补一个实操经验:max-active设 16 通常够用,但如果你在 RPC 调用里同步嵌套了多次 Redis 操作(比如先查缓存再查分布式锁再写日志),并发请求峰值时连接很容易被占满,建议把连接池max-active放大到 32,同时配合max-wait设置一个比较短的等待时间,让调用快速失败而不是无限阻塞。
RPC 超时和 Redis 超时经常被混在一起排查。比如热词里的curl 56 recv failure: 连接超时,这是 HTTP/网络传输层的错误,和 Redis 本身没关系,但表现都是"调用等不到结果"。我们当时的排查经验是:先在调用链上逐个环节打点(RPC 发出前、Redis 访问前、Redis 访问后、RPC 返回后),看时间到底耗在哪个环节,不要一上来就调某个组件的超时参数——把超时参数调大只是掩盖问题。
4.3 缓存治理:命中率、穿透、击穿、雪崩
RPC 结果缓存上线后,第二个星期监控就发现 Redis 的 CPU 和内存暴涨,这就是热词里提到的"redis缓存治理"。我们最常用的几个治理手段:
- 布隆过滤器挡穿透:把系统里存在的 key 集合提前加载到一个布隆过滤器里,查询前先判断 key 是否可能存在,如果 Bloon 判定不存在,直接返回空,不查 Redis,更不发起 RPC;
- 缓存击穿用互斥锁:针对某个热点 key 过期瞬间的大量请求,用一个分布式锁(就是前面提到的
SET NX)保证只有一个请求真正去重建缓存,其他请求短暂等待或直接返回旧值; - 缓存雪崩用随机过期:不要把所有 key 的 TTL 设成同样的 300 秒,而是在 TTL 基础上加 30~60 秒随机偏移,避免同一时刻大量 key 同时过期,让下游 RPC 被打爆。
还有一点容易忽略:缓存命中率监控。我们在 Redis 里定期执行INFO stats,看keyspace_hits和keyspace_misses的比例。如果命中率长期低于 80%,说明缓存的 key 设计有问题(要么过期太短,要么 key 的维度不对),需要回头调设计,而不是盲目扩容。
5. 常见问题排查与避坑实录
5.1 RPC 调 30 秒超时:从哪查起
热词里那个cannot finish rpc call in 30 seconds: nul的错误,是 RPC 框架抛出的典型客户端超时。它反映的不是某一项服务的故障,而是整个调用链路变慢,其中 Redis 常常是"背锅侠"。我们的排查思路整理成一个速查表:
| 现象 | 优先排查项 | 常用命令/工具 |
|---|---|---|
| RPC 整体超时 | 先看 Redis 慢查询、连接池是否耗尽 | SLOWLOG GET、INFO clients |
| 单个 redis 命令偶发超时 | 大 key、网络抖动、AOF 刷盘阻塞 | redis-cli --bigkeys、redis-cli --latency |
| 客户端连接被拒绝 | 连接数超上限、防火墙、Redis 配置 maxclients | INFO stats的total_net_output_bytes |
| 锁竞争等待超时 | 锁的 TTL 设计不合理、业务执行时间过长 | 统计锁等待时间、看门狗日志 |
| 反序列化异常导致重试放大 | 序列化器配置、类 schema 变更 | 查看异常栈中的 ClassCastException |
特别提醒一个细节:AOF 刷盘本身也可能引起 Redis 阻塞。当appendfsync为everysec时,如果磁盘 IO 变慢,每秒刷盘可能阻塞主进程几十毫秒甚至更久,表现为所有命令都变慢,RPC 请求纷纷超时。所以排查慢查询时,除了看SLOWLOG,还要看INFO persistence里的aof_pending_bio_fsync和磁盘 IO 指标。
5.2 分布式锁的几个经典翻车场景
聊到 Redis 分布式锁,同事总结了他们踩过的坑,我觉得很有价值,放在这里:
- 锁误删:A 进程获取锁后执行时间超过 TTL,锁自动过期,B 进程拿到锁开始执行。A 执行完直接
DEL,把 B 的锁删了。解决办法就是前面说的:DEL 前必须校验 value 是"自己的随机数",用 Lua 脚本保证原子性; - 时间跳跃:如果 Redis 服务器时间被人为调整,
PX过期时间的计算可能出问题。生产环境建议启用 NTP 校时,避免手动跳时间; - 主从切换丢锁:主节点加锁后还没同步到从节点,主节点宕机,从节点升级为主,新主节点上不存在这把锁,另一个进程就拿到锁了;
- 单点问题:一个 Redis 实例挂了,锁就不可用。要么接受这个事实(小团队常见做法),要么引入多主节点的 Redlock(但也有争议,实现复杂度高)。
我们最终建议是:分布式锁只用于对"偶尔并发重复执行"不致命的场景。如果是扣款、转账这类资金操作,宁可性能慢一点,也一定要用数据库唯一约束兜底。
5.3 快照恢复和数据完整性:压箱底的经验
最后分享两个和数据快照恢复直接相关的经验,也是这次讨论之后我自己补测验证过的:
第一,RDB 文件也可能损坏。虽然不是高频事件,但磁盘坏道、强制断电都可能让 dump.rdb 文件写坏。Redis 官方自带一个修复工具redis-check-rdb,但它只能帮你把损坏文件里还能解析的数据捞出来,无法做到完整恢复。所以我的建议是:RDB 备份文件一定要按天轮转,保留最近 3~7 天的备份,最好再异地备份一份。恢复时优先用最近的 AOF,如果 AOF 也坏了,再退而求其次用 RDB。
第二,AOF 重放慢会影响服务可用性。有次我们一个节点重启,AOF 文件已经到 8GB,重启后重放日志花了 12 分钟,这期间 Redis 是不可用的,所有 RPC 全超时。后来我们启用了混合持久化(aof-use-rdb-preamble yes),AOF 文件重写后头部是一段 RDB 快照,恢复时先加载快照,再增量应用日志,8GB 的文件恢复时间缩短到 3 分钟内。这就是为什么要重视 AOF 重写配置的原因。
聊到最后,我们达成的共识其实挺朴素的:Redis 是工具,它解决的是"共享""快照""加速"三类问题,但每类问题都有边界。进程间共享数据要设 TTL、明确生产者和消费者;数据快照要根据业务能接受的丢失窗口选择 RDB 还是 AOF;RPC 场景里 Redis 能做缓存、锁、注册中心,但绝不能替代强一致的存储。我自己在做后续落地时,还加了一条运维侧的建议:给 Redis 单独配一套监控大盘,重点看内存碎片率、慢查询数、连接数变化和持久化阻塞时间,这几个指标能提前暴露大多数问题。
如果你也在和同事讨论 Redis 该怎么用,希望这篇能把你们的讨论拉到一个更深的层面——先别急着讨论哪条命令快,先把"这份数据能不能丢、能忍多久丢、谁写谁读"这几个问题定下来。这个思路定住了,Redis 用起来会顺手很多。