ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Redis为什么这么快?从内存存储到IO多路复用的性能设计解析

Redis为什么这么快?从内存存储到IO多路复用的性能设计解析 1. 先弄清楚“Redis快”到底快在哪聊Redis绕不开那句老话Redis为什么这么快。很多人第一反应是“因为数据放在内存里”这当然没错但远远不够。内存数据库又不是只有Redis一家Memcached也把数据放在内存里为什么后来很多人还是选择Redis再说了把数据放内存就一定能快吗如果代码写得烂、IO模型设计得差就算数据全在内存照样能把服务拖垮。我在生产环境里见过不少把Redis用慢的案例也见过单机扛住十万级QPS的案例。差距不在硬件而在对Redis内部机制的理解。要真正说清楚Redis快在哪得从存储模型、数据结构、IO模型、命令设计、持久化取舍这几个层面逐一拆开看。先给个直观概念Redis官方给出的基准测试数据单实例在普通服务器上跑SET/GET这类简单命令QPS通常能到10万以上如果使用Pipeline批量操作几十万甚至上百万QPS都不稀奇。这个成绩背后没有一台机器是“超算”核心全靠设计和取舍。这篇文章不打算写成教科书式的源码解析而是从一个使用者的角度把Redis快的底层逻辑掰开揉碎讲清楚同时结合我在实际项目中的测试和踩坑经验说说哪些因素真正决定它的性能上限以及快背后到底牺牲了什么。适合刚接触Redis不久、希望理解其原理的同学也适合已经用了一段时间但还没摸透调优门道的朋友。2. 存储引擎的不一样数据放内存只是前提2.1 内存寻址与磁盘IO的差距到底有多大Redis的第一层快确实来自内存存储。但很多人对“内存比磁盘快多少”没有量化概念只是模糊觉得“快很多”。我习惯用一组直观数据来说明普通机械硬盘随机读写延迟大约在5~10ms之间。普通SATA固态硬盘随机读写延迟大约在0.1~0.2ms之间也就是100~200微秒。NVMe固态硬盘延迟能到20~50微秒。内存随机访问延迟大约在50~100纳秒。注意单位变化从毫秒到纳秒差了四个数量级。机械硬盘的随机IO延迟是内存延迟的十万倍左右。传统关系型数据库之所以在大量随机小查询场景下吃力很大一部分时间都耗在磁盘寻道和IO等待上。Redis直接把全部数据放在内存里绕开了这条最耗时的链路。但这里要补充一个容易被忽略的点内存快不等于“所有操作都快”。如果Redis每处理一个请求都做系统调用、都触发内核态和用户态切换、都做复杂的内存分配那再快的内存也会被软件层拖慢。所以内存只是基础后面的IO模型和数据结构设计才是真正拉开差距的地方。2.2 数据结构的针对性优化不只是Hash表那么简单Redis快还体现在它的数据结构上。很多人用过Redis的Hash、List、Set、ZSet但未必想过这些结构底层是怎么实现的。以最常用的String为例Redis没有直接用C语言传统的char*而是自己实现了一个叫SDSSimple Dynamic String的结构。SDS相比普通C字符串有几个关键优势获取长度是O(1)操作因为长度字段直接记录修改字符串时预分配空间减少内存重新分配次数二进制安全可以存储任意数据包括包含\0的字节流。Hash类型底层有两种编码一种是ziplist压缩列表当字段数量少、值比较短时使用把内存紧凑排列省内存且缓存友好当数据量增长后自动转换为hashtable。List和ZSet也有类似的两级编码设计。这种“小数据用紧凑结构大数据用标准结构”的策略让Redis在数据量不大时能用极少的额外开销完成操作。ZSet的跳表设计也很典型。如果你需要保持有序通常的选择是红黑树或平衡二叉树Redis却用了跳表实现起来更简单而且范围查询天然友好。跳表查找复杂度是O(log n)和红黑树一样但它在处理“取某个范围内的所有元素”这类操作时比树结构更直接。3. IO模型是提速的关键单线程多路复用3.1 为什么多线程并不总是更好Redis是单线程模型的代表这经常让刚接触的人困惑现在CPU动辄十几核Redis单线程岂不是浪费硬件我在面试候选人时也经常被反问这个问题。要理解Redis为什么坚持单线程得先搞清楚它快在什么地方。Redis的操作全部基于内存指令执行本身极快绝大多数操作的时间都在微秒级别。如果引入多线程就要考虑锁竞争、线程上下文切换、CPU缓存失效等问题。对于纯内存操作来说多线程带来的并行收益很可能被锁竞争和切换开销抵消甚至得不偿失。举一个很直白的类比一条流水线上每个人都只做一件几分钟的细活加几个人确实能提高产量但如果每个人的操作只需要几微妙工序之间交接和协调的时间反而比干活时间还长那加人就不会带来收益。Redis 6.0之后引入了多线程IO这点要说清楚多线程只负责网络数据的读写命令执行仍然在单线程中完成。为什么这么做因为网络IO的序列化和反序列化在高并发场景下确实能占到不少CPU时间分担这部分工作是有收益的而命令执行保持单线程依然能避开锁竞争。3.2 多路复用机制一个线程怎么盯住成千上万的连接单线程要同时处理成千上万个客户端连接靠的是IO多路复用。Redis在Linux上默认使用epoll机制这需要简单解释一下。传统阻塞IO模型下每个连接至少需要一个线程去读取数据连接数一多就要创建大量线程线程切换开销会让CPU不堪重负。epoll的核心思想是把“监听哪些连接有数据可读”这件事交给内核内核用事件通知的方式告诉程序“哪些socket准备好了”。Redis主线程只需要在事件循环里不断调用epoll_wait一旦有连接可读立即处理对应的事件处理完继续回到等待状态。这样一来Redis能把成千上万个连接同时挂在一个事件循环上不会有线程频繁切换也不会有大量线程空转等待。单线程的工作模式配合高效的事件通知机制才是Redis能支撑高并发连接的根本原因。3.3 Redis事件循环的真实运转顺序Redis内部有一个事件循环核心是两个事件类型文件事件文件事件和超时事件时间事件。文件事件来自客户端请求的连接和读写操作时间事件则用于处理cron任务这类周期性工作。事件循环每轮大致做这几件事调用epoll_wait等待文件事件变为就绪状态这里带上一个最短阻塞时间避免长时间空等。按照事件优先级先处理已经就绪的文件事件读取客户端请求、执行命令、把结果写回客户端。检查时间事件是否到期处理后台任务比如定期删除过期键、触发持久化操作等。回到步骤1继续循环。这个循环的调度策略很简单但没有多余的等待和繁忙占用。Redis的快不是靠复杂调度算法而是靠减少无意义的等待和切换。4. 避免慢操作从命令设计到计算模型4.1 O(1)操作原则让每个命令都保持轻量Redis另一个容易忽略的优势是命令本身的复杂度设计。绝大多数常用命令比如GET、SET、HGET、HSET、LPUSH、RPUSH都是O(1)时间复杂度。这意味着无论数据量多大单次操作耗时几乎不变。我在项目里做缓存时经常强调能用O(1)命令解决的绝不用O(N)命令。比如用Hash代替多个独立String不仅节省内存还能在一次请求里拿到多个相关字段。反过来哪些是O(N)命令要心里有数KEYS是典型的全量遍历命令生产环境绝对不能直接使用数据量一上去单次执行可能耗时几百毫秒甚至秒级直接把Redis阻塞住。SMEMBERS在集合特别大时也一样慢。ZRANGE如果范围过大也会带来明显耗时。经验是O(1)命令运行时微秒级O(N)命令N一变大直接就慢。Redis本身设计上没有问题问题往往是使用者选错了命令。4.2 Pipeline和批量操作把网络往返压缩到极致网络往返时间在网络通信中占据很大比重。假设你的应用和Redis在同一机房一次请求响应大约0.2~0.5ms其中一大半消耗在网络上真正执行命令的时间反而很少。如果你的业务需要在一次请求里连续执行10条命令逐条发送就需要10次网络往返总耗时可能到2~5ms。用Pipeline一次发送10条命令只需要1次网络往返耗时直接降到0.5ms左右。我在测试中实测过使用Pipeline处理批量写入吞吐量能提升5倍以上。Redis事务MULTI/EXEC、Lua脚本也能减少网络往返同时Lua脚本还能保证多条命令的原子性。但要注意Pipeline和事务不是一回事Pipeline只是批量传送不保证原子性事务是批量执行命令之间不会插入其他客户端的命令。4.3 慢命令陷阱那些你以为很快其实很危险的操作这里要专门提几个容易踩坑的命令KEYS pattern全局遍历所有键数据量大时严重阻塞。FLUSHALL、FLUSHDB清库操作可能瞬间卡死线上服务。HGETALL、SMEMBERS返回大Key全部数据网络开销和序列化开销都不小。SORT、LREM、ZREM如果操作的数据量大耗时也会显著上升。SETRANGE、APPEND每次都可能触发SDS空间预分配和扩容频繁使用时注意内存碎片。排查线上慢命令最直接的办法是用SLOWLOG GET查看Redis执行超过指定阈值的命令。我一般把慢查询阈值配置在10ms如果哪条命令频繁出现在慢日志里基本可以断定这条命令用得有问题。5. 持久化带来的取舍快的代价与平衡5.1 RDB与AOF快是要付出代价的Redis既然把数据放内存就必须考虑宕机后数据恢复的问题。Redis提供了两种持久化方式RDB快照和AOF日志。RDB是周期性把全量数据写入磁盘生成一个二进制快照文件。优点是恢复速度快文件紧凑适合备份缺点是两次快照之间的数据可能丢失。AOF是追加写日志记录每个写操作。优点是数据丢失少按fsync策略最多丢失1秒数据缺点是日志文件会越来越大重放恢复比RDB慢。生产环境常用组合是RDB做定期备份AOF做崩溃恢复两者配合使用。如果你对数据丢失容忍度低AOF的appendfsync everysec是常用配置相当于最多丢1秒数据性能影响也相对可控。5.2 fork与写时复制快照怎么做到不阻塞主线程RDB持久化时Redis会调用fork()创建子进程子进程负责把数据写入磁盘主进程继续处理请求。这里用到了操作系统的写时复制机制fork时子进程和父进程共享同一份内存页只有发生写入时才会复制对应内存页给父进程。这就是为什么RDB生成时业务不卡顿的底层原因。但要注意一个细节如果在这期间父进程收到了大量写请求每写一个内存页就要复制一份内存占用可能飙升。所以不能只看“RDB期间不阻塞”就放心大胆地频繁触发快照内存容量和写入频率都要考虑进去。5.3 到底要不要开持久化我的实践建议这个问题没有标准答案取决于业务场景。我在项目中的做法是纯缓存场景允许数据丢失可以关闭持久化或者用save 禁用RDB只开AOF以防万一。数据不能丢、但可以接受秒级丢失的场景开启AOFappendfsync everysec。数据要求极高连秒级丢失都不能接受AOF使用always但性能损耗明显需要提前压测评估。6. 实战中如何逼出Redis的极限速度6.1 基准测试与性能分析工具想理解Redis的性能边界最好自己动手测一测。Redis自带redis-benchmark工具能模拟并发请求验证吞吐量。我常用的测试方式redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 100000 -t set,get这个命令用50个并发连接发10万个请求测试SET和GET的吞吐量。输出结果会显示每秒处理的请求数和平均延迟。真实项目里我还会加-P参数测试Pipeline模式redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 100000 -t set,get -P 10-P 10表示每个管道中打包10条命令你很快就能看到吞吐量翻几倍的效果。排查实际线上问题还有几个命令要常用INFO查看内存、连接数、命中率、持久化状态等。SLOWLOG GET 10查看最近10条慢命令。MONITOR实时监控命令请求排查异常流量很有用但生产环境要谨慎使用会消耗性能。CLIENT LIST查看连接来源和分析连接数。6.2 常见性能瓶颈与排错思路实际运维中Redis性能突降通常有几个原因第一存在大Key和热Key。大Key是指单个Key的value特别大例如几MB的字符串或几十万成员的Set。大Key操作会阻塞单线程影响所有请求。热Key是指某个Key被超高频率访问单节点CPU被打满。排查大Key用redis-cli --bigkeys它能扫描出各类数据里最大的Key。热Key排查通常依赖业务日志或MONITOR采样。第二内存碎片率过高。用INFO memory看mem_fragmentation_ratio这个指标显著大于1.5说明碎片严重可能导致内存使用率超预期并触发淘汰策略。此时重启实例常能解决或者调整分配器相关的配置。第三持久化影响性能。AOF的fsync如果频率过高会导致主线程阻塞。需要注意appendfsync always对写性能影响很大一般线上不推荐。第四连接数过多导致文件描述符耗尽或者单个客户端滥用SUBSCRIBE/MONITOR占据资源。需要检查maxclients配置合理设置连接数上限客户端也要做连接池管理。6.3 配置调优的实战建议结合个人经验简单列出几条值得在生产环境验证的配置调整maxmemory和maxmemory-policy设置内存上限和淘汰策略。常用的是allkeys-lru和volatile-lru注意结合缓存是否设置过期时间来选择。tcp-backlog和timeouttcp-backlog在高并发连接场景下需要调大否则连接可能排队timeout如果设置为0可以避免空闲连接被断开但也可能占满连接数。appendfsync everysec兼顾数据安全与性能的默认选择。hash-max-ziplist-entries等压缩编码阈值如果业务以小型Hash为主可以适当调大阈值让更多小Hash保持紧凑编码节省内存。lazyfree-lazy-eviction yes等延迟释放配置开启后删除大Key时内存释放放在后台线程执行避免阻塞主线程。这些配置不是越多改越好改每项之前都要想一想业务形态是否匹配然后通过压测验证效果。一次只改一项对比前后数据才能知道改动是否有效。7. 最后分享两个小技巧再说两个我在项目中反复用到的小经验。一个是关于连接复用。很多使用Redis变慢的问题根源在于客户端频繁建立和断开连接。建议所有语言客户端都使用连接池并保持合理的空闲连接数。一次连接相比一次命令执行虽然不算特别慢但高频请求场景下连接建立的开销会让整体性能明显下降。另一个是多实例部署的建议。单机Redis能跑多实例利用多核CPU提升整体吞吐量。例如物理机8核可以启动4个Redis实例分别监听不同端口业务按key做一致性哈希分散写入。我之前在4核机器上把单实例压到性能瓶颈后通过启两个实例并分片写入整体吞吐提升了接近一倍。这个方法不改变Redis本身的单线程性能但把多核利用起来了。Redis的快本质是设计上的层层取舍内存、数据结构、IO模型、命令复杂度控制、持久化策略每个环节都奔着“减少无谓开销”这个目标去。把这些原理真的吃透之后你调优时就不会人云亦云而是能根据实际场景做决策。祝各位在生产环境里把Redis的性能吃到够别让它在你的系统里跑冤枉路。
返回列表