Redis(上):缓存用法与单线程模型的限制

📅
2 分钟阅读
·

系列目录

  1. MySQL 索引与慢查询:B+ 树如何减少扫描
  2. 声明式事务之下:InnoDB 的 MVCC 与锁
  3. 接入层:Nginx 反向代理与 OpenResty 的边界
  4. Web 容器与 Netty:线程模型之下的 IO 模型
  5. Redis(上):缓存用法与单线程模型的限制(本篇)

缓存整体变慢的那次排查

Tair 缓存的穿透、雪崩与可用性)记录了一次缓存热点 key 失效导致数据库被打满的事故,以及穿透、雪崩、热点三类问题的处理方法。那篇讨论缓存位于数据库之前时,失效会如何导致请求回源。本篇讨论 Redis 本身的行为:它为什么快,以及使用时需要满足哪些条件。

2016 那篇从缓存失效后的回源问题出发,排查路径是「缓存命中率归零 → 数据库 QPS 飙升」,处理方式是给 TTL 加抖动、热点 key 互斥重建。本文讨论另一种变慢:命中率正常、数据库压力正常,但缓存接口的 RT 整体升高,所有使用这个 Redis 实例的业务都受影响。

排查从慢日志开始。Redis 通过 SLOWLOG 记录执行时间超过阈值的命令,默认阈值为 10ms。实例整体 RT 抖动时,SLOWLOG GET 反复出现一条针对大 Hash 的 HGETALL。这个 Hash 存放聚合后的展示数据,字段数随业务增长到较大规模(具体数字按惯例不公开)。HGETALL 一次性取出全部字段,单次执行时间为几十毫秒。在 Redis 的单线程命令处理模型中,这段执行时间会阻塞其他命令。

# 查看慢日志
SLOWLOG GET 10

# 临时调高阈值便于捕获
CONFIG SET slowlog-log-slower-than 20000  # 单位微秒,20ms

处理方式是将 HGETALL 改为 HMGET,只取需要的字段,并评估这个大 Hash 是否需要拆分或更换存储。该问题说明,Redis 的单线程模型中,一条慢命令会拖慢整个实例,而非仅影响发起请求的连接。

单线程与 epoll 如何处理连接和慢命令

Redis 的性能依赖三个条件:数据保存在内存中、命令由单线程处理、IO 使用 epoll 多路复用。这些条件共同使一个线程能够处理大量连接。

内存操作是基础。一次内存读取通常为纳秒级,一次磁盘寻道为毫秒级,两者相差五六个数量级。Redis 将数据保存在内存中,每条命令的操作成本主要是内存访问和数据结构的计算复杂度,通常在微秒级完成。这是它相对磁盘数据库延迟较低的主要原因。

单线程是第二个条件。命令在一个线程中串行执行,避免了命令处理阶段的锁竞争、上下文切换和并发同步。多数命令本身具有原子性,调用方无需自行加锁。多线程数据库客户端通常需要额外机制来提供这类保证。

epoll 多路复用是第三个条件。epoll 由内核通知用户态哪些连接有数据可读,线程只处理就绪连接,不会在某一个连接上等待。Web 容器与 Netty:线程模型之下的 IO 模型讲过 Netty 的 Reactor 模型,二者都通过少量线程和事件循环处理大量连接。Netty 的事件循环执行业务 handler 时,业务代码可自行决定是否阻塞;Redis 则在同一线程中处理命令和 IO,命令之间不能并行执行。

单线程事件循环正常处理与慢命令阻塞的对照

在命令执行时间较短的前提下,Redis 可以处理较高 QPS,瓶颈通常是内存容量和网络带宽,CPU 较少成为瓶颈。如果一台 Redis 实例的 CPU 跑满,应先排查执行时间较长的命令;在内存数据库中,单纯由 QPS 导致 CPU 不足的情况较少。

单线程也是主要限制。任何执行时间较长的命令都会独占处理线程,期间整个实例无法响应其他命令。O(1) 和 O(log N) 的命令通常在微秒级完成;O(N) 命令在 N 较大时会阻塞实例。KEYS * 扫全库、大 Hash 的 HGETALL、大 List 的 LRANGE 0 -1、大 Set 的 SMEMBERS、大 key 的 DEL,这些命令的执行时间与数据量线性相关,常导致线上实例变慢。

这次排查中的 HGETALL 是典型的 O(N) 命令。它执行的几十毫秒内,该实例上其他连接的命令都会排队,因此实例整体 RT 会升高。

数据结构编码随数据量切换

Redis 的数据结构实现也会影响性能。本节只说明不同编码的使用条件和影响,不展开实现细节。

Redis 的五种基本类型(String、List、Hash、Set、Sorted Set)在内部各有多种编码,按数据量自动切换。以 Hash 为例:字段数少且每个 field/value 短的时候,用 ziplist 编码;字段数多或值长的时候,切换成 hashtable。

ziplist 是一段连续内存,每个元素紧凑排列,没有指针开销。字段较少的 Hash 使用 ziplist 时,内存开销几乎只有数据本身;使用 hashtable 则需要为每个 entry 维护指针和哈希表结构,内存开销可能高于数据本身。代价是读写时需要在线性内存中扫描定位,复杂度为 O(N)。数据量较小时,这个 N 对性能影响有限;数据量增大后,扫描成本会上升。

切换到 hashtable 后,读写变成 O(1) 平均复杂度,代价是内存开销上升。这个切换由 hash-max-ziplist-entrieshash-max-ziplist-value 两个配置控制,默认值在 Redis 3.2 里分别是 512 和 64 字节。List、Set、Sorted Set 各有类似的阈值配置。

Sorted Set(有序集合)用跳表(skiplist)实现。跳表是一种多层链表结构,通过在不同层级的链表里跳跃查找,把有序集合的范围查询做到 O(log N + M)(M 是返回元素数)。Redis 选跳表而不选平衡树(如红黑树),主要原因是跳表实现简单、范围查询天然友好,找到起点后顺着最底层链表往后扫就行,红黑树做范围查询需要中序遍历,实现复杂。代价是跳表的内存开销比平衡树略高(多层指针),以及最坏情况下的复杂度保证不如平衡树严格。Redis 的取舍是拿这点内存开销换实现简单和范围查询友好。

Redis 会根据数据量自动切换编码,在小数据量时减少内存开销,在数据量增大后提高读写性能。使用者通常无需关心具体编码,但需要识别大 key:字段数过万的 Hash 无论采用何种编码,操作成本都高于几个小 Hash,并可能产生慢命令。

过期删除与内存淘汰

当 Redis 的可用内存受到限制时,需要通过过期删除或内存淘汰释放空间。

过期删除针对设置了 TTL 的 key。Redis 结合惰性删除和定期抽样。惰性删除在访问 key 时检查其是否过期,过期则删除并返回空,因此不会返回过期数据。未再次访问的过期 key 仍会占用内存。为处理这类 key,Redis 每秒执行若干次定期抽样,随机检查设置了过期时间的 key;若抽样中的过期比例超过阈值,则继续抽样并删除过期 key。这种概率清理不保证所有过期 key 立即回收,但可以控制过期 key 的总体积。

内存淘汰在内存用满时触发。maxmemory 配置上限,maxmemory-policy 配置满了之后怎么办。Redis 3.2 提供的淘汰策略:

  • noeviction:不淘汰,写入直接报错。适合当存储用。
  • allkeys-lru:从所有 key 里淘汰最近最少使用的。适合纯缓存。
  • allkeys-random:从所有 key 里随机淘汰。
  • volatile-lru:从设了过期时间的 key 里淘汰最近最少使用的。
  • volatile-random:从设了过期时间的 key 里随机淘汰。
  • volatile-ttl:从设了过期时间的 key 里淘汰 TTL 最短的。

allkeys-*volatile-* 的选择取决于数据是否可重建。纯缓存场景的数据都能从数据库重建,allkeys-lru 会在所有 key 中淘汰最近最少使用的 key。如果实例同时存放缓存和不能丢失的数据,volatile-* 会将淘汰范围限制在设置了过期时间的缓存数据内,不设过期时间的数据不会被淘汰。

选择 allkeys-* 还是 volatile-* 前,需要先确定实例承担缓存还是存储。缓存数据可丢失、可重建,可以使用覆盖全部 key 的淘汰策略;存储数据不可丢失,需要使用 noeviction,或以持久化和复制保证数据可恢复。若将不可重建的数据放入配置了 allkeys-lru 的实例,内存用满时这些数据可能被淘汰,业务未必能立即感知。

持久化:RDB 快照与 AOF 日志的保证差异

Redis 的持久化有两种方式,保证强度不同。

RDB(Redis Database)是时间点快照。Redis fork 一个子进程,子进程把内存里的数据写到磁盘上的 RDB 文件。快照是某一时刻的完整状态。故障重启时加载最近的 RDB 文件,恢复到上次快照的时间点。快照之间的写入会丢失,如果每 5 分钟做一次快照,故障时最多丢 5 分钟数据。RDB 的优势是文件紧凑、加载快,适合做备份和快速重启。

AOF(Append Only File)记录每条写入命令。故障重启时重放 AOF 里的命令恢复数据。AOF 的持久化强度由 appendfsync 配置决定:

  • always:每条命令都 fsync 到磁盘。最多丢一条命令,但性能影响大。
  • everysec:每秒 fsync 一次。最多丢一秒数据,性能影响小,是多数场景的选择。
  • no:由操作系统决定何时 fsync。性能最好,故障时丢失的数据量不确定。

AOF 的持久化强度高于 RDB,但文件比 RDB 大,重启加载慢,要重放所有命令。Redis 会在 AOF 文件过大时自动重写(rewrite),把多条命令合并成等价的少量命令。

两种方式可以同时开启,重启时 Redis 优先加载 AOF(保证更完整)。同时开启的代价是磁盘 IO 和内存开销增加。

是否开启持久化及其方式取决于实例承担缓存还是存储。缓存数据可重建,可以不开持久化,重启后从数据库回填;作为存储时,需要开启 AOF 并确认 appendfsync 的配置。若将未启用持久化的缓存实例用于存储,重启后内存中的数据会全部丢失。

使用与运维检查项

  1. 区分缓存与存储。 实例角色决定是否开启持久化、采用何种淘汰策略,以及能否放入不可重建的数据。纯缓存的数据可丢、可重建,可使用 allkeys-lru,持久化可选。作为存储时,应使用 AOF everysec 或更高的持久化强度,配置 noeviction 或限制淘汰范围,并配合主从复制。
  2. 慢命令是线上变慢的常见根源。 KEYS 在生产禁用,用 SCAN 替代。大 Hash 的 HGETALL、大 List 的 LRANGE 0 -1、大 Set 的 SMEMBERS 都按需取字段或分批取。大 key 的 DEL 也是 O(N),3.2 还没有异步删除,删大 key 会阻塞实例(4.0 的 lazyfree 留给Redis(下):超出缓存用途的用法:锁、队列与排行榜)。
  3. 监控慢日志和内存。 SLOWLOG GET 定期查,阈值按业务调。INFO memoryused_memorymem_fragmentation_ratio。大 key 用 redis-cli --bigkeys 扫描识别,单个 key 的规模可以用类型自带的长度命令(HLENLLENSCARD 等)确认。
  4. 淘汰策略和数据可重建性匹配。 allkeys-lru 适合纯缓存;实例里有不可丢数据时用 volatile-*noeviction,并确认这些数据不设过期时间。
  5. 按可接受的数据丢失量选择持久化配置。 缓存可以不开持久化;作为存储时,至少使用 AOF everysec。同时开启 RDB 和 AOF 时,需要确认磁盘 IO 和内存能够承受。
  6. 分别处理大 key 和热 key。 大 key 会因慢命令拖慢实例;热 key 会将读请求集中到单个实例,形成单点压力。大 key 可拆分或更换存储;热 key 可在客户端使用本地缓存,或从多个副本读取以分散压力。
  7. 主从复制有延迟。 读写分离时,从库异步复制,写主库后立刻读从库可能读到旧值。强一致读要走主库。故障切换时从库提升为主库,期间丢失的写入等于复制延迟窗口内的数据,存储场景要确认这个窗口能否接受。

参考资料


657 字 · 63 段落
ximing

Follow onGitHub

相关文章