有人说计算机领域只有两个问题: 命名和缓存. 命名的重要性自不必说, 缓存更是广泛存在于计算机的各个层次中, 从最顶层的应用层缓存到最底层的CPU缓存, 虽然实现的方式各不相同, 但其目标都是一致的, 即使用空间换取时间.

在常规的业务逻辑开发中, 缓存也是一个重要的优化措施, 合理的设计和使用缓存能极大的提高程序的性能. 然而, 正因为缓存的使用场景广泛, 很难有一个组件能满足所有场景的需求, 几乎在每个项目中都要重新造轮子. 实际上, 由于缓存非常重要, 因此在任何一个主流编程语言中都存在海量的缓存组件可供使用, 因此在业务中, 如何实现缓存组成甚至不能算一个问题. 真正重要的问题是一个好的缓存组件应该如何设计并遵循哪些准则.

一个好的缓存组件应该满足如下的一些目标

  1. 可复用: 对于不同的接口不需要重复的开发
  2. 解决缓存关键问题: 缓存雪崩, 缓存击穿, 缓存穿透等经典问题
  3. 可配置缓存策略: 存储方式, 是否存储空值, 失败策略等
  4. 提供监控能力: 查看缓存命中率等监控指标
  5. 风险可控: 在满足目标的前提下组件代码应该尽量简单, 避免引入额外的复杂度

技术方案选型

选择一个缓存组件最重要的事情就是要明白需求. 产品经常会提出既要又要的需求, 但现实通常并不能满足产品的所有要求, 因此在选择技术方案前一定要和产品就最关键的技术要求达成一致. 否则当功能已经上线, 数据已经写入其中之后, 再想修改那就是开车过程中换轮胎, 既费力又不讨好.

生命周期

首先需要确认数据的生命周期, 常见类型包括

  • 按需加载,带过期时间(TTL):典型的缓存模式。数据用的时候去拿,不用就空闲回收或等到期。这需要 TTL 清理、淘汰策略等全套机制。
  • 按需加载,永不过期:数据加载后永不主动失效,但可能因为容量不足被 LRU 等策略淘汰。这需要容量控制和淘汰算法,但不需要 TTL。
  • 永不过期,由外部同步:数据一旦加载就一直有效,直到下一次显式更新。

TTL和容量淘汰虽然都是常见能力, 但实现它们需要额外的空间和代码复杂度, 因此如果不需要这些功能时, 应该选择更简单的组件.

失败偏好

失败偏好决定了在缓存未命中时或者底层数据源失效时的处理逻辑, 典型类型包括

  • 强依赖穿透模式(Cache-Aside/Read-Through):缓存没数据时,必须去数据源加载。如果数据源挂了,这个 key 的访问就失败了。缓存只是加速器,不承载“源不可用”时的兜底职责。
  • 弱依赖/离线可用模式:内存中的数据是唯一事实来源,即使数据库、网络全断,也必须能返回数据(可能是旧的)。缓存变成了一个自持的本地副本,启动时必须加载成功,更新失败要保留旧版本,绝不能因为同步异常而清空数据。

通常缓存只承担加速功能, 但某些耗时敏感或者重要性高的接口, 可能要求缓存在本地始终有效, 此时虽然这份数据依然叫做缓存, 但它实际上更接近于一个数据副本. 两者情况使用的缓存组件也完全不同.

读写比例与并发模式

读多写少会影响同步机制的选择, 典型类型包括

  • 读写均衡: 最经典的模式, 通常可以使用锁来保证原子性
  • 读多写少: 绝大部分情况下是读取请求, 极少进行写入. 此时可以考虑写时复制, 分段锁等优化机制

缓存组件很多, 因此基本上不需要手动实现以上的技术方案. 但选择的时候依然需要注意复杂度, 不要被用不上的功能把项目变得复杂.

一致性模型

一致性模型决定了能容忍多旧的数据, 越接近实时对性能的要求越高. 在这一点上产品往往会要求越快越好, 但实际的机器性能并不总能满足要求, 因此也需要仔细分析后确定.

  • 容忍较长窗口(秒级到分钟级):定时轮询全量替换就足够了,简单可靠。
  • 要求准实时(秒级以内):需要事件驱动的失效通知(如 pub/sub、消息队列),或者直接使用 write-through/behind 让缓存与源紧密耦合。
  • 要求强一致:这通常意味着你不能用“缓存”这个词,而应该走分布式事务、读写串行化等方案,这已经超出了缓存的常规边界。

性能指标

  • 平均耗时: 直接反映了缓存组件的性能情况. 由于在内存中操作, 因此一般性能都很高
  • P99耗时: 由于存在GC的抖动, P99耗时更能反应极端情况下的性能表现

对于有GC的语言, 不当实现的缓存组件可能产生较大的GC压力, 导致组件平均来看性能尚可, 但经常性的出现少量请求超时的问题. 如果接口对耗时敏感或者对失败率敏感, 则这类问题可能非常难以解决. 因此对于这类接口几乎总是要慎重的平衡能力和性能, 性能要求越高, 则代码实现的功能要越简单直接.

选项控制

缓存组件应该支持一些关键的选项控制, 以便于避免常见的缓存问题

缓存穿透(空值控制)

  • 是否允许空值
  • 空值时返回的默认值
  • 空值的过期时间

缓存雪崩(随机过期时间)

支持随机过期时间可避免大量key在同一时间过期导致的缓存雪崩. 如果采用轮询的方式更新数据, 也应该使用随机的起始时间是的请求分散.

缓存击穿(请求锁)

大量请求同时查询某一个key, 导致缓存还未建立前发送大量请求, 需要在业务逻辑测加上锁. 相较于前面两种情况, 缓存击穿不一定出现, 需要结合业务情况判断. 需要注意加锁也会引入额外的复杂度, 非必要不使用.

缓存一致性

更新策略 问题
先更新数据库, 再更新缓存 更新失败脏数据, 并发更新写入历史数据
先更新缓存, 再更新数据库 并发更新写入历史错误数据, 不建议
先删除缓存, 再更新数据库 重新载入旧数据, 可使用延迟双删除策略
先更新数据库, 再删除缓存 读取旧数据, 相对最优方案

虽然各种策略似乎都不太行, 但需要注意到缓存通常是一种可以过期的临时数据, 因此短时间内是旧数据通常可以接受, 配合过期时间自动更新即可.

最后更新: 2026年09月11日 11:53

版权声明:本文为原创文章,转载请注明出处

原始链接: https://lizec.top/2024/10/02/%E7%BC%93%E5%AD%98%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86%E4%B8%8E%E5%AE%9E%E7%8E%B0/