有人说计算机领域只有两个问题: 命名和缓存. 命名的重要性自不必说, 缓存更是广泛存在于计算机的各个层次中, 从最顶层的应用层缓存到最底层的CPU缓存, 虽然实现的方式各不相同, 但其目标都是一致的, 即使用空间换取时间.
在常规的业务逻辑开发中, 缓存也是一个重要的优化措施, 合理的设计和使用缓存能极大的提高程序的性能. 然而, 正因为缓存的使用场景广泛, 很难有一个组件能满足所有场景的需求, 几乎在每个项目中都要重新造轮子. 实际上, 由于缓存非常重要, 因此在任何一个主流编程语言中都存在海量的缓存组件可供使用, 因此在业务中, 如何实现缓存组成甚至不能算一个问题. 真正重要的问题是一个好的缓存组件应该如何设计并遵循哪些准则.
一个好的缓存组件应该满足如下的一些目标
- 可复用: 对于不同的接口不需要重复的开发
- 解决缓存关键问题: 缓存雪崩, 缓存击穿, 缓存穿透等经典问题
- 可配置缓存策略: 存储方式, 是否存储空值, 失败策略等
- 提供监控能力: 查看缓存命中率等监控指标
- 风险可控: 在满足目标的前提下组件代码应该尽量简单, 避免引入额外的复杂度
技术方案选型
选择一个缓存组件最重要的事情就是要明白需求. 产品经常会提出既要又要的需求, 但现实通常并不能满足产品的所有要求, 因此在选择技术方案前一定要和产品就最关键的技术要求达成一致. 否则当功能已经上线, 数据已经写入其中之后, 再想修改那就是开车过程中换轮胎, 既费力又不讨好.
生命周期
首先需要确认数据的生命周期, 常见类型包括
- 按需加载,带过期时间(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
版权声明:本文为原创文章,转载请注明出处