Steve Sun

分布式缓存系统的设计

很久不写技术文章了。这是一篇关于 Redis 构建分布式缓存系统的总结,结合之前项目上的使用场景,做一个系统性的梳理。

下面就以我做过的商品预约平台项目作为引子,引出分布式缓存设计的一些要点。

该商品预约平台的背景如下:

  • 该系统由多个微服务组成
  • 预约的过程:用户可以选择指定门店,指定日期到店提领商品,如果对应门店和日期没有库存,则不能预约
  • 因为“预约”的是未来时刻的库存,所以门店的未来某个时间剩余库存是通过一系列公式计算得出的。这个公式比较复杂,考虑到了用户指定的日期是否在配货周期内等因素,这里省略掉细节
  • 每年节日高峰时期,用户会集中预约商品,导致服务压力骤增。又因为未来日期的库存需要动态计算的特点(比如 A 预约了 1 月 1 日的最后一件商品,B 就会无法在该日预约),不同用户的预约操作会互相影响,严重时导致数据库死锁、数据不一致等问题

基于以上背景,这个预约系统的设计必须将性能作为主要优化目标,而缓存作为性能优化的不二选择,就承担了重要职责。

识别热点数据

并不是所有数据都有必要被缓存,往往缓存的数据具有以下几个特点:

  • 读写比很高。如果写操作比读操作还多,缓存系统频繁更新会大大降低可用性
  • 是热点数据。因为内存的价格昂贵,所以按照 2-8 原则,20%热点数据才值得被缓存
  • 能够容忍短时间的不一致

结合项目需要,排除掉一些不适合缓存的数据:

  • 对于那些只读的、配置相关的数据,只需要做进程缓存(使用 Guava Cache),在服务启动时加载数据到内存就可以了
  • 尽量用 CDN 和 Nginx 静态缓存来解决大部分不常更新的资源

对于该预约项目,用户最频繁查询的数据是不同门店在不同日期下的库存数量。这类数据是缓存设计的重点照顾对象:

  • 用户选择了指定城市、指定门店后,系统会返回最近 30 天的库存信息,用户只可能修改其中一条信息。所以读写比很高
  • 库存信息是预约订单流程的必备步骤,而且是跨服务调用(预约服务 -> 库存服务)的数据,所以涉及到大量网络请求、数据库查询。

指定性能优化的指标

在即将完成业务系统开发时,我们就根据 Google SRE Books 提到的四个黄金指标,制定了监控系统性能的四个维度:

  • 请求率
  • 错误数,非 200 返回结果数量
  • 响应时间
  • 资源利用率(CPU、内存)

我们使用 Prometheus + Grafana 的组合实现监控可视化,这样每次测试人员进行压力测试时,都可以通过这些指标对系统进行调整。缓存影响最大的指标是请求率(一般用 TPS 或者 QPS)和响应时间。所以在设计缓存系统时,要不断参照这两个指标进行优化。

缓存的设计的实践

分级缓存

为了不让某一接口或者微服务的缓存失效导致其他接口或服务的并发量暴增,就要针对不同来源(数据库的表、接口等)的数据做分级缓存。比如用户在一次查询中涉及到“附近可预约门店”的查询、“活动期间不同日期剩余库存”的查询、“已预约数量“的查询,这三种查询逐层依赖后边的查询结果。

假设如果只针对库存数量做缓存,一旦这部分缓存失效,那么“附近可预约门店”的查询就会直接访问数据库查询全部门店的剩余库存来确定哪个门店可以预约。这样就导致查询库存的接口并发量骤增。所以分级缓存一定程度上缓解了缓存雪崩的问题

自动化测试 API 参数合法性

我们的 QA 通常会写自动化脚本对后端 API 做定期的扫描,检查哪些接口的数据输入、输出有不合法的类型或是数值范围。除了巩固系统的健壮性,还能帮助缓存系统抵御缓存穿透的风险。

缓存和数据库双写问题

这是一个“先淘汰缓存"还是”先写数据库“的问题。通常没有明确的最佳方法。我们采用 Cache-Aside Pattern 的方式:

  • 失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从 cache 中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

缺点:可能有小概率脏数据。比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。

考虑到写操作通常比读操作时间更长,所以 Cache-Aside Pattern 中的脏数据概率非常小,即便发生,用户在实际下单时系统仍然会去数据库里做数据校验,不会影响业务数据的正确性。

如果对缓存一致性有更高要求,可以采用延时双删策略或异步更新缓存。不过这两种方式本质都是用一定程度的串行化操作来解决并发造成的问题。

预热

预加载热点数据时需要注意的点是,要考虑好服务一旦重启或是生产环境发生事故,要避免服务重启后再次造成二次事故。

缓存系统常见的问题和应对思路

首先要保证应用服务能做好熔断、限流、降级的措施。然后再针对不同情况做应对处理。

缓存雪崩

原因:热点缓存数据批量过期,导致大量缓存失效。

解决思路:

  • 错开过期时间、随机过期时间
  • 构建多级缓存
  • 避免热点数据频繁淘汰(如修改 Redis 淘汰策略为 LRU)
  • 必要时限流、降级

缓存击穿

原因:热点 Key 突然过期。

解决思路:

  • 设计系统时针对性预防措施,比如热点 Key 的更新策略不依据时间,而是程序控制
  • 配合监控和后台调整,保证高峰时段热点 key 可用
  • 必要时限流、降级

缓存穿透

原因:黑客通过访问缓存中不存在的数据,将大量请求直达数据库。

解决思路:

  • 监控报警
  • 在服务层做好熔断

小结

在设计缓存系统时优先排除掉大部分不需要缓存或者通过进程本地缓存的数据。搭建合理的监控手段,自动化测试框架,再结合预热、缓存淘汰策略、双写策略等最佳实践方法,不断优化缓存性能。

尤其要注意缓存的集中常见问题:雪崩、击穿和穿透。做好应用服务的熔断、降级、限流措施,保证在事故发生时针对每种情况都有预案。