• 请不要在回答技术问题时复制粘贴 AI 生成的内容
imchuncai
V2EX  ›  程序员

《拒绝最终一致性的脏读陷阱: Umem-cache 强一致性实现原理》

  •  
  •   imchuncai ·
    imchuncai · 3h 21m ago · 512 views

    原文:consistency.rst

    缓存一致性是 Umem-cache 最关键的特性,缓存协议和集群的设计方案都是围绕着它展开的。 缓存一致性能减少软件开发中的复杂性,因为你不需要担心过时的数据,这是最终一致性做不到的。

    选择缓存一致性是有代价的:我们牺牲了可用性。 也就是一旦 Umem-cache 挂掉,即使你的其他服务依旧完好,整个系统也无法工作。 但是不必担心,我们有基于 raft 共识算法的集群解决方案,它可以提供很高的容错能力。

    缓存协议

    我们的缓存协议提供了 GetOrSet 和 Delete 两个命令,是的, 我们把 Get 和 Set 合并了。

    缓存一致性的实现是靠在数据库中的 Key 更新完成的瞬间,以下事件确保发生:

    • 没有任何连接正在读取 Key
    • 所有在此之前读到 Key 且未完成 Set 的连接都已被关闭
    • Umem-cache 中不存在该 Key

    下面我将详细说明:

    缓存命中

    缓存命中时,Umem-cache 直接返回数据。

    但是如果 key 因为正在 set 而被锁住了,该 get 操作会给该锁设置上过期时间,并排队等待 value 。 另外,如果锁到期了,持有锁的连接会被关闭,并把锁转交给排队中的下一个连接。

    ::

    客户端                  Umem-cache                  数据库
               get key
    |------------------------->|
                               | 1. 等待锁释放
               返回 value        |
    |<-------------------------|
    

    缓存未命中

    当缓存未命中时,我们会锁住 key ,然后尝试 set ,其他 get 请求会排队等待,这种机制是为了防止缓存击穿。

    你可能发现了,Umem-cache 不响应 Set ,这让我们省掉了一次网络传输。 因为它大概率会成功,就算失败也不是什么大事。

    ::

    客户端                  Umem-cache                  数据库
               get key
    |------------------------->|
                               | 1. 锁住 key
               返回 miss         |
    |<-------------------------|
    
                           get key
    |--------------------------------------------------->|
                                                         | 2. 等待读锁
                           返回 value                      |
    |<---------------------------------------------------|
    
    
    |------------------------->| 3. set value
                                 4. 释放 key 的锁
    

    更新或删除

    事务和锁能保证数据库在更新完成时,该 key 未被缓存,且不在缓存的路上。

    将第 4 步放在事务的最后是最佳实践,因为如果该 key 已被缓存,那么在此之前,所有的 Get 都会命中, 而不是在队列中等待。

    ::

    客户端                Umem-cache               数据库
    
    |-------------------------------------------------->| 1. 开启事务
                                                        | 2. 获取 key 的写锁
    |<--------------------------------------------------| 3. 更新或删除 key
    
          删除 key
    |-------------------->|	4. 删除 key 或关闭持有 key 锁的连接
    |<--------------------|
    
    
    |-------------------------------------------------->| 5. 提交事务
    

    集群

    集群的负载均衡靠的是客户端散列,在集群模式下,每个 key 都携带了 8 字节的版本号前缀, 这个版本号与它要散列到的成员的版本号一致。 一旦 key 散列到的成员发生变化,对应成员的版本号会增长,这个版本号是由 Raft 共识协议来维护的, 并且我们保证了所有成员的版本号都不同。 所以一旦 key 散列到的成员发生变化,旧成员上的 key 就会因为版本号的变化自动失效, 这个机制就保证了同一时间不可能有两个成员上存在相同的有效的 key 。

    集群也有一个版本号,这个版本号可以用来快速判断集群是否发生变化,这个版本号也是由 Raft 共识协议来维护的。 每次集群成员变更或者成员可用状态变更都会导致集群版本号增长。 所以集群版本号的增长也代表着集群中 Key 的散列分布发生了变化。

    我们的集群协议提供了 Leader ,Authority 和 Cluster 三个命令, 每一个缓存命令都会向集群获取执行授权,这个授权通过 Authority 命令来执行,Authority 命令会返回当前集群的版本号。

    下面是客户端实现流程:

    ::

                                -----
                                |开始|
                                -----
                                  |
                                  v
                      -------------------------
                      |随机选择服务器查询领导者信息|<----------
                      -------------------------          |
                                  |                      |
                                  v                      |
                 ----------------------------------      |
                 |向领导者查询集群信息,得到 version V1|      |
                 ----------------------------------      |
                                  |                      |
                     ---------------------------         |
                     |           并行           |         |
                     v                         v         |
     ----------------------------------      -----       |
     |向领导者请求认证权威,得到 version V2|      |缓存|       |
     ----------------------------------      -----       |
                     |                         |         |
                     ---------------------------         |
                                  |                      |
                                  v                      |
                                 / \                     |
                                /   \                    |
                               /     \                   |
                              /V1==V2 \                  |
                              \       /--------否---------
                               \     /
                                \   /
                                 \ /
                                  |
                                  是
                                  |
                                  v
                                -----
                                |结束|
                                -----
    
    9 replies  •  2026-10-27 13:19:38 +08:00
    cloudzhou
        1
    cloudzhou  
       2h 52m ago   ❤️ 1
    一直讨论类似缓存一致性问题,直到我选择了更简单的方式
    你的这个实现方式太重度了,以至于无法验证正确性

    我的方式很简单:依赖 db/redis 的一致性
    简单说:
    无论是 insert/update/delete ,都认为是变更,version + 1
    cdc 监听事件变更:
    ( version, object )变成一个事件

    cache:
    object_key_$id : {version: $version, objcet...}

    redis 更新,update if version > current
    简单高效,不阻塞更新过程,允许瞬间 cache 差异

    ---
    你这套机制,除非高一致性才需要
    但是,到了这一级别的高一致性,简单依赖 db 就好了,没必要这么复杂
    LDa
        2
    LDa  
       2h 2m ago
    @cloudzhou 确实 深有同感
    cloudzhou
        3
    cloudzhou  
       1h 38m ago
    @LDa 他这里还有一个非常难解决的问题,就是 update 然后其实隐含多个 cache 更新
    比如 update order set xxx where yyy ,导致多个 order 修改
    这时候,触发的不是单个 order 更新,是批量更新
    cdc + 版本号,解决这个问题
    imchuncai
        4
    imchuncai  
    OP
       59 mins ago
    @cloudzhou 你说的多个 cache 更新的问题是在我流程里是不存在的,因为第一步就是 select for update 去拿写锁,所有要改的都已经查出来了。
    至于你说的前面一套方案,你还要考虑很多问题,第一你的业务和 cdc 是需要对 key 的格式有约定的,这一点是需要同步的;第二数据库需要维护 version 字段,业务上 insert/update/delete 这些操作你都需要额外得去维护 version 字段,万一忘记更新就会变成永久的不一致,连最终一致性都达不到。
    其实这些都不是问题,只要你能接受短暂的不一致,只要你够小心。我的观点是如果缓存能直接提供强一致性,你不需要考虑额外的东西,业务的实现会变得简单,也不需要部署额外的 cdc ,考虑 cdc 的可用性。
    imchuncai
        5
    imchuncai  
    OP
       40 mins ago
    @cloudzhou 另外在部署集群的情况下多个 cache 更新,redis 连最终一致性都保证不了,因为 redis 不支持跨节点的 lua 脚本
    imchuncai
        6
    imchuncai  
    OP
       37 mins ago
    @cloudzhou 不好意思,我说的 redis 不支持跨节点的 lua 脚本的问题是不存在的,因为你的方案是一条一条独立更新的
    imchuncai
        7
    imchuncai  
    OP
       30 mins ago
    @cloudzhou 我的方案只有 Get 操作才会对缓存进行写入,而你的方案所有的操作都会对缓存进行写入,我的方案对热点数据更友好,冷数据的修改可能永远都不会触发写入缓存
    cloudzhou
        8
    cloudzhou  
       19 mins ago
    @imchuncai

    1. 因为第一步就是 select for update 去拿写锁,所有要改的都已经查出来了。
    你这一步,对业务的开发,已经影响很大了,不管是模式还是性能,开发肯定习惯 update/delete 一个 sql 搞定

    2. cdc 是需要对 key 的格式有约定的
    这就太简单了,${table_name}_${id},约定大于配置即可

    3. 第二数据库需要维护 version 字段
    这个 version ,可以放在 db column ,也可以是独立的 redis ,或者 kv db ,核心还是 ${table_name}_${id}: ${version}。如果是 db column ,那么天然就有,update version = version+1 ,如果其他途径,无非就是变更 version+1
    因为 cdc 是顺序的,所以 version + 1 然后设置一个最新的 table object 数据,没问题,只需要确定 version 递增,和关联表数据,没有乱序即可

    cdc 是难得的,把所有变更都纳进来的一种方式,不管从哪里更新

    cache 这玩意,就不要讲究高一致性了,raft 带来的损耗,还不如去掉缓存,把 mysql innodb cache 加大点
    imchuncai
        9
    imchuncai  
    OP
       Just Now
    @cloudzhou 如果是中途引入 redis 呢?你这种方案应该怎么操作?应该会很比较复杂吧。
    About   ·   Help   ·   Advertise   ·   Blog   ·   API   ·   FAQ   ·   Privacy   ·   Solana   ·   3814 Online   Highest 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 37ms · UTC 05:20 · PVG 13:20 · LAX 22:20 · JFK 01:20
    ♥ Do have faith in what you're doing.