缓存是万恶之源

缓存在降低延迟和负载方面非常有效 , 但它也引入了一些正确性相关的问题 , 本文介绍了如何避免常见缓存问题的方法 。
缓存是万恶之源文章插图
插图来自 Jeremy Nguyen/ 艺术指导 Sarah Kislak
缓存的实践在降低延迟和负载方面是有效的 , 但它引入了一些糟糕的正确性相关问题 。 这几乎是一个自然规律 , 一旦你引入了反规范化 , 它会偏离真理的源头就是迟早的事了 。 缓存的瞬态性使得问题很难调试 , 并且使问题变得更加神秘 。 这就是说 , 如果你可以在没有缓存的情况下承受性能和负载 , 那么出于对世界上所有美好事物的热爱 , 就不要添加它了 。 不过 , 在某些情况下 , 你的客户无法忍受较长的延迟 , 并且你的记录系统也无法承受相应的负载 , 那你就要不得不与缓存“恶魔”(你认为memcached中的“ d”代表什么意思)达成协议了 。
缓存是万恶之源文章插图
在 Box , 我们与“恶魔”发生过冲突 , 为了驯服它 , 我们依赖了许多业界众所周知的策略以及一些技巧 , 我们很乐意为社区工具带贡献一些力量 。 由于缓存最常用于优化重读环境中的延迟和负载 , 因此在本文中 , 我们将避免直写缓存的变化 , 而将重点放在读取时填充缓存上 。
缓存是万恶之源文章插图
在较高的层次上 , 如果需要的话 , 读操作在从记录系统中读取值之前 , 会先在缓存中查找该值 。
缓存在未命中时进行填充 。 写操作负责使陈旧缓存值失效 。
正如计算机科学的一句谚语所言 , 缓存失效是最困难的部分 。要弄清楚给定的记录系统突变会使哪些缓存键过时 , 通常不是一件容易的事情 。 尽管这可能非常繁琐 , 但是相对而言 , 它比较容易重现和测试 。 另一方面 , 与并发相关的缓存一致性问题要微妙得多 。 熟悉分布式系统的读者可能会注意到 , 在上述缓存系统中可能会出现的这样的几个问题:

  • 在高流量的读取情况下 , 写操作(从而导致缓存值失效)会导致大量的读冲击记录系统 , 以将值重新加载到缓存中 。
  • 并发读写操作会导致陈旧值无限期地存储在缓存中 。 考虑如下的操作顺序 , 例如:

缓存是万恶之源文章插图
这些步骤的序列化会在缓存中产生一个持久的陈旧值:在写操作更改记录系统并使受影响的缓存值失效之前 , 读操作会先写入它所读取的值 。
【缓存是万恶之源】上述两个并发问题的规范解决方案是由 2013 年 Facebook 的一篇著名的论文《在 Facebook 弹性伸缩 Memcache》中提出的 。 引入“租约”(“lease”)的概念 , 并将其作为每个缓存的键锁 , 以防止突发流量和陈旧值集 。 它依赖于通用缓存系统的两个操作:
  • atomic_add(key , value):当且仅当key尚未设置时 , 才为key设置所提供的值 。 否则 , 操作失败 。 在 Memcached 中 , 它被实现为add , 而在 Redis 中则被实现为SETNX 。
  • atomic_check_and_set(key , expected_value , new_value):当且仅当key刚好与expected_value关联时 , 才为所提供的key设置new_value 。 在 Memcached 中 , 它被实现为cas 。 不幸的是(也令人惊讶的是) , Redis 中没有具有这样语义的命令 , 但是可以通过一个简单的 Lua 脚本来弥补这一功能的不足 。
有了这些概念 , 我们的读操作实现可以修改成如下形式:
缓存是万恶之源文章插图
读操作实现的修正 , 以防突发流量和陈旧值保护 。
这种方法能使缓存有效地保护记录系统以免遭突发流量的冲击 。 在缓存未命中的情况下 , 只有一个幸运的请求能够成功添加租约并与真实源交互 , 而其他请求将被降级为轮询租约 , 直到幸运的请求将其计算出来的值填充完缓存为止 。
这种机制还可以保护我们免受上述竞争状况的影响 。 当记录系统发生突变 , 并且读操作从真实源中获取数据并将其放入缓存的过程中产生了缓存无效 , 就会发生缓存中毒 。 该模型可以防止读操作毒害缓存 , 因为如果写操作更改了缓存下面的记录 , 则其原子检查和设置就会失败 。
缓存是万恶之源文章插图
尽管它暂时感到困惑 , 但不幸的是 , 缓存“恶魔”确实有更多的锦囊妙计 。 考虑这样一个用例:数据始终被频繁地读取 , 但是部分数据也会被频繁地周期性写入: