多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Redis用于进程间共享数据:数据快照、RPC与分布式锁实战解析

Redis用于进程间共享数据:数据快照、RPC与分布式锁实战解析 上周五临下班我们组几个人围着白板争了半小时。起因是同事在服务里用Redis做了一个“全局配置中心”让A进程写入的配置能同步给B进程另一个同事直接反问同一台机器上搞个共享内存不就行了吗跨机器调个RPC拿数据不也行吗为什么非要绕一圈用Redis这一下子把“进程间共享数据”“数据快照”“RPC”三件事全搅在一起了。整理笔记时我想这个话题值得单独写一篇它表面上是在争论Redis能不能这么用实际上是在问你有没有想清楚自己的数据到底要经过谁、怎么流动、丢了怎么办。1. 背景一次让代码评审气氛变紧张的Redis争论1.1 同事说的“直接调RPC不就行了”为什么没说服我当时同事的理由很朴素如果B进程需要A进程的数据直接暴露一个RPC接口让B去调用数据是实时的也不用额外引入一个存储。这个说法对很多“查询型需求”其实成立。比如A进程有一个用户订单状态B进程需要查那A提供一个查询接口B每次调用就能拿到最新值确实不需要Redis。但问题在于这个方案有两个隐含条件。第一A必须保持存活且健康A挂了所有依赖它数据的人都跟着挂。第二RPC是“拉模型”调用方每次都要主动请求如果十个进程都需要这份数据A就要被调十次如果再加上对账、报表、告警这类批量任务调用量会迅速膨胀。Redis在这里解决的不是“查询”问题而是“数据所有权”问题。把共享数据放到Redis后A、B、C不再关心数据是谁产生的它们只跟Redis打交道。这个数据就变成了一种团队成员都能读写的公共状态而不是A的私有状态。对于多人同时协作、版本迭代快的团队来说这种解耦往往比“精确但脆弱”的直接RPC更值得优先考虑。1.2 Redis在IPC场景里的真正优势很多人一提到进程间通信第一反应是共享内存、Unix Socket、RPC。它们当然更“底层”、更快但都隐含一个前提进程之间离得足够近或者网络拓扑相对固定。共享内存只能同一台机器Unix Socket只能本机TCP RPC虽然能跨机器但你需要自己设计连接管理、超时、重试、序列化协议。Redis不同它天然就是一个TCP服务所有语言都有官方客户端。你不需要自己造轮子只需要把要共享的数据用简单命令写进去另一端就能读取。这就把“进程间共享数据”变成了“大家一起操作一个带超时时间的字典”学习成本和接入成本一下子降下来了。而且Redis自身还天然提供了“数据快照”能力。比如RDB持久化可以在某个时刻把内存里的数据全部落下盘。这就意味着它不只是两个进程之间的临时交换场所它还是进程状态的存档点机器重启、容器重新调度、新实例扩容都能从这个存档点恢复。这一点是共享内存和普通RPC完全做不到的——共享内存崩了就没了RPC调用本身也不保存任何状态。聊到这同事自己就承认如果只是简单“取数”RPC够了但要做“共享状态可恢复”Redis确实是最短路径。2. 进程间共享数据该怎么选数据结构、序列化与锁2.1 先回答最原始的问题为什么需要一个“中间仓库”如果两个进程要共享一份数据最直接的方式是把数据放在一个双方都能访问的“仓库”里。仓库可以是数据库、文件系统、Redis也可以是etcd、ZooKeeper。选型要考虑三个问题读写频率、可接受的延迟、丢失后能不能重建。文件系统的问题是锁太弱多个进程同时写一个文件很难保证原子性数据库的问题是单次查询延迟相对高高并发下扛不住。Redis是内存操作单条命令延迟通常在亚毫秒级而且提供了INCR、HSETNX、EXPIRE这类原子命令可以在一个命令里完成“判断写入设置过期”这对并发协作非常有用。所以在讨论里我画了个比喻Redis像一个“公共公告栏”进程们把状态写到上面其他进程不需要知道写字的人是谁只要按规矩擦写就行。公告栏挂了也不要紧因为Redis自己会把内容定期快照存档重启后公告栏恢复原样。这个“中间仓库”解决的核心问题是把进程之间的强耦合改成“每个进程只依赖Redis”的弱耦合。2.2 数据结构的选择不是拍脑袋是看并发模型Redis数据类型并不只是“键值对”它们各自适合解决不同并发场景。这段讨论我们现场整理成了一张表贴在笔记本上类型典型使用场景注意事项String配置项、分布式锁、轻量状态单个值不能太大大Value会拖慢其他命令Hash一个业务对象的多字段比如用户信息、任务详情还可以单独修改某个字段无需整个覆盖List任务队列、异步消息通道配合LPUSH/BRPOP做生产者消费者Set去重、标签、在线状态集合SADD/SREM很多逻辑可以一条命令完成ZSet带权重的排行榜、延迟任务、超时队列分数可以是时间戳用来做延时扫描举一个实际场景B进程要维护一批在线设备的版本号。一开始同事用String每个设备一个key版本更新就重写整个字符串。后来发现读取侧只关心其中某几个字段写入侧更新了一个字段却要带上其他字段很容易产生脏写。换成Hash之后一个设备一个Hash单独更新version字段其他字段不受影响代码也简化了不少。选择数据结构的核心判断标准不是“哪个操作看起来方便”而是“你的并发读写单元是整条数据还是数据的一部分”。如果每个写操作都覆盖整个对象String就够如果多个进程会同时改不同字段Hash要可靠得多。2.3 序列化决定不同进程能不能“听懂同一句话”我们最初踩过一个很隐蔽的坑A进程是JavaB进程是GoA把对象序列化后塞进RedisB读出来是一堆乱码。排查半天才发现A用的序列化器是JDK默认序列化这种格式只有Java能读。这就是进程间共享数据最容易忽略的问题Redis只负责存储字节流不负责理解内容两个进程要想“听懂同一句话”必须约定一套共同的语言。我的建议是跨语言场景统一用JSON或Protobuf。JSON适合配置、状态信息因为它可读性好排障时拿到key就能肉眼看出内容Protobuf适合高吞吐、大数据量的场景体积小、反序列化快但调试时不太友好需要额外工具。另外还要注意字段兼容。一次发布过程中A进程上线写入了包含新字段的数据B进程还是旧版本代码反序列化时直接报错。后来我们约定字段只增不改旧进程遇到未知字段要忽略不能直接抛异常。这个约定比选哪个序列化器更重要因为只要多个进程还在共享数据版本不一致就是常态。2.4 用Redis实现分布式锁从SETNX到续租讨论进程中共享数据绕不开并发控制。最典型的做法是分布式锁。早期很多人习惯这样写SETNX lock:task 1 ... 执行业务 ... DEL lock:task问题是SETNX不带过期时间如果进程在执行业务时挂了锁永远不释放其他进程全部卡死。后来推荐用一条命令把加锁和过期时间一起完成SET lock:task 1 NX EX 3030表示30秒后自动释放解决了“持有者挂掉锁变僵尸”的问题。但紧接着又有了新问题如果业务执行超过30秒锁提前过期了另一个进程就能拿到同一把锁两个进程同时操作共享资源安全性还是无法保证。所以还要有一个续租机制用Lua脚本检查当前锁的value是不是自己的标识如果是就延长过期时间。别小看这一层很多线上“奇怪并发问题”都源于锁被别人误删。典型错误是A进程处理完直接DEL完全不管锁是不是自己当初放的那个。正确做法是value放一个随机UUID删除前用Lua判断匹配才删if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end那么用不用Redlock我的看法是绝大多数业务用上面这种带续租的单节点锁就够了。Redlock虽然解决了主从切换下的极端场景但实现复杂度高而且本身也有争议。先想清楚你的业务能不能接受极端情况下“两人同时拿锁”如果最多只是浪费一点计算资源那简单锁即可没必要把系统搞重。3. 数据快照RDB、AOF和进程状态的“存档点”3.1 RDB快照是fork出来的“照相馆”Redis生成RDB快照的机制很多人理解成“复制内存”其实不对。Redis会通过fork创建一个子进程子进程顺手把内存里的数据写成一份RDB文件父进程继续接待请求。这里的关键是fork之后父子进程共享同一份内存页如果父进程要修改某个页操作系统会把那页复制一份给父进程用子进程看到的仍然是fork那一刻的内存。这就是“写时复制”所以RDB不会长时间阻塞主进程。但要注意RDB的触发是有代价的。如果Redis占用了10GB内存fork那一刻可能要把相关资源准备一下在超大实例上偶尔会出现几十毫秒到一两秒的停顿。另外写时复制期间如果有大量写入内存使用可能短暂翻倍。所以不要在生产环境大实例上频繁触发BGSAVE更不要手滑执行SAVE——SAVE是同步生成快照会直接卡住Redis。RDB自动触发规则也很容易忽略默认配置里900秒内1个key变化、300秒内10个key变化、60秒内10000个key变化会依次触发。但如果你只写入很少的key长时间可能都不落盘。比如一个配置中心一天只改几次Redis默认配置可能要900秒甚至更久才快照一次这时最好手动调低阈值。3.2 AOF美名“追加日志”本质也是增量快照很多人觉得RDB就是快照AOF就只是日志。其实AOF同样是一种数据恢复手段它记录的是每次写操作相当于把“变化的经过”存下来配合定期重写可以得到一份相当于快照的基线。AOF可以配置三种刷盘策略appendfsync always、everysec、no。always每次写都刷盘最可靠但最慢everysec每秒刷一次性能比较平衡no交给操作系统刷性能好但丢数据多。线上我一般用everysec既能保证大部分情况下最多丢一秒数据又不至于让Redis变成磁盘瓶颈。但AOF文件会越来越大所以Redis有AOF重写机制。重写的含义是扫描当前内存数据生成一个最小化的命令序列相当于重新生成一份“当前状态快照”。这样日志文件就不会无限膨胀。重写也通过fork子进程做所以经验和RDB类似监控内存、避免高峰期触发。实际生产里我的建议是同时开启RDB和AOFRDB负责快速恢复和备份AOF负责把数据丢失窗口压缩到秒级。Redis重启时会优先用AOF恢复因为AOF数据更新。如果只是拷贝一份临时存档也可以用BGSAVE触发一次RDB再把dump.rdb传到备份存储。3.3 快照恢复的实战套路把Redis当状态机用跟同事讨论时我说快照最容易被理解成“备份文件”但在进程间共享数据的语境里它就是“上次公共状态的存档”。我们有一个任务分发程序多个worker进程会从Redis的ZSet里取“待处理任务”每个任务的score是下次执行时间。这套状态如果只存在内存里一个worker重启它负责的任务就可能永远没人执行。后来我们做了一套恢复流程Redis打开AOF后任务状态全部落在Redis里即使所有worker重启只要Redis还在ZSet里的任务排队信息完好无损。更进一步Redis自己如果也在灾难中重启依靠RDB和AOF恢复整个任务队列不会丢。这里有个非常重要的实操细节如果Redis机器本身要停机迁移不能直接kill然后拷走dump.rdb。因为内存里可能还有没来得及落盘的写操作。稳妥做法是先用BGSAVE主动触发一次快照等它完成后再把dump.rdb和AOF文件一起复制到新环境。否则丢了几秒钟的数据对某些业务来说就是事故。快照的价值不是在你一切正常时体现的而是在你不得不从废墟中恢复时体现的。4. RPC链路里的Redis从缓存到状态协调4.1 缓存RPC结果学会给重复调用“减负”再回到开头那个质疑进程间直接调RPC行不行行但很多时候不需要每次都调。比如一个RPC接口返回用户的基础信息这个信息十分钟内基本不会变如果100个请求打过来每次都去下游查一遍既浪费下游资源也增加接口延迟。用Redis缓存RPC结果是最常见的“给重复调用减负”的做法。做法很简单把RPC请求参数拼接成keyRPC返回值序列化后作为value设置合理的TTL。第一次调用会真正打到下游之后一段时间内直接读Redis。这个方案的问题主要在三个地方缓存穿透、缓存雪崩、缓存击穿。穿透是指查询一个不可能存在的key每次都会打到底层。解决办法是即使RPC返回值是空也缓存一个空值并加一个较短的过期时间。雪崩是指大量key同时过期导致同一时刻请求全部打向下游。解决办法是给TTL加一个随机余量让过期时间分散。击穿是指一个热点key过期大量请求同时去重建缓存。解决办法是加互斥锁只有一个请求去调RPC其他请求短暂等待后读新缓存。这三板斧看起来基础但能把缓存RPC这套组合拳打得很舒服。4.2 RPC超时与幂等Redis是天然的重试保险箱前段时间线上报过一个错误某个RPC客户端报了“cannot finish rpc call in 30 seconds”后面跟着“curl 56 recv failure”。排查后发现问题不在Redis但Redis成了救场的关键。当时的服务逻辑是A进程调用B进程B进程处理时间不固定最慢时可能超过30秒。A进程的RPC框架到30秒直接断开B进程其实还在处理处理完想回传结果已经找不到调用方了。这个问题本质是超时时间设置不合理但更麻烦的是A进程随后可能发起重试如果重试会重复创建资源就必须有幂等机制。我们用Redis存了一个请求状态Hashkey是requestId字段是state处理中/成功/失败和result。A进程发起RPC前先往Redis写“处理中”B进程处理完更新“成功”。如果A超时不要立刻盲目重试而是先查Redis里的requestId看看B进程到底有没有做完。如果已经成功直接拿结果如果还在处理中继续等待或者等下一次轮询。这样做之后RPC调用的“超时”和“结果未知”不再是一团乱麻Redis充当了双方都能看见的进度表。这个模式在框架层可能已经帮你做了但如果你遇到简易RPC或者RPC客户端没有幂等能力自己用Redis补一手是很便宜、也很实用的方案。4.3 用Redis维护服务列表轻量注册中心该怎么做服务多了之后很多团队会引入完整的注册中心比如etcd、Consul。但有时候只是几个内部服务要互相发现没必要上那么重的组件。Redis本身就能做一个轻量版的服务列表。做法是每个服务实例启动时向Redis写一个Hash字段HSET service:order node_1 timestamp_1 HSET service:order node_2 timestamp_2然后每个实例每隔几秒用事务或Lua脚本刷新自己的心跳时间。调用方RPC前从service:order里把所有字段取出来过滤掉心跳时间超过阈值的节点剩下的就是可用实例列表。这套方案解决了一个实际问题进程间RPC如果写死IP节点扩容或故障替换时改配置重启所有调用方会非常痛苦。有了Redis这个共享仓库节点上下线几乎是实时的。但要注意两个细节心跳刷新不能用SET直接覆盖整个key多个实例会互相踩删除下线节点时也要确认它确实心跳过期了再删。轻量注册中心的核心是“快、够用、好解释”而不是代替专业注册中心去做强一致协调。4.4 用List把同步RPC改成异步通道还有一些场景进程间根本不需要同步等结果。比如B进程要触发C进程做一次批量渲染A进程在旁边等待没有任何意义不如把任务塞进Redis的ListC进程用BRPOP阻塞式取任务处理完再更新状态。这样RPC调用的“同步等待”就变成了“异步投递”。流程简单来说是这样LPUSH task:render {jobId: 123, params: ...} # worker进程执行 BRPOP task:render 30这里我用BRPOP而不是LPOP是因为它会在队列为空时阻塞等待不消耗CPU。任务处理可能失败所以要实现一个最简单的确认机制worker取到任务后先把任务信息往一个“处理中”Hash里写一条处理成功再删除如果下一秒又读到同一个jobId说明上一次处理失败了可以重试或者放到死信队列。Redis List做轻量异步通道的好处是零额外依赖缺点是可靠性不如专业消息系统Redis重启时List内容恢复依赖AOF如果AOF没打开未消费的任务会丢。所以这个方案更适合内部低优先级任务或者你能接受丢失少量消息的业务。如果丢不起消息还是老老实实上MQ别让Redis硬扛它不擅长的活。5. 排障实录与日常配置的六个细节5.1 命令超时先查的不是Redis而是谁blocked热词里有一堆“redis command timed out”相关的报错很多人一看到就以为Redis挂了。我的经验是先不要慌着重启先看看Redis是不是被某一个慢命令或者大Key操作给堵住了。Redis是单线程执行命令一旦执行一个耗时非常长的操作后面所有命令都得排队。排查命令很简单redis-cli --latency redis-cli SLOWLOG GET 10 redis-cli CLIENT LISTSLOWLOG能看到执行时间超过阈值的命令比如某个KEYS *在几百万key的实例上跑了几百毫秒这就是阻塞的主要嫌疑。再比如读取一个巨大的String或Hash网络传输和序列化也很费时间。遇到这种情况解决办法不是加连接池大小而是改造命令用SCAN代替KEYS用HGETALL改成分批HSCAN大Value拆分或压缩。把Redis用成“简单快速的小操作”才是正路。5.2 主从同步与快照别在峰值期触发BGSAVE构建高可用时Redis运维上最容易忽略的是主从同步和快照的冲突。主从首次同步时从节点会请求主节点生成RDB快照如果主节点本身内存里数据量很大这个RDB文件的生成和传输会吃掉大量磁盘IO和带宽。业务高峰期如果正好碰上有新从节点加入主节点延迟会肉眼可见地上升。我的做法是凡是涉及主从扩容、重新全量同步的操作都安排在低峰期做如果高峰期必须加从节点就要限制RDB传输速率或者临时调低client-output-buffer-limit避免拖垮主业务。另外主节点的RDB快照自动触发规则不要保留默认的900秒配置就完事了要结合业务写入量主动进行一次压测看看高峰写入时触发BGSAVE延迟会跳多高。能提前发现的问题就不要留到线上报警时才研究。5.3 连接不上八成是bind和protected-mode在捣乱很多刚接触Redis的人包括以前的我都会遇到“本地明明装好了代码连不上”的情况。尤其在Windows环境或者本机用虚拟机安装RedisCannot connect报错一大半原因是Redis默认只监听127.0.0.1而且开启了protected-mode不设密码的话拒绝外部访问。解决方法要看你的使用场景如果只是本地开发最简单的办法是改一下配置文件把bind改成0.0.0.0并设置一个密码如果是公司内网使用一定要设置requirepass再配合ACL控制客户端权限。千万不要图省事把所有保护都关掉你只是想让本机调试方便结果可能让整个内网都能操作你的Redis。可视化管理工具方面我一直在用的是Another Redis Desktop Manager比很多老的桌面客户端清爽支持键值预览和命令行执行。注意它只是一个查看工具删除、清空这类风险操作还是要谨慎线上环境建议先用TTL、TYPE看清楚再动手。5.4 给共享数据设置过期时间但别迷信过期时间最后一个小细节也是那次讨论让我印象最深的用Redis做进程间共享数据时几乎每个key都应该有一个明确的TTL。别觉得“我们想长期维护这份数据所以不设过期”。没有过期时间一旦某个进程忘了更新这份数据就会一直留在那里成为“僵尸状态”。但反过来过期时间也不能解决所有问题。有个同事提出既然Redis能设过期时间那“共享数据失效”就交给Redis自动处理好了。这个想法部分正确但不完整。比如我们用ZSet实现了一个任务延时队列score是期望执行时间如果只是依赖key过期那Redis过期删除策略是惰性的不能保证精确到秒。所以业务逻辑要在读取时自己判断score和当前时间的差值把过期时间当作“最终兜底”而不是“唯一调度器”。那次讨论最后我们达成了一个共识Redis可以帮你存储、共享、恢复状态但不会替你做业务判断。你能把多少逻辑前置到设计阶段决定了Redis在项目里是“可靠状态仓库”还是“临时数据垃圾桶”。跟同事争辩的那半小时最后沉淀下来的不是谁对谁错而是几个朴素原则共享数据要有明确归属快照要有恢复演练RPC要能处理“结果未知”。每次要往Redis里塞数据时先问自己一句这份数据如果睡一觉起来没了我的进程还怎么跑想清楚了再动手Redis大概率不会让你失望。
返回列表