尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Redis核心知识与实战:线程模型、缓存一致性及内存管理

Redis核心知识与实战:线程模型、缓存一致性及内存管理 1. Redis基础知识点那些被问烂了却依然能问出新花样的“送分题”1.1 别再背“Redis是内存数据库”了——它到底在解决什么问题Redis全称Remote Dictionary Server本质上就是一个基于内存的键值存储系统。很多人在面试或者日常交流里第一句都会背“Redis是一个高性能的缓存数据库”这话没错但如果你只停留在这一层后面几乎任何一个追问都会把你卡死。我们换个角度理解Redis它解决的从来不是“存储”问题而是“访问速度”和“并发压力”问题。磁盘数据库的瓶颈在IO即便上了SSD随机读写也就几万IOPS但Redis跑在内存里读写都是微妙级别的单实例QPS轻松到10万。所以项目里面对热数据、高并发读、分布式锁、排行榜、消息队列这类场景首选基本都是Redis。Redis能这么流行还有一个很关键的原因它的数据类型不是简单的key-value字符串而是包含String、List、Hash、Set、ZSet五种基础数据结构外加Bitmap、HyperLogLog、Geo、Stream这些扩展结构。这意味着你不光能缓存对象还能直接用它来计数、做限流、做去重、算地理位置距离、做轻量级消息队列。我见过很多团队把Redis当缓存用其实只用了它20%的能力。1.2 缓存三大经典问题穿透、击穿、雪崩背后都是同一套思路这三大问题基本是Redis面试题里稳坐C位的话题很多人能背出解决方案但未必理解为什么这么设计。我建议换个思路去记这三种情况都是“缓存没挡住请求压力落到数据库上”区别只在于请求的对象不同。缓存穿透请求的是缓存和数据库里都不存在的数据。比如用一个不存在的用户ID查用户详情每次请求都直接打到数据库缓存形同虚设。常见的解决方案有两个方向第一布隆过滤器把可能存在的数据ID全部放进过滤器里查询前先过滤ID不在过滤器里直接返回不存在第二缓存空值即使查询结果为空也缓存一个短时的null值比如设置60秒过期避免同一批无效请求反复穿透到底层。缓存击穿某个热点key在同一时刻失效大量并发请求直接打到数据库。比如秒杀商品详情页某个爆款商品的缓存刚好在0点过期一瞬间所有用户请求都穿透到底层。解决方案通常是互斥锁只允许一个请求去重建缓存其他请求等待或直接返回旧缓存更高级一点的做法是逻辑过期缓存里不设置物理过期时间而是带一个过期标记字段后台异步刷新。缓存雪崩大量key在同一时间段集中过期或者缓存节点故障宕机导致请求大面积打到数据库。这个问题的核心是避免“同时失效”所以最简单的做法就是给缓存过期时间加一个随机值把集中过期的时间打散。另一种思路是做多级缓存本地缓存比如CaffeineRedis集群兜底即便Redis出现问题热点数据还能在本地挡一阵。1.3 持久化选型RDB和AOF各有什么底牌Redis虽然定位是内存数据库但持久化是不可回避的生产需求。RDB和AOF这两套机制很多人只知道“RDB是快照、AOF是日志”但真正到选型的时候往往犹豫不定。RDB把内存数据以二进制快照的形式写入磁盘。优点是文件紧凑、恢复速度快、适合做冷备和灾备缺点是它是“定时快照”模式两次快照之间的数据如果宕机会直接丢失而且触发快照时如果数据量大fork子进程会带来短暂的阻塞。默认配置里只要满足“900秒内有1次写操作”或“300秒内有10次写操作”等条件就会触发生成快照。AOF以追加日志的方式记录每一条写命令数据安全性更高可以做到每秒同步甚至每次写入同步。优点是丢数据概率极低缺点是文件会越来越大恢复速度比RDB慢。好在Redis 4.0之后引入了AOF重写机制可以压缩日志体积而且支持RDBAOF混合持久化——用RDB作为基础快照增量数据用AOF日志记录兼顾了恢复速度和数据安全。实际生产里我建议这么选如果对数据丢失容忍度很低比如订单数据、账户数据那就用AOF并且配置为appendfsync everysec这个配置在性能和安全性之间最均衡如果只是缓存业务或者可以从上游重新构建数据那么RDB就够了顶多再做一次主从复制从节点再开AOF兜底。大多数情况下混合持久化是最稳妥的选择。1.4 过期删除和内存淘汰两套机制千万别混为一谈Redis里的key一旦设置了过期时间谁来负责删除这个问题很多人答不清因为Redis同时存在两套删除机制一套管“到期的key怎么删”一套管“内存不够了怎么腾”。过期删除策略Redis采用“惰性删除定期删除”的组合拳。惰性删除是说每次访问key时才检查它是否过期过期就删优点是省CPU缺点是过期key如果没有被访问就一直占着内存定期删除是后台每隔一段时间随机抽一批设置了过期时间的key删除其中已过期的。这种组合把CPU开销和内存浪费控制在了可接受的范围内。内存淘汰策略当内存达到maxmemory上限时Redis需要决定腾出空间。这里有8种策略常用的无非是这几个策略说明适用场景noeviction不淘汰写请求直接报错严格要求不丢数据的场景allkeys-lru所有key中淘汰最久未使用的通用缓存场景allkeys-lfu所有key中淘汰访问频率最低的热点数据访问频率差异大的场景volatile-lru设置了过期时间的key中有key淘汰需要保留不设过期时间key的场景volatile-ttl淘汰剩余存活时间最短的key设置了过期时间的场景LRU和LFU的区别值得多提一句LRU只看“最近多久没被访问”LFU则关注“访问频率”。很多实际业务里热点key是持续高频访问的用LRU可能会把一些“偶发访问但频率不高”的key淘汰掉而LFU能更精准地把“长期低频访问”的key清理掉。如果业务有明显的二八分布LFU往往表现更好。2. Redis线程模型拆解单线程为什么反而更快多线程又改了什么2.1 单线程的性能逻辑瓶颈从来不在CPU先说一个很多人理解偏差的点Redis的单线程指的是“命令执行”这个核心环节是单线程的并不是整个Redis进程只有一个线程。文件事件处理器、过期key清理、AOF重写这些后台任务分别有专门的线程在处理。那么问题来了为什么单线程执行命令反而性能比很多多线程系统还高主要有四个原因一是内存存储数据都在内存里CPU不需要等待磁盘IO这是性能上限的根本保障。二是IO多路复用Redis基于epoll实现了事件驱动模型以非阻塞的方式处理大量socket连接单线程就能处理几万个并发连接。三是避免了线程切换和锁竞争的开销。多线程系统里线程切换、上下文保存、锁竞争都会消耗大量CPU周期Redis用一个单线程把这些开销全部省掉了。四是数据结构高效Redis底层用SDS简单动态字符串、跳表、压缩列表这些精心设计的数据结构在读写时效率极高。很多人会在面试里被问到“单线程不怕慢查询阻塞整个系统吗”这个问题问得挺准。正因为命令执行是单线程所以一条慢命令会阻塞后面所有命令的执行。这也是为什么生产上严禁使用KEYS、严禁对大集合执行SMEMBERS等O(N)复杂度指令。2.2 IO多路复用Redis是怎么做到“一个人干几个人的活”的要理解Redis的并发能力绕不开IO多路复用和多路复用的三剑客select、poll、epoll。简单说多路复用就是让一个线程同时监听多个socket的读写事件哪个socket有数据来了就去处理哪个。具体到Redis的实现它用了一个事件处理器分为文件事件和时间事件两类。文件事件负责处理socket的读写请求时间事件负责处理定时任务。核心工作方式就是事件处理循环不停调用epoll_wait把就绪的文件描述符收集起来然后逐个执行对应的回调函数。因为所有回调都是在一个线程里顺序执行的所以天然不需要加锁。这里有个容易被忽略的细节epoll相比select和poll的显著优势是它用红黑树管理监控的事件集合内核态和用户态共享事件表不需要每次调用都把所有fd从用户态拷贝到内核态而且它通过回调机制直接通知“哪个fd就绪了”不用每次都线性扫描查找所有fd。Redis选择epollLinux平台是性能上的必然因为要支撑几万个连接用select显然不现实。2.3 Redis 6.0引入多线程IO改的是网络不是命令执行从Redis 6.0开始引入了多线程IO但这只是针对网络读写部分。逻辑是这样的Redis的瓶颈逐渐转向了网络IO——多路复用虽然能用单线程处理海量连接但每个连接在read/write数据时拷贝数据的开销开始占大头单线程下这部分耗时会逐渐放大。于是官方引入了“IO线程组”用来分担socket数据的读写和解析但命令执行仍由主线程串行完成。默认配置下多线程IO是关闭的。开启方式很简单在redis.conf中加上io-threads 4 io-threads-do-reads yes这里建议大家注意三点第一io-threads通常建议设为4或8不是越多越好因为线程间的同步也是有开销的第二多线程只在处理网络IO时生效命令的执行还是排队串行的所以即便开了多线程也不代表一条慢命令不会阻塞其他命令第三io-threads-do-reads默认是no也就是默认只开写多线程读多线程要显式开启。我的经验是在QPS达到十几万以上且压测确认网络IO成为瓶颈之前保持默认的单线程即可毕竟多线程带来的额外复杂性也是需要运维成本的。2.4 线程模型视角下的性能调优方向搞懂了线程模型你就大概知道Redis的调优应该往哪个方向使劲了。第一优先级永远是避免慢命令。慢命令就像一个单行道的堵车点一条命令卡住几毫秒后面几千条命令都在排队。我在生产上排查过一些Redis超时问题最后定位到都是某个大的Hash结构执行HGETALL导致的几千个字段一次拉出来命令执行时间直接到几十毫秒。第二优先级是控制网络包大小。如果单个value特别大比如存储了几MB的文本或图片base64序列化反序列化加上网络传输的时间会显著增加而且容易触发内存碎片。很多时候把大value拆分成多个小key或者移到对象存储/文件存储里整体性能反而更好。第三优先级是合理使用Pipeline。如果一个业务逻辑里需要连续执行多条命令尤其是有循环的情况一条一条发会浪费大量的RTT往返时延。用Pipeline把命令批量发送到服务端再批量接收结果可以获得数倍的性能提升。但要注意Pipeline属于非原子操作如果对原子性有要求需要用事务或Lua脚本。3. Redis内存管理你以为内存无限大其实处处都是取舍3.1 maxmemory和淘汰策略的选型直接影响稳定性Redis放在内存里那内存就是最宝贵的资源。我在很多团队看到过因为Redis内存打满导致线上故障的情况而且往往是“写失败”或者“频繁淘汰”这种比较隐蔽的问题。所以第一步就是给Redis设置内存上限也就是maxmemory参数。这个参数不设置Redis会一直往系统内存里写最终触发操作系统OOM Kill。设置完maxmemory就要选择淘汰策略。前面表格里已经列了常见策略这里再说说生产上的选型思路。如果是纯缓存业务像用户登录态、验证码、接口返回值的缓存这些数据丢了影响不大重建成本低直接选allkeys-lru或allkeys-lfu即可。allkeys表示不区分key是否设置了过期时间统一参与淘汰这在缓存场景下最省心。如果Redis里同时存了业务数据和缓存数据业务数据不能丢那就要用volatile-lru或volatile-ttl之类只淘汰那些设置了过期时间的key不设过期时间的核心数据永远保留。这样的前提是你必须对每个要淘汰的key都设置了TTL否则淘汰策略会无从下手。如果Redis用于分布式锁、幂等记录等场景建议不要开启淘汰策略。一旦锁的key因为内存淘汰被清掉分布式锁就失效了这个后果相当严重。此时可以把maxmemory-policy设为noeviction让写入直接报错保证已有数据不被动淘汰。3.2 内存碎片Redis空间占用居高不下的隐形元凶我接触过不少运维场景明明Redis里总共存了几GB数据但用内存工具一看RSS常驻内存却占到了十几GB。出现这种差异多半就是内存碎片在作怪。Redis默认使用jemalloc内存分配器它在分配内存时会把请求大小对齐到固定的规格上比如请求17字节实际分配32字节这种设计能提高分配速度、减少某些场景下的内存碎片但也会因为对齐导致少量浪费。另外频繁的写入删除、大量key不同大小的value交替存在会造成内存分配和释放不均产生碎片。怎么判断碎片是否严重看INFO memory里的两个指标used_memory表示Redis实际使用的内存used_memory_rss表示操作系统视角下Redis占用的物理内存。碎片率 used_memory_rss / used_memory。正常情况下这个值在1到1.5之间如果大于1.5说明碎片较多如果小于1说明内存被操作系统回收了一部分swap风险比较高。处理碎片有两条路一是开启自动碎片整理Redis 4.0之后支持activedefrag配置在碎片率超过阈值时自动对内存进行整理二是在主从架构下对从节点进行重启切换让数据以紧凑的方式重新加载一遍。第二种方式虽然粗暴但有时候比自动整理更彻底。3.3 小对象编码同样的数据不一样的体积Redis在存储小对象时会做一些编码优化这一点非常实用但容易被忽略。以Hash为例如果字段数很少且每个value都很短Redis会用ziplist压缩列表来存储而不是标准的hashtable这样连续的内存块能大幅减少内存占用。List在元素少时用的是quicklistSet在元素全是整数且数量少时用的是intsetString短于44字节时用embstr编码。这些优化完全是自动的不需要开发人员介入。不过这类优化有阈值限制比如hash-max-listpack-entries默认128超过这个数量就升级为正常编码。如果你的业务里刚好有大量小对象比如积分明细、用户标签这些配置可以适当调大能节省不少内存。反过来如果里面存的是超过阈值的大对象那么这种优化也没意义不必强行调参。我在实际项目里做过一次对比同样10万个用户标签用Hash存储时合理设置hash-max-listpack-entries和hash-max-listpack-value内存从500MB降到了380MB左右效果很明显。这类参数对业务透明调起来安全边际高适合作为内存治理的第一步。3.4 Redis的key过期扫描与实际内存回收的延迟还有一个小细节Redis删除过期key并不等于内存立刻释放。内存分配器可能继续保留已释放内存供后续使用不会立刻归还给操作系统。所以有时候业务里删除大量key后你观察used_memory_rss并没有明显下降这是很正常的。更典型的场景是一批带TTL的key到期后你看到内存下降是“阶梯式”的而不是瞬间的。因为定期删除是抽样扫描默认每秒扫描数次每次只处理部分key大量过期key可能分布在不同桶里需要多个周期才能全部处理完。如果过期key数量特别大还可能触发主动的过期循环清理占用主线程资源造成短暂延迟。所以生产上做缓存治理时我会把“TTL是否合理”作为一个专项来检查。给一批key设置完全相同的过期时间比如每天凌晨统一过期是造成雪崩或者过期风暴的常见诱因务必要在过期时间上做随机化处理。4. Redis事务机制它跟MySQL事务长得像但脾气完全不同4.1 MULTI/EXEC/DISCARDRedis事务的基础流程Redis事务从命令上看很简单MULTI开启事务EXEC执行事务DISCARD丢弃事务。执行流程是客户端发送MULTI后Redis会把后续命令全部放入一个命令队列直到收到EXEC才按顺序执行队列里的全部命令。这里有几个关键特性值得展开。第一事务执行期间不会有其他客户端的命令插入所以事务里的命令之间不会相互干扰这个称之为“隔离性”是够格的第二事务不具备传统意义上的回滚能力——如果某条命令在“入队”阶段就发现语法错误Redis会在EXEC时直接拒绝整个事务但如果命令在“执行”阶段才出错比如对一个字符串执行LPUSHRedis不会撤销之前已经执行的命令也不会回滚而是跳过出错命令继续执行后面的命令。这意味着什么意味着你用事务做批量写入不要想当然地认为“要么全成功要么全失败”。举个例子用MULTI同时给用户A加积分、给用户B减积分如果给B减积分的命令在运行时出错比如B的key不是数字那A的积分已经加成功了而B的操作被跳过。这种“部分成功”的状态在涉及资金、库存这类敏感数据时是不可接受的。所以这些场景正确做法是结合WATCH做前置校验或者直接用Lua脚本。在选用事务前建议问自己一个问题这些命令如果执行到一半出错结果是否可接受如果不可接受就别用裸的事务老老实实加上回滚补偿或者换Lua。4.2 WATCH乐观锁给事务加上并发控制Redis事务本身不解决并发冲突问题。比如两个客户端同时读到某个key的值各自经过计算再写回最后结果就是“后写覆盖先写”数据就丢了。为了解决这个问题Redis提供了WATCH命令它是一种乐观锁机制。使用方法是先WATCH一个或多个key然后MULTI开始事务队列里写入需要执行的命令最后EXEC执行。如果在此期间被WATCH的key被其他客户端修改过EXEC会返回nil事务不执行你可以重新读取数据再战。这个机制非常适合“先检查再更新”的业务场景。举个例子秒杀场景里要扣减库存可以先WATCH库存key事务里执行“检查库存大于0扣减库存”。如果两个用户同时读库存都是1WATCH能够察觉到库存被改动第二个用户执行时事务直接失败避免超卖。这种乐观锁思路简单高效但缺点是冲突严重时重试频繁。所以它更适合冲突概率低、读多写少的场景一旦热点key写并发很高需要引入分布式锁。4.3 Lua脚本比事务更可靠的原子操作方案在实际生产里我更喜欢用Lua脚本代替裸的MULTI/EXEC事务因为Lua脚本在Redis中是以原子方式执行的。脚本在执行期间服务端不会处理其他任何命令整个脚本就是一个不可分割的整体这从根上解决了“中间出错不留痕”的问题。更重要的一点是Lua脚本里可以写判断逻辑。比如扣库存如果库存不够脚本直接返回错误码不会执行扣减也不需要外界补偿。这比MULTI/EXEC配合WATCH做乐观锁的方案要简洁得多。-- 扣减库存的Lua脚本 if redis.call(get, KEYS[1]) - tonumber(ARGV[1]) 0 then return -1 end return redis.call(decrby, KEYS[1], ARGV[1])用EVAL命令执行这个脚本Redis会保证脚本内部所有操作的原子性。因为这个特性很多分布式限流、接口幂等、秒杀扣减功能在实现时首选都是Lua脚本。需要提醒的是Lua脚本也有它的边界一是脚本要尽量短小因为执行期间会阻塞其他命令长脚本同样会成为慢操作二是Lua脚本里要避免使用随机值或非确定性命令比如TIME否则主从复制时从节点执行结果可能与主节点不一致三是脚本要提前在测试环境验证性能避免线上才发现脚本执行时间超过预置的lua-time-limit。4.4 Redis事务、分布式锁与“Spring事务失效”之间的关系很多人会把Redis事务、Spring事务、分布式事务搅在一起面试里也经常被连环追问。我简单梳理一下它们的关系。Spring的事务Transactional是数据库本地事务基于连接Connection的提交和回滚它管的是单机数据库的事务和Redis事务是两个层面的东西。Spring事务失效常常是因为自调用同类内部方法互相调用、方法非public、异常被catch等原因本质上这些失效场景和Redis没关系但如果你在同一个方法里既操作数据库又操作Redis就需要格外小心。举个例子一个典型的业务方法标注了Transactional方法内先更新MySQL再写入Redis缓存。这时你面临的其实是两个独立的事务MySQL事务和Redis操作。MySQL回滚了Redis里可能已经被写入了脏数据Redis写失败了MySQL却已经提交。这种不一致只能靠应用层的补偿机制比如消息队列异步重试、定时任务对账去解决而不是指望在Spring事务里加个Redis操作就完事。再往深一层如果要在微服务之间保证多节点、多数据库的一致性那就是分布式事务的范畴了常见方案有基于消息队列的最大努力通知、基于可靠消息的一致性方案、以及Seata这样的分布式事务框架。这里注意一个原则不要把分布式事务的重任压在Redis身上Redis事务只解决单节点上的原子操作问题。Redis的分布式锁SET NX EX、RedLock虽然能帮你在分布式场景下的互斥控制提供基础能力但它不提供ACID事务保证。5. 我的实战心得缓存一致性、慢查询排查与安全底线5.1 缓存一致性问题先更新数据库还是先删缓存这是Redis面试题中极高频的追问也是生产里天天会遇到的问题。主流方案是“Cache Aside Pattern”读的时候先读缓存读不到再读数据库然后回填缓存写的时候先更新数据库再删缓存。先更新数据库再删缓存比先删缓存再更新数据库要安全。原因是如果先删缓存在缓存没有回填的间隙另一个请求读到数据库的旧值并回填缓存那么这条旧数据可能在缓存里驻留很长时间而先更新数据库再删缓存虽然也存在短暂的不一致窗口但至少不会把旧数据长时间写入缓存。为了进一步缩小不一致窗口还有一个“延迟双删”技巧更新数据库后先删缓存然后睡眠一小段时间比如几百毫秒再删一次把可能回填旧数据的缓存再清掉。这个方案在实践中可以大大降低脏缓存命中概率但没有办法完全消除。真正的一致性兜底还是要靠“缓存过期时间”作为最终防线——把核心缓存的TTL设置到一个可接受的范围内即便出现极端情况也能在TTL到期后自动恢复。5.2 线上Redis变慢我的排查链路是什么聊几个真实的踩坑经历。有一次线上Redis响应时间从0.5毫秒飙升到10毫秒我当时的排查链路是这样的第一步登录Redis执行INFO commandstats看最近命令的调用次数和耗时排序quickly定位到大量HGETALL大Hash操作——这就是慢命令的元凶一个Hash里有上万个字段每次HGETALL都要序列化全部数据。第二步用SLOWLOG GET查看慢日志确认几条执行时间超过100毫秒的命令逐一分析。比如有个客户的积分操作集中在整点触发导致整点附近大量写命令排队。第三步检查INFO keyspace看key的数量增长情况。如果发现大key增长很快考虑进行拆分或改用HSCAN分批读取。第四步查网络方向。Redis的响应时间陡增不一定全是服务端问题如果是跨机房访问网络抖动也可能导致客户端感知到超时。即便Redis本身处理正常客户端连接池打满、频繁建连也会拖慢整体链路。这里特别提醒一点生产环境一定要提前开启慢日志。默认配置里慢日志阈值是10000微秒10毫秒我建议直接调到5000微秒因为Redis单命令执行超过5毫秒基本就需要关注了。5.3 生产Redis的配置底线别把安全当小事Redis在刚安装时默认没有密码、默认端口6379、默认只绑定本地很多人图方便直接裸奔部署。一旦暴露到公网扫描工具几分钟内就能发现并写入恶意key或者直接被用来做挖矿木马的跳板。几个必须落地的配置项requirepass 强密码 rename-command KEYS rename-command FLUSHALL appendonly yes maxmemory 4gb maxmemory-policy allkeys-lru把KEYS、FLUSHALL这类危险命令改名或禁用是防止误操作和外部攻击的关键。同时修改默认端口、禁用protected-mode或者用防火墙/安全组严格限制访问来源都是最基础的安全底线。如果在容器或云环境里部署还要注意不要用root权限运行Redis进程降低被利用后的影响面。5.4 一条被忽略但非常重要的建议监控Redis而不是迷信Redis文章写到这里我想把最重要的经验放在最后想在一个系统里长期稳定使用Redis技术选型和参数调优只是第一步持续的监控和容量规划才是避免事故的核心。监控方面INFO memory里的used_memory、used_memory_rssINFO stats里的instantaneous_ops_per_sec瞬时QPS、total_net_input_bytesINFO replication里的主从延迟这些都是不能漏掉的指标。建议在QPS、内存使用率、连接数上设置告警。尤其是内存使用率我的经验是长期超过70%就要开始做容量评估或数据清理了因为一旦进入淘汰或者内存碎片加剧性能会以肉眼可见的速度恶化。容量规划上Redis不像MySQL那样加了索引就能抗住增长它的一切优秀性能都建立在“所有数据都活在内存里”这个前提之上。所以每接入一个新业务、每新增一个缓存key都要问一句这个数据必须全量放Redis里吗它的增长上限是多少如果数据量无限增长要不要用集群分片这些问题的答案决定了Redis在你的架构里能稳定跑多久。最后再分享一个小技巧当你觉得Redis变慢、配置也已经调到一个合理水位时先用redis-cli --stat观察一段时间的实时指标再结合慢日志判断瓶颈而不是凭感觉改配置。稳得住比追求极致的参数更重要。
返回列表