ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis实战避坑指南:大Key、热Key与缓存一致性全解析

Redis实战避坑指南:大Key、热Key与缓存一致性全解析 做后端这些年Redis算是我又爱又恨的一件东西。爱它是因为一个单线程模型加几行命令就能把高并发缓存扛得稳稳的恨它是因为一旦使用姿势不对线上的幺蛾子一个接一个。最早一次印象深刻的翻车发生在一个平平无奇的周四下午接口突然大面积超时监控里Redis的CPU直接打到100%费了很大劲才定位到罪魁祸首——一个list集合里塞了几百万条消息单个key的value超过2GB一条LRANGE就把整个实例卡住了。从那时候起我凡是经手Redis项目都会先把坑清单过一遍。这篇文章就结合我自己的踩坑经历把Redis日常使用中最容易出问题的几个环节拆开讲讲顺便给规避方案。不管你是刚把Redis装起来跑Demo还是已经上线了一段时间总感觉哪里不对劲里面提到的场景应该都能对号入座。1. 大key与热keyRedis单线程模型下的两大杀手1.1 大key是怎么把Redis卡死的先理解一个前提Redis是单线程模型所有命令在服务端是串行执行的。一个命令处理多长时间后面的请求就得排多久的队。这就决定了任何单个命令开销很大的情况都是隐患而大key是制造慢命令的头号原因。什么叫大key简单说就是单个key的value特别大或者集合类型的元素特别多。常见的有一个list里塞了几百万条消息比如用户行为埋点、日志推送hash结构存了大量字段HGETALL一次拉几千个字段String直接存了base64图片、大JSON对象、序列化后的完整对象一个set存了全量用户ID。大key的麻烦不只是读的时候慢而是全方位的慢命令拖垮全局LRANGE 0 -1、HGETALL、SMEMBERS这类O(N)命令在大key上执行时间可能从几十毫秒到几百毫秒不等期间所有其他key的请求全部排队删除也是重灾DEL一个几百MB的key内存释放过程可能阻塞服务好几秒。Redis 4.0之后有了UNLINK命令可以异步释放内存但很多人不知道还在用DEL硬删内存倾斜在集群或主从架构里一个大key会让某个分片的内存和带宽远高于其他节点扩容都解决不了问题网络带宽超大value的序列化传输会打满网卡拖累同机部署的其他服务。怎么发现大key最直接的方式redis-cli --bigkeys这个命令会用SCAN遍历整个实例按类型统计出最大的key。注意它是采样统计不是逐key精确扫描但对日常巡检够用了。如果想做更细的监控可以自己写脚本用SCAN TYPE 长度判断定期输出TopN大key。规避方案上我的经验是设计阶段就定义好value上限超过阈值就拆分。比如一个list要存用户最近100条记录那就只保留100条用LTRIM或LPUSH LTRIM限制长度大文件、图片不要进Redis放对象存储Redis只存元数据和URL删除大key用UNLINK或者分批删除比如用SCAN每次取100个元素然后HDEL/LREM避免一次性阻塞上线前把大key巡检做成定时任务超过例如10MB或100万元素的key直接告警。1.2 热key一个key扛下所有流量如果说大key是东西太大热key就是访问太集中。常见场景是秒杀商品详情、爆款新闻、排行榜、热门活动页。某个key在短时间内被超高并发的读请求打上去这个key只落在某一个分片或者某一台实例上所有流量砸向单点CPU和网卡很容易被打满更危险的是热点key如果这时候过期了就会瞬间变成缓存击穿流量直接打到数据库。识别热key比较麻烦服务端本身不直接暴露哪个key访问最多常用的手段是在客户端里加一个本地计数比如Redisson/Jedis的拦截器或AOP统计每个key的访问次数超过阈值报警用MONITOR命令抽样观察一段时间的命令流但注意MONITOR本身会降低Redis吞吐生产环境只能短时间开代理层方案如Codis、Twemproxy会提供热点统计能力不过这些组件现在用的团队已经很少了。处理热key最有效的是打散和分层本地缓存Redis多级缓存JVM里用Caffeine或Guava缓存热点数据设置合理的过期时间扛掉大部分流量key加随机后缀拆片比如hot:item:1这个key访问量太大就拆成hot:item:1:{0}到hot:item:1:{99}每次查询随机带后缀访问把单key压力分散到多个分片提前预热对于可预知的流量高峰比如活动开始前主动把热点数据加载到Redis并设置比平常更长的过期时间热点key的过期时间要错开不要整点集体过期否则就是人为制造击穿。还要提一句热key和大key经常同时出现。比如一个list既是热key又是大key那就更麻烦既要打散访问又要限制大小。处理原则是先拆大再防热。2. 缓存一致性先写库还是先删缓存的争了十年2.1 为什么会出现双写不一致Redis做缓存最常见的模式是Cache Aside也就是旁路缓存读的时候先读缓存没有就查数据库再回填缓存写的时候更新数据库然后删缓存或更新缓存。问题就出在更新数据库和操作缓存这两步不是原子的一旦有并发时序对不上就会不一致。最经典的两个姿势先删缓存再更新数据库。并发下线程A删缓存还没来得及更新DB线程B读到缓存没命中去DB查了旧数据并回填缓存然后线程A才把DB更新成新值。结果缓存里永远是旧值。先更新数据库再删缓存。这个看起来好一些但也有窗口线程A更新DB之后在线程A删除缓存之前线程B读取缓存还是旧值更麻烦的是如果删缓存这步失败了缓存就一直脏下去了。那到底选哪种我的实际感受是先更新数据库再删缓存在大多数业务里比先删缓存更安全因为真正删缓存失败的次数很少而且可以重试兜底。但你不能只靠运气。2.2 延迟双删、异步重试与最终一致我之前在一个订单系统里用的是延迟双删方案长这样1. 先删除缓存 2. 更新数据库 3. 延时N毫秒通常500ms-1000ms 4. 再次删除缓存。这个延迟的作用是等并发读请求把旧缓存写完再删掉。N到底取多少要大于读请求查DB回填缓存的总耗时压测的时候实测过一般300ms-500ms就够。你说这方案完美吗也不是。如果第二步更新DB失败缓存已经被删了下一次读会感知到旧数据这反而是安全的。如果延时没控制好窗口期依然可能不一致。更稳妥的做法是异步删除重试应用层写完DB之后发一个MQ消息或者利用canal订阅binlog由消费者去删除缓存删除失败就自动重试。这样即使应用崩溃消息还在最终也能把缓存删掉。但我要泼一盆冷水大部分业务根本不该追求强一致。缓存本身就是性能与一致性的trade-off正确姿势是设置合理的TTL兜底哪怕出现短暂不一致过期后也会自动恢复更新DB后异步删缓存删除失败靠重试对一致性要求极高的场景比如余额、库存扣减不要依赖缓存做主数据直接用数据库事务和分布式事务方案。2.3 穿透、击穿、雪崩三个容易混淆的坑这三个词放一起说是因为它们都会把流量打到数据库但成因完全不同应对也不同。缓存穿透请求的key在缓存和数据库里都不存在每次请求都直接穿透到数据库。攻击者可以故意构造一堆不存在的ID来打垮你的DB。规避方案接口层先做参数校验无效ID直接拒绝缓存空值即使DB里没有也把这个key缓存下来TTL设短一点比如60秒布隆过滤器把所有可能的ID预加载到布隆过滤器请求进来先判断是否存在不存在直接返回不用碰DB。缺点是布隆过滤器有误判率且对删除场景不友好。缓存击穿某个热点key过期的一瞬间大量请求同时去DB加载。规避方案互斥锁重建发现缓存没命中时先用SETNX抢锁只有抢到锁的线程去查DB并回填缓存其他线程稍后重试或直接返回旧值逻辑过期value里不存真实过期时间而存一个逻辑过期时间字段每次读取判断是否过期过期后异步去刷新期间依然返回旧值。适合读多写少、允许短时间延迟的场景。缓存雪崩大量key在同一个时间点集体过期Redis瞬间“空窗”所有请求打到DB。规避方案过期时间加随机数比如基础过期时间300秒每个key再加0到300秒的随机值多级缓存兜底Redis挂了或空了还有本地缓存集群化部署把key分散到不同分片避免单点压力。这三板斧配套使用基本能挡住绝大多数缓存故障。在我维护的项目里空值缓存和随机过期时间是默认配置互斥锁只加在热点路径上布隆过滤器则要看数据集规模决定要不要引入。3. 分布式锁的四个常见翻车姿势3.1 只加锁不续期客户端一崩就死锁Redis分布式锁最基础的实现是SETNX很多人一开始写的代码长这样if (jedis.setnx(lock:order, 1) 1) { try { // 业务逻辑 } finally { jedis.del(lock:order); } }这段代码的问题一眼就能看出来如果业务逻辑抛异常或进程OOM崩溃finally没执行锁永远留在Redis里后面的请求全部拿不到锁业务直接卡死。正确的加锁姿势是用一条命令同时设置NX和过期时间SET lock:order 123456 NX PX 30000这样即使客户端宕机锁最多存在30秒自动释放不会死锁。注意一定要用SET命令不能在SETNX之后再调EXPIRE因为这两步不是原子的中间宕机同样会死锁。3.2 锁过期导致临界区被并发进入加了过期时间之后又会出现一个新问题锁的过期时间设成30秒但业务逻辑执行了35秒。第30秒锁自动释放第31秒线程B拿到锁进入临界区然后线程A也还在临界区里两个线程同时在写分布式锁名存实亡。这个问题的本质是锁的过期时间无法预估业务耗时设短了会提前释放设长了故障恢复慢。成熟的解法是给锁自动续期也就是Redisson的看门狗机制。Redisson加锁时默认leaseTime是-1会启动一个后台定时任务每10秒检查一次锁是否还在持有如果还在就给锁续期到30秒。业务执行完释放锁看门狗也跟着关闭。手写续期逻辑很繁琐。如果要用分布式锁我强烈建议直接用Redisson别自己造轮子。你只要记住自己实现的分布式锁十有八九死在没续期或者续期写错上。3.3 误删别人的锁还有一个很隐蔽的坑线程A持有锁执行时间太长导致锁过期线程B拿到了同一把锁线程A执行完了finally里执行DEL把线程B的锁给删了。于是线程C又能拿到锁三个线程同临界区。解决思路是给锁加一个身份标识。value里放一个唯一的UUID或者业务ID删除锁之前先比较value是不是自己的是自己的才删。而且这个先比较再删除必须用Lua脚本保证原子性if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end用Redisson的话这些逻辑框架都已经处理好了不需要自己写Lua。3.4 主从切换时锁丢失最后一个坑涉及架构层面。Redis主从复制是异步的如果线程A在master上拿到了锁master还没来得及把锁同步到slave就宕机了哨兵把slave提升为新master此时新master上没有这把锁线程B就可以在同一时间去获取同一把锁分布式锁的互斥性被破坏。这个问题在单机Redis上不存在只有主从或哨兵架构下才有。业界有RedLock方案向多个独立的Redis节点同时加锁超过半数成功才算加锁成功但它本身也有争议很多专家认为RedLock在极端网络分区下依然可能失效。我的个人看法是绝大多数业务用Redisson的普通锁就够了不必上RedLock。更重要的是不要把所有一致性都压在Redis锁上。比如库存扣减即使Redis锁偶尔失效数据库的唯一约束、乐观锁、或幂等键依然能兜底。4. 客户端超时与连接池一次CommandTimedOut的排查实录4.1 报错长什么样Spring Boot项目里最常见的Redis报错就是这句Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutExceptionLettuce是Spring Boot默认的Redis客户端底层基于Netty和Jedis那种每个连接一个线程的模型完全不同。很多团队第一次遇到这个报错就慌了以为是网络问题或者Redis挂了其实根因通常有几种Redis服务端卡住了有大key在跑慢命令或者BGSAVE触发fork导致主线程阻塞网络抖动跨机房调用、带宽打满、交换机拥塞客户端线程阻塞Lettuce共享连接如果某一个请求特别慢后续请求会在队列里排队等超时连接池配置问题Spring Boot默认Lettuce其实没有连接池只有引入commons-pool2之后才有很多人并不知道。4.2 一次完整的排查链路我复盘过一次线上问题当时的链路可以分享给大家参考第一步先用命令行确认网络延迟redis-cli -h redis-ip -p 6379 --latency如果min/avg/max延迟都正常比如几毫秒内说明网络没问题重点转向服务端。第二步看Redis服务端有没有慢命令redis-cli -h redis-ip -p 6379 SLOWLOG GET 10结果里面赫然出现一条KEYS *命令耗时700多毫秒。这就是罪魁祸首。KEYS命令会遍历整个key空间数据量一大必然慢而且它是串行执行的期间所有正常请求全部排队排队超过客户端超时时间就抛出RedisCommandTimeoutException。第三步查看客户端配置。当时项目里spring.redis.timeout没有显式配置用的是默认值结果约等于60秒所以客户端一直在等等不到就报错。这个配置在故障时看似给了容错实际掩盖了问题服务端越慢客户端等得越久请求堆积越多雪崩越严重。最后把KEYS命令改成SCAN分批遍历再给Redis加了一个大key的巡检问题就没再出现过。4.3 规避手段和配置建议基于这次经历我对客户端配置有几点建议命令超时时间spring.redis.timeout建议设在200ms到500ms之间太短会让正常波动也超时太长会让故障雪崩如果用的Lettuce一定显式配置连接池避免默认无界模式里请求无限堆积spring: redis: timeout: 300ms lettuce: pool: max-active: 100 max-idle: 20 min-idle: 5 max-wait: 300ms服务端侧把KEYS、MONITOR、FLUSHALL这些危险命令在生产环境全部禁用或改名见下面的安全章节如果要排查Redis服务端状态多看看INFO里的connected_clients、used_memory、total_commands_processed配合SLOWLOG基本能定位大部分问题。至于Jedis和Lettuce怎么选我的建议是新项目直接用Spring Boot默认的Lettuce没问题关键是理解它的线程模型出问题知道从哪查如果项目里全是同步调用团队对Jedis更熟换成Jedis也完全可以前提是根因不是Lettuce本身否则换客户端也白搭。5. 内存、持久化与主从复制的可靠性陷阱Redis官方定位是内存数据库但很多团队会把它当成可靠存储用。这就得聊聊内存和持久化层面的坑。5.1 maxmemory与淘汰策略不配置就是定时炸弹Redis默认maxmemory是不限制的。这听起来无害但后果很可怕内存一直增长到物理内存耗尽操作系统OOMRedis直接挂掉而且恢复时可能RDB加载失败。正确操作是设上限并选好淘汰策略。Redis提供了六种策略我简单列个表格策略行为适用场景noeviction内存满后写入直接报错数据不能丢的存储场景allkeys-lru对所有key按LRU淘汰纯缓存场景volatile-lru只淘汰设置了TTL的key缓存少量持久数据混合allkeys-random随机淘汰任意key访问模式无规律的缓存volatile-random随机淘汰设置了TTL的key冷热不明显的缓存volatile-ttl优先淘汰剩余寿命最短的key希望TTL快到期的先走最大的坑有两个一是缓存场景配了noeviction内存满了写入就报错业务突然一脸懵二是把Redis当存储用却配了allkeys-lru结果某些重要数据被内存淘汰事后查都查不到。我的建议是缓存实例和存储实例分开部署。缓存实例用allkeys-lru存储实例不设置淘汰策略或者只对无TTL的数据做保护。另外要留意内存碎片INFO memory里的mem_fragmentation_ratio如果长期大于1.5考虑开启activedefrag或重启实例。5.2 RDB与AOF你以为不会丢数据其实会Redis持久化有两种主流手段。RDB是定期全量快照文件小、加载快但两次快照之间的数据会丢。AOF是记录每个写命令最多丢多少取决于刷盘策略appendfsync always每个写命令都刷盘最安全但性能最差appendfsync everysec每秒刷一次最多丢1秒数据兼顾性能和数据安全这是最常用的配置appendfsync no交给操作系统刷盘丢失窗口最大基本没人用。AOF最大的坑是文件无限膨胀。Redis会自动重写但重写本身会占CPU和内存如果实例内存好几十GB重写时瞬间涨出双倍内存是有可能的。4.0之后有了混合持久化aof-use-rdb-preamble yesAOF文件开头是RDB格式的全量快照后面追加增量命令文件大小和加载速度都改善了很多。我的建议是缓存数据不持久化业务数据用appendfsync everysec 混合持久化同时定期手动BGSAVE做物理备份。但记住一点任何配置都不能保证绝对不丢数据主从复制的异步特性决定了总有一个极小的丢失窗口。5.3 主从复制异步复制带来的丢数据与脑裂Redis主从复制是异步的master写完就返回客户端数据要等一会儿才同步到slave。master如果在这个窗口期宕机slave提升为新主这部分数据就永久丢失。缓解手段是配置min-replicas-to-write和min-replicas-max-lag意思是当从库少于指定数量或落后超过指定秒数时master拒绝写入用牺牲可用性的方式保护数据一致性min-replicas-to-write 1 min-replicas-max-lag 10脑裂问题是主从架构下更隐蔽的坑。网络分区时旧master和哨兵失联但还在接收客户端写入哨兵选举了新master分区恢复后旧master降级为slave它分区期间写入的数据会被新master的全量同步覆盖等于白写。最典型的案例是主从切换期间用户下单成功但订单数据丢了。缓解脑裂也是靠min-replicas-max-lag。当旧master发现自己和从库失联并超过阈值就拒绝写入减少丢失窗口。严格来说脑裂不可能完全避免CAP定理决定你必须做选择。如果业务不能接受任何丢失就得考虑用强一致的存储方案Redis不适合做这层保障。6. 部署与安全Docker、可视化工具和日常治理6.1 Docker部署主从时最容易被忽略的几件事现在很多人用Docker装Redis图省事直接docker run redis但这样踩坑的概率很高。第一个坑是容器一删数据全没了。镜像里的Redis工作目录在/data如果不挂载数据卷容器删除后rdb和aof文件全部消失。最少要加-v redis-data:/data。第二个坑是主从连不上。Docker用户自定义网络里两个容器之间要用服务名而不是IP访问。部署主从时slave的replicaof要写成master的容器名或compose服务名不能写成localhost或宿主机IP。第三个坑是端口暴露到了公网。很多教程写-p 6379:6379默认绑定0.0.0.0等于把Redis裸奔到外网。没有密码或者弱密码的话分分钟被扫描出来种挖矿程序。macOS本地调试想快速起一个测试Redisdocker run -d --name redis-test -p 127.0.0.1:6379:6379 redis:7-alpine注意我把端口绑定到了127.0.0.1而不是0.0.0.0这样外网扫描不到本地调试也够用。也可以用brew install redis直接装原生版本两者都行。用docker-compose起主从的话一个最小示例是这样services: redis-master: image: redis:7-alpine command: [redis-server, --requirepass, change-me] volumes: - master-data:/data redis-slave: image: redis:7-alpine command: [redis-server, --slaveof, redis-master, 6379, --masterauth, change-me] depends_on: - redis-master volumes: - slave-data:/data volumes: master-data: slave-data:6.2 未授权访问与危险命令Redis裸奔的下场Redis安全这块说实话很多团队意识是淡薄的。我见过不止一次运维图省事Redis没设密码就放到云服务器上6379端口直接暴露公网。攻击者连上去之后用Redis写cron、写SSH公钥、植入挖矿脚本一波带走整个服务器。等保和攻防演练里Redis未授权访问是最高频的漏洞之一。加固手段其实不复杂配置文件里设置bind 127.0.0.1或内网IP别监听0.0.0.0设置强密码requirepass禁用或改名高危命令rename-command KEYS rename-command FLUSHALL rename-command FLUSHDB rename-command CONFIG rename-command EVAL Redis 6.0以上用ACL给不同业务开不同权限。只读账号不给写权限运维账号才给全量权限Docker部署时对外端口务必绑定内网IP或本机回环地址不要直接映射到0.0.0.0。6.3 可视化客户端的选型与使用禁忌可视化工具方面网上提到最多的几个我也都试过Redis Desktop ManagerRDM最经典的老牌工具但免费版功能有限部分版本开始收费Another Redis Desktop Manager开源免费跨平台支持集群模式内置大key扫描日常开发最常用Redis InsightRedis官方出品的工具支持内存分析、Turbo模式、慢日志查询功能最强纯免费。我的选择逻辑很简单追求功能性用Redis Insight追求界面顺手用Another Redis Desktop Manager。但是用可视化工具连接生产Redis有几个禁忌必须说清楚连接生产环境一定用只读账号不要用有写权限的账号日常浏览数据防止手滑不要在工具里执行KEYS *或者全库刷新这是把生产实例拖垮的经典操作大key扫描、逐key遍历要放在低峰期比如凌晨两点注意DB编号。很多工具默认连db0如果你看不到数据先确认项目里用的哪个DB别急着下结论说数据丢了工具里的批量删除本质是遍历DEL大数据量场景一样会阻塞Redis。6.4 日常缓存治理怎么做最后说治理这是把坑消灭在萌芽状态的手段。我自己在实践中整理过一个Redis缓存治理checklist大致分成四类key命名规范统一用业务:模块:ID的格式。比如订单缓存是order:detail:123456。没有规范的话到后面连哪个key是干嘛的都不知道更别提治理了。TTL治理定期扫描没有设置过期时间的key超过一定数量就告警。缓存场景里永久缓存基本是bug的温床数据变更了不删内存却一直占着。大key与热key巡检用SCAN TYPE抽样扫描统计大key清单客户端访问计数识别热key两者都接入告警。慢命令与命中率监控SLOWLOG里的慢命令要定期清理源头INFO stats里的keyspace_hits和keyspace_misses就是缓存命中率的原始数据命中率低于某个阈值说明缓存利用率不高该优化代码逻辑了。日志层面Redis本身运行日志不多很多团队会忽略。建议至少开启慢日志持久化并让日志采集系统定时分析一旦出现大量慢命令立刻报警。Docker部署时日志要挂载出来别让容器一重启就把日志丢了。说到面试题Redis方向的高频考点其实都藏在这些坑里面大key和热key的治理、缓存穿透/击穿/雪崩、分布式锁的正确实现、持久化原理、主从复制断点续传、内存淘汰策略。你把这些实际问题真正在项目中解决过一遍面试答起来会非常扎实因为背答案的人说不出我遇到过慢命令阻塞单线程这种细节。最后说一点我自己的体会Redis的坑看起来五花八门但归根结底是三类问题——不了解数据结构特性、没提前设计缓存策略、上线前没做巡检和压测。我现在的习惯是每次新接一个涉及Redis的项目先花半小时把key设计、过期时间、淘汰策略、慢日志监控这几件事列成清单再动手写代码。这样做的效果非常明显线上的Redis事故率能降下来一大截。希望这篇也能帮你少踩几个坑。
返回列表