后端服务缓存总结
背景
最近再思考之前做的一个项目的时候, 有遇到一个问题, 那就是多级缓存的一致性问题.
之前在更新策略里面提到了DB和缓存的一些更新时的操作, 本文探讨存在并发场景下缓/多级缓存可能存在的问题.
并发场景下的集中缓存
在绝大多数以HTTP为核心的请求情况下, 由于不同的负载均衡策略, 可能会导致有不同的请求落在集中缓存中.
<span style="font-weight: bold;" class="bold">读</span>
一个读缓存的请求结果可能有以下三种:
- 缓存命中, 且数据有效 (可以直接使用数据)
- 缓存命中, 但数据无效 (使用兜底处理 / 请求DB )
- 缓存未命中 (读DB -> 写缓存)
笔者的技术有限, 在这里提供几种方式
分布式锁
分布式锁的主要目的是为了控制到达DB的流量
在缓存失效的情况下, 多个进程同时请求DB, 在极端情况下会导致 缓存击穿, 此时使用一个分布式锁来控制并发是一个不错的选择
考虑到这个场景下, 对于分布式锁的要求是能够过滤大多数并发请求即可, 无需追求etcd
带来的强一致性控制.
如果能够获取到锁, 则请求DB并返回. 如果无法获取到锁, 则使用 (本地缓存 -> 兜底返回 -> 错误返回)
问题
-
<span style="font-weight: bold;" class="bold">在这种策略下, 可以保证对于DB的流量控制, 但是可能会导致出现同时出现较多的无效返回, 可能带来一些不好的体验?</span>
如果我们的需求是尽可能的<span style="font-weight: bold;" class="bold">返回数据</span>而不是<span style="font-weight: bold;" class="bold">返回有效数据,</span> 可以添加一个本地缓存(一定要注意<span style="font-weight: bold;" class="bold">本地缓存的TTL</span>)
-
<span style="font-weight: bold;" class="bold">如果存在非常大的流量, 这种策略会不会有问题?</span>
如果我们的分布式缓存都没法承担这种热点, 这意味着我们还需要一个本地的锁来控制
即保证在同一段时间内, 本地只有一个请求去尝试获取分布式锁.
实现可以是通过 <span style="font-weight: bold;" class="bold">实例Hash</span> 获取本地锁. 如果能够获取到锁, 则尝试请求分布式锁, 如果获取不到, 则按照 (本地缓存 -> 兜底返回 -> 错误返回)
-
<span style="font-weight: bold;" class="bold">本地缓存TTL应该怎么设置?</span>
个人建议是选择 30% - 50%的集中缓存的TTL, 在加上一些随机的偏移值(本地缓存的 10% 以内)
NO TTL
即然造成失效的源头是缓存失效, 那么只要不失效就好了
对于一些活动类需求, 设计一套完美的方案是非常浪费时间的, 因为可能你的缓存TTL都比活动时间长
在这种情况下, 我建议你直接做缓存预热 + NOTTL
在活动结束后手动删除.
问题
-
<span style="font-weight: bold;" class="bold">怎么删除?</span>
如果能够单拎出缓存实例最好, 如果不能, 那就设置一些共同的前缀, 然后再业务的低峰进行删除.
-
<span style="font-weight: bold;" class="bold">怎么不对其他业务产生影响?</span>
不要使用
*Keys*
命令, 这会直接导致线上问题!不要使用<span style="font-weight: bold;" class="bold">
***Keys***
</span>命令, 这会直接导致线上问题!**不要使用<span style="font-weight: bold;" class="bold">
***Keys***
</span>命令, 这会直接导致线上问题!<span style="font-weight: bold;" class="bold">你可以写一个脚本 使用</span>SCAN + UNLINK的<span style="font-weight: bold;" class="bold">方式来处理
这种方案的好处是能够比较好的保证不阻塞主业务逻辑 以及 不会因为大量删除Key 导致出现Slots Rebalance.
package main import ( "context" "github.com/redis/go-redis/v9" ) var c redis.Client func main() { ctx := context.Background() var cursor uint64 = 0 var keys []string for { var err error keys, cursor, err = c.Scan(ctx, cursor, "biz_prefix*", 100).Result() if err != nil { break } if len(keys) == 0 { break } c.Unlink(ctx, keys...) } }
</span>写 / 更新<span style="font-weight: bold;" class="bold">
这两种操作带来的问题其实会比较多, 原因就出在这个并发环境以及一致性保证上了.
为什么? 归根到底只有一点, 怎么保证并发环境下的一致性?
我们需要确定这个几个基本的规则:
- </span>如果DB中没有写入, 那么必不能从缓存中读取出.<span style="font-weight: bold;" class="bold">
- </span>尽可能的返回及时的内容.<span style="font-weight: bold;" class="bold">
- </span>保证对底层DB有较小的读负载.<span style="font-weight: bold;" class="bold">
有几种方案, 我们挨个分析下
更缓存 -> 更DB
</span>我根本不推荐你使用这种方案, 因为不出现问题还好, 一旦出现问题就是巨大的麻烦.<span style="font-weight: bold;" class="bold">
这种方案的优点是很快, 进程A更新后, 后续的进程可以无感知的直接使用新的缓存.
能较好的保证规则2, 规则3
但是问题是, 如果更DB失败了呢?
那么导致的结果会非常严重: 持久化出现问题 </span>相比给出一个过时的返回, 给一个错误的返回可能造成更大的风险.<span style="font-weight: bold;" class="bold">
请一定慎重!!!
删缓存 -> 更DB
这个方案听起来好像没有什么问题, 但是请不要忽略了一件事情, 在并发环境下, 一切皆有可能, 沟槽的并发.
在正常情况下, 我们的想法是删除缓存, 更新DB, 然后其他的请求过来, 通过上述的</span>读缓存操作<span style="font-weight: bold;" class="bold">获得了最新的DB, 然后DB缓存也一致, 完美!
sequenceDiagram
A ->> Cache: Delte Cache
Cache -->> A: Delte OK
A ->> DB: Update
DB ->> A: OK
B --x Cache: Load
B ->> DB: Load
DB ->> B: OK
B ->> Cache: Set Cache
但是实际情况不然
sequenceDiagram
participant A
participant Cache
participant DB
participant B
A ->> Cache: Delte Cache
Cache -->> A: Delte OK
B --x Cache: Load
B ->> DB: Load
DB ->> B: OK
A ->> DB: Update
DB ->> A: OK
B ->> Cache: Set Cache
在这种情况下, 删完缓存, 有另外一个B直接从DB获取给刷进去了, 那么就坏事儿了.
更DB -> 删缓存
这个方案是一个比较可用的方案, 先更新DB, 然后再删除缓存, 这样不就能保证一致性了吗?
正常处理如下
sequenceDiagram
participant A
participant Cache
participant DB
participant B
A ->> DB: Update
DB ->> A: OK
A ->> Cache: Delte Cache
Cache -->> A: Delte OK
B --x Cache: Load
B ->> DB: Load
DB ->> B: OK
B ->> Cache: Set Cache
但是实际可能出现一些特殊的情况: 如果删缓存失败了呢?
sequenceDiagram
participant A
participant Cache
participant DB
participant B
A ->> DB: Update
DB ->> A: OK
A ->> Cache: Delte Cache
Cache -X A: Delte Error!
B ->> Cache: Load
Cache ->> B: OK
问题
-
</span>如果没法删除缓存呢?<span style="font-weight: bold;" class="bold">
那么后果就是要等到一个缓存TTL, 才能获取到最新的数据.
-
</span>怎么改善?<span style="font-weight: bold;" class="bold">
其实缓存了一个老的数据并不是一个无法接受的事情.
我们可以通过降低TTL的方式来提高一致性, 删缓存失败的出现频率有多高呢?
另外就是如果第一次删缓存失败, 等待重试能够解决大部分问题.
延迟双删
删缓存, 更DB, 再删缓存.
示意图:
sequenceDiagram
participant A as Client A
participant Cache as Cache
participant DB as Database
participant B as Client B
A->>Cache: Delete Cache
Cache-->>A: Delete OK
A->>DB: Update Data
DB-->>A: Update OK
A->>+Cache: Delayed Delete Cache (after some time)
Cache-->>-A: Delete OK
B--xCache: Load Data
B->> DB: Load Data
DB ->>B: Return Data
B->>Cache: Set Cache
改善了 删缓存 -> 更DB的影响范围, 我们前面提到了, 如果先删除缓存在更新DB, 有可能在更新DB前, 被其他的进程给写了老的缓存.
在延迟双删的情况下:
sequenceDiagram
participant A as Client A
participant Cache as Cache
participant DB as Database
participant B as Client B
A->>Cache: Delete Cache
Cache-->>A: Delete OK
A->>DB: Update Data
DB-->>+A: Update OK
B--xCache: Load Data
B->> DB: Load Data
DB ->>B: Return Data
B->>Cache: Set Cache
A->>-Cache: Delayed Delete Cache (after some time)
Cache-->>A: Delete OK
看起来一切都很美好, 但是在极端的情况下可能出现
sequenceDiagram
participant A as Client A
participant Cache as Cache
participant DB as Database
participant B as Client B
A->>Cache: Delete Cache
Cache-->>A: Delete OK
B--xCache: Load Data
B->> DB: Load Data
DB ->>B: Return Data
B->>Cache: Set Cache
A->>DB: Update Data
DB-->>+A: Update OK
A-x-Cache: Delayed Delete Cache (after some time)
即在最坏的情况下, 可能会回退到 更DB -> 删缓存 的情况.
思考
-
</span>延迟双删改进了什么?<span style="font-weight: bold;" class="bold">
比较 删缓存 -> 更DB, 解决了中间请求导致的不一致问题
比较 更DB -> 删缓存, 因为第一次删除的存在, 保证在 (Delete缓存, Update DB] 中间只要没有请求就能保证一致性
只有在存在中间请求, 且最后一次删除失败的情况下才会出现回退到到 </span>更DB -> 删缓存<span style="font-weight: bold;" class="bold">的策略.
-
</span>延迟双删的延迟改怎么选择?**
我的建议是选择业务 2 * P99, 这只是一个经验之谈, 请尽量考虑自己的业务场景以及对不一致请求数量容忍性来考虑
延迟设置的越高, 整体一致性越好, 同时中间请求数量越多
延迟设置的越低, 中间请求数量越少, 同时越有可能出现中间请求
last SetCache
的情况, 进而导致出现数据不一致情况, 一致性破坏可能性越高.
事件驱动的缓存同步
事件驱动的缓存同步是指使用事件来触发和协调缓存与数据源之间的数据同步。
这种模式特别适合于大规模、高性能的系统架构,因为它能够减少系统组件之间的耦合,提高数据处理的效率。
实现
事件驱动的缓存同步通常涉及以下组件:
- <span style="font-weight: bold;" class="bold">事件生产者</span>:一般是基于业务操作产生事件的服务. 通常来说是MongoDB oplog, MySQL binlog (row模式)
- <span style="font-weight: bold;" class="bold">事件传递系统</span>:负责事件的传输,常用的有消息队列系统,如 Kafka、RabbitMQ、Redis pub/sub等。
- <span style="font-weight: bold;" class="bold">事件消费者</span>:订阅事件并执行缓存同步操作的服务。
实现步骤:
- <span style="font-weight: bold;" class="bold">捕获数据变更事件</span>:当数据源中的数据发生变更时,要生成一个事件。(通常使用Debezium)
- <span style="font-weight: bold;" class="bold">发布事件</span>:将捕获的事件发布到消息队列系统中。这样可以解耦生产者和消费者,提高系统的伸缩性和容错能力。
- <span style="font-weight: bold;" class="bold">处理事件</span>:实现一个或多个事件消费者,它们从消息队列中订阅并接收事件,然后根据事件内容来更新缓存中的数据。
优劣
<span style="font-weight: bold;" class="bold">优点:</span>
- <span style="font-weight: bold;" class="bold">低耦合</span>:生产者和消费者之间通过事件解耦,降低了系统间的直接依赖。
- <span style="font-weight: bold;" class="bold">高可扩展性</span>:系统各部分可以独立扩展,满足不同的性能要求。
- <span style="font-weight: bold;" class="bold">实时性</span>:可以实现接近实时的缓存更新,提高系统响应速度。
- <span style="font-weight: bold;" class="bold">容错性</span>:消息队列系统通常具有容错机制,可以在某个组件失败时继续保持系统的稳定。
<span style="font-weight: bold;" class="bold">缺点:</span>
- <span style="font-weight: bold;" class="bold">复杂性</span>:引入了额外的组件和系统复杂性,需要更多的维护工作。
- <span style="font-weight: bold;" class="bold">一致性挑战</span>:可能会出现数据不一致的情况,特别是在分布式系统中。
- <span style="font-weight: bold;" class="bold">消息积压</span>:在高负载情况下,如果处理不及时,可能会导致消息堆积。
- <span style="font-weight: bold;" class="bold">延迟波动</span>:系统的性能可能会受到网络延迟和消息队列性能的影响。
并发情况下的多级缓存
以下内容不适用
通常来说是不需要多级缓存的, 为什么?
在经典的后端场景下, 底层DB的典型QPS在1W, 缓存中间件的QPS在10W
通常来说, 很少有能够触碰到10W QPS的场景.
但是在极少数情况下, 例如鉴权/用户信息等等接口, 可能出现一些超高请求量的情况, 我个人是比较建议使用 缓存Cluster 来解决这个问题, 如果经济实力允许的话, 能够最高支撑到 200 * 10W, 但是那个时候基础设施的建设已经超出我的能力理解范围外了.
本章节指代的多级缓存是由 LocalCache -> Redis Cache -> DB 的三级, <span style="font-weight: bold;" class="bold">高性能的集中缓存, 有一层就够了.</span>
什么场景需要?
能够保证同一个Key(用户/IP)的请求都能落在一个服务实例上, 比如配置的IP 负载均衡, 或者是使用长连接的情况下, 如果一个Key是随机的落在任意实例上的, 那么此时的本地缓存是没有什么效果的.
-
超他妈的高的流量, 我的Redis顶不住了
OK, 你可以试一试, 但是我没有遇到过, 所以你就听一听就好了.
总之, 保证你的请求的内聚性.
主要问题
引入了本地缓存之后, 主要的问题就是一致性问题, 即本地缓存 -> 集中缓存 -> DB的一致性.
但是细细想来, 其实也没有那么复杂.
解决方案
集中缓存 -> DB的一致性
这里的解决方案和章节2所示的一模一样, 没有任何改动
本地缓存的一致性
读: 与集中缓存的一致性
看到这个标题, 你可能会有疑问, 为什么是本地缓存和集中缓存的一致性?
原因很简单, 因为我们通常认为本地缓存的TTL要远小于集中缓存, 如果频繁的请求DB, 则可以直接取消掉集中缓存的存在.
<span style="font-weight: bold;" class="bold">为什么?</span>
因为每个服务实例都存在一个本地缓存, 如果你设置了一个长的TTL, 你还需要集中缓存吗?
如果TTL过期了, 直接请求DB, 你还需要集中缓存吗?
OK, 总之, 我们确定了一点:
本地缓存的更新应该总是按照: 集中缓存 -> DB这样的逻辑来处理.
写: 与DB的一致性
看到这个标题, 你可能又有疑问, 为什么是本地缓存和DB的一致性? 不应该是本地缓存 -> 集中缓存(Redis)的一致性吗?
因为集中缓存不论如何都是有可能出现不一致的情况的, 这一点在上面的标题也提到了.
最准确的结果应该总是从DB中获取.
请不要忘记了一件事情, 如果是写 或 更新的情况下, 我们一定可以知道最新的数据情况, 那么此时直接绕过Redis进行更新, 会有什么问题?
答案: 不存在任何问题.
因为你的本地缓存一定是最新的内容, 本地缓存和DB的一致性一定要比 集中缓存和DB的一致性要高.
所以, 这种情况下, 你可以为写/更新 导致的缓存更新设置一个比较高的TTL.
我的建议是, 设置为读更新TTL的两倍, 具体视你的业务场景来处理
如果是一个写频率稍高的场景, 可以将TTL设置的稍小一些. 反之, 就可以设置的大一点.
总结
其实看到这儿, 你也明白了, 本地缓存的引入其实会带来非常多的问题.
维护两个缓存其实是一件不那么容易的事情, 我的建议是能不用本地缓存, 就不用本地缓存, 99.9%情况下,你不需要本地缓存.
当然, 如果基本不写入, 是一些比较静态的数据, 那么本地缓存还是非常推荐的.
一个真实的场景
背景
- 一个IM即时通信的场景, C/S维持长连接, 一个需求是用户的每个消息, 需要查看之前的历史记录.
- 用户发送消息的频率还是比较高的, 所以可以认为是一个读多写多的场景.
- 有其他的服务需要查看历史记录, 但是对一致性要求不高.
设计
- DB存储使用MongoDB, 每一个请求都会落MongoDB, 进行持久化处理
- Server端为每个Client维护一个 context, 在context中维护一个List, 用来存放历史记录
- 消息持久化之后, 异步的放入Redis List中.
分析
这就是典型的可以使用本地缓存的场景, 为什么?
因为在这中情况下, 可以最高效率的利用本地缓存, 几乎所有的本地缓存都可以使用到,而且除非断线重连.
如果不是因为其他的服务需要一个快速的而且允许不一致的数据访问方式, Redis可以直接去掉的.
总结
在多级缓存架构中,一致性问题成为了一个关键的挑战,尤其是在高并发场景下。一致性问题主要来源于缓存与数据库状态不同步的问题,以及不同级别的缓存之间状态不一致的问题。处理这些问题的策略主要围绕数据更新路径的设计,以及缓存失效和更新机制。
单级缓存一致性的解决方案
读操作
- <span style="font-weight: bold;" class="bold">分布式锁</span>:用于控制并发下对数据库的访问,以避免缓存击穿。但需注意,分布式锁可能导致大量的无效返回和流量冲击。
- <span style="font-weight: bold;" class="bold">NO TTL</span>:对于短期的高流量活动,可以采用缓存预热加上不设置TTL的方法,活动结束后手动删除缓存。
写操作
- <span style="font-weight: bold;" class="bold">更缓存 -> 更DB</span>:不推荐,因为一旦更新数据库失败,缓存中的数据将是陈旧的,造成数据不一致.
- <span style="font-weight: bold;" class="bold">删缓存 -> 更DB</span>:可能会导致在删除缓存和更新数据库之间的窗口期内,其他请求将老数据写回缓存,造成不一致.
- <span style="font-weight: bold;" class="bold">更DB -> 删缓存</span>:更为安全的方法,即使删除缓存失败,也只会导致数据短暂的不一致.
- <span style="font-weight: bold;" class="bold">延迟双删</span>:结合删缓存 -> 更DB 和 更DB -> 删缓存 的方法,通过延迟删除来尽量减少不一致窗口期的大小.
- <span style="font-weight: bold;" class="bold">事件驱动:</span> 性能强, 侵入低, 但是引入了新的组件, 可能会降低可维护性.
多级缓存一致性问题
在某些极端高流量情况下,可能需要采用多级缓存来分担集中缓存的压力。这时,除了要处理集中缓存与数据库之间的一致性问题外,还要处理本地缓存与集中缓存之间的一致性问题。
集中缓存和DB的一致性
对于集中缓存和数据库的一致性问题,解决方案与单级缓存一致性的解决方案相同。
本地缓存的一致性
- <span style="font-weight: bold;" class="bold">本地缓存与集中缓存</span>:本地缓存的TTL应该小于集中缓存的TTL,确保数据的更新能够及时反映到本地缓存中。
- <span style="font-weight: bold;" class="bold">本地缓存与DB</span>:写操作时,可以直接更新本地缓存与数据库,保证本地缓存的数据总是最新的。设置合理的TTL以确保数据一致性。
最终总结
多级缓存一致性问题的处理是一个复杂的挑战,特别是在高并发、读写频繁的背景下。每一种缓存一致性策略都有其适用场景和潜在风险。
在设计缓存更新机制时,需要全面考虑业务需求、性能和一致性的平衡。
在大多数情况下,单级缓存足矣。而在特定高流量场景下,引入本地缓存是必要的,但这需要仔细设计以确保数据一致性,并尽量减少复杂性。