后端
将 Redis 用作分布式缓存层
缓存是用一致性换延迟,而大多数缓存事故都源于忘记了这笔交换。本指南介绍在把 Redis 放到数据库前面之前,我们会考虑的模式、过期策略、故障模式和拓扑选择。
· 阅读约 5 分钟 · Starone 工程团队
在一条慢查询前面加上 Redis,是见效最快的性能改进之一,也是最容易做错的一种。第一版能跑。问题出现在后面:谁也解释不清的陈旧数据,某个热门 key 过期时被压垮的数据库,在高峰期耗尽内存的集群。
我们的出发点是:缓存是一种优化,系统必须在没有它的情况下也能运行。如果 Redis 被清空,应用应当变慢,而不是出错或不可用。下文的每一项决策,都是为了守住这条底线。
慎重选择写入模式
Cache-aside 是我们的默认选择。应用先读 Redis,未命中时回源数据库,再把结果写入对应的 key。写入时先更新数据库,然后删除该 key。删除比直接写入新值更安全,因为否则两个并发写入者可能把较旧的值留在缓存里。这里仍然存在一个很窄的竞态窗口:某个读取者恰好在删除之前加载了旧行,又在删除之后把它写了回去,这也是每个 key 仍然要带 TTL 的原因之一。
Write-through 把更新缓存作为写入路径的一部分,因此写后读是热的,并且与应用最后写入的内容一致。它的代价是每次更新多一次写入,而且会缓存可能永远不会被读取的数据。Write-behind 由缓存确认写入,再异步持久化。它的写延迟最低,但如果节点故障,可能丢失已确认的数据,所以我们只把它用于可以丢失或可以重建的数据,例如计数器和活跃度信号。
TTL、抖动与失效
TTL 表达的是一个值最多允许陈旧到什么程度,这既是技术问题,也是产品问题。商品目录也许可以容忍几分钟;权限校验也许只能容忍几秒,甚至完全不能容忍。写入时的显式失效可以缩短这个窗口,但它应当是过期机制的补充而不是替代,因为失效消息总有可能丢失。
一起写入的 key 会一起过期。如果一次部署或一个批处理作业用相同的 TTL 预热了一万个 key,它们会在同一秒全部未命中,数据库一下子承受全部负载。给每个 TTL 加上随机抖动,幅度为其长度的百分之几,就能把过期时间分散开来,而且没有任何成本。
缓存击穿及其预防
当一个计算代价高昂的热门 key 过期,大量请求同时未命中并各自重算同一个值时,就发生了缓存击穿(cache stampede)。恰恰在缓存本该保护数据库的那一刻,数据库迎来了一波完全相同的重查询。应对手段已经相当成熟,并且可以组合使用。
- Single-flight:由一个请求获取一把短时锁,例如带 NX 和过期时间的 SET,并重新计算该值;其余请求短暂等待,或返回上一个值。
- Stale-while-revalidate:让值在逻辑过期之后继续保留,并在后台刷新,这样没有任何用户请求需要为重算买单。
- 概率性提前刷新:随着过期时间临近,每个读取者都有一个较小且逐渐升高的概率去刷新,不需要任何锁就能避免硬性的过期边界。
- 负缓存:为不存在的记录保存一个短时标记,这样对缺失标识的重复查询就不会每次都打到数据库。
热 key 与大 key
Redis 在每个分片上以单线程执行命令,这让它的行为可预测,也让两种形态的 key 变得危险。热 key 被读取得太频繁,以至于一个分片饱和而集群其余部分空闲;分片帮不上忙,因为一个 key 只会落在一个 slot 里。常见的解法是在 Redis 前面加一层很小的进程内缓存,TTL 设为一两秒,或者在可以接受数据略微陈旧的场景下从副本读取。
大 key,例如包含数百万成员的 hash 或 sorted set,在被整体读取或删除时会阻塞事件循环。KEYS 之类的命令和无界的范围读取有同样的效果,应当在应用代码中禁用,改用 SCAN 和分页访问。大型结构应当拆分到多个 key 上,并用 UNLINK 删除,让内存回收在主线程之外进行。
内存上限与淘汰策略
没有内存上限的缓存迟早会被操作系统杀掉。我们会显式设置 maxmemory,为复制缓冲区和内存碎片留出余量,并根据数据的性质选择淘汰策略。
- 当实例里的所有内容都是缓存条目时,使用 allkeys-lru 或 allkeys-lfu;在少数 key 远比其他 key 热门的情况下,LFU 的表现更好。
- 当实例里既有会过期的缓存条目,又有不能被淘汰的 key 时,使用 volatile-lru 或 volatile-ttl,并接受这样的后果:一旦只剩下不过期的 key,写入就会失败。
- 当 Redis 保存的是队列、锁或限流状态,不能悄无声息地消失时,使用 noeviction;此时应用必须自行处理写入错误。
Sentinel 还是 Cluster
Sentinel 为带副本的单主节点提供高可用:它监控主节点,并在故障时提升一个副本。数据集必须能放进单个节点,所有写入也都落在同一处。对很多系统来说这已经足够,而且所有多 key 命令和事务都仍然可用。
Redis Cluster 使用 16,384 个哈希槽把数据分片到多个主节点上,从而水平扩展写入和内存。代价是,多 key 操作、事务和 Lua 脚本只有在所有 key 都哈希到同一个 slot 时才能工作,这意味着从一开始就要用 hash tag 来设计 key 的命名,并且客户端在重新分片期间必须跟随重定向。只有当数据集或写入速率超出单节点的承载能力时,我们才选择 Cluster,而不是把它当作默认方案。
缓存不是权威数据库
Redis 可以通过 RDB 快照和 AOF(append-only file)持久化到磁盘,也可以做复制。但复制是异步的,所以一次故障切换可能丢弃旧主节点已经确认的写入,而 AOF 的默认设置是每秒刷盘一次。这些特性对缓存来说很合理,对别处没有副本的数据来说则是错的。
相反的选择有时也是对的。会话状态、限流计数器和短时锁可以只存在于 Redis 中,前提是清楚丢失它们的后果:用户重新登录,限额被重置,锁过期。如果丢失数据会造成资金损失或破坏某个不变量,那么权威副本就应当放在具备持久化、同步提交能力的数据库里,Redis 只持有一份派生视图。
