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

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

  •  2
     
  •   imchuncai ·
    PRO
    ·
    imchuncai · 4 days ago · 2502 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
                                -----
                                |结束|
                                -----
    
    56 replies  •  2026-10-31 15:04:42 +08:00
    cloudzhou
        1
    cloudzhou  
       4 days 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  
       4 days ago
    @cloudzhou 确实 深有同感
    cloudzhou
        3
    cloudzhou  
       4 days ago
    @LDa 他这里还有一个非常难解决的问题,就是 update 然后其实隐含多个 cache 更新
    比如 update order set xxx where yyy ,导致多个 order 修改
    这时候,触发的不是单个 order 更新,是批量更新
    cdc + 版本号,解决这个问题
    imchuncai
        4
    imchuncai  
    OP
    PRO
       4 days ago
    @cloudzhou 你说的多个 cache 更新的问题是在我流程里是不存在的,因为第一步就是 select for update 去拿写锁,所有要改的都已经查出来了。
    至于你说的前面一套方案,你还要考虑很多问题,第一你的业务和 cdc 是需要对 key 的格式有约定的,这一点是需要同步的;第二数据库需要维护 version 字段,业务上 insert/update/delete 这些操作你都需要额外得去维护 version 字段,万一忘记更新就会变成永久的不一致,连最终一致性都达不到。
    其实这些都不是问题,只要你能接受短暂的不一致,只要你够小心。我的观点是如果缓存能直接提供强一致性,你不需要考虑额外的东西,业务的实现会变得简单,也不需要部署额外的 cdc ,考虑 cdc 的可用性。
    imchuncai
        5
    imchuncai  
    OP
    PRO
       4 days ago
    @cloudzhou 另外在部署集群的情况下多个 cache 更新,redis 连最终一致性都保证不了,因为 redis 不支持跨节点的 lua 脚本
    imchuncai
        6
    imchuncai  
    OP
    PRO
       4 days ago
    @cloudzhou 不好意思,我说的 redis 不支持跨节点的 lua 脚本的问题是不存在的,因为你的方案是一条一条独立更新的
    imchuncai
        7
    imchuncai  
    OP
    PRO
       4 days ago
    @cloudzhou 我的方案只有 Get 操作才会对缓存进行写入,而你的方案所有的操作都会对缓存进行写入,我的方案对热点数据更友好,冷数据的修改可能永远都不会触发写入缓存
    cloudzhou
        8
    cloudzhou  
       4 days 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
    PRO
       4 days ago
    @cloudzhou 如果是中途引入 redis 呢?你这种方案应该怎么操作?应该会很比较复杂吧。
    imchuncai
        10
    imchuncai  
    OP
    PRO
       4 days ago
    @cloudzhou 还有个一理论上存在比较极限的一个情况,比中途引入 redis 更极限,会破坏最终一致性:
    1. 连接 1 cash miss ,get 数据库 version = 1, set redis 还没发射请求
    2. 连接 2 更新数据库 version = 2, cdc set redis version = 2 成功
    3. redis 因为缓存压力驱逐了该 key ,在中途迁移的情况是,大量 key 的写入,导致了该 key 的驱逐
    4. 连接 1 停止卡顿,set version = 1
    cloudzhou
        11
    cloudzhou  
       4 days ago
    @imchuncai

    Get 操作才会对缓存进行写入
    你这里我看不懂,Get 有 cache 就直接拿,不要等 set 结束了,cache 怕的是持久不刷新,不是这种场景

    key 的驱逐这个情况,是不考虑的,这要考虑,就 Keyspace Notifications 订阅,然后 row 数据重放一下就行了,version 已经自带。

    我这个方案,其实核心差异是:version 是否是 db 自带,各有优劣
    version db 自带,所有问题都非常简单,任意重放 row 数据即可,除了 update/delete 需要注意

    如果 db 不自带,这时候 version 来自外部,尤其要考虑到 cache miss ,就有些复杂度

    但是如果使用你这个方案,那问题就非常简单了:
    1. cache miss 触发一个 miss 事件
    2. 在补充 cache 的时候,设置 lock key 周期,单位时间 lock time +1
    3. 补充 cache ,检查 lock key 没有其他写操作,也就是 lock time 依然是 1 ,说明补充 cache 成功
    4. 如果 lock time 不等于 1 ,说明有其他写操作,设置 next try 重试
    5. 其他写操作,lock time +1

    这个逻辑和你的 缓存未命中 是很想的
    就是说,你的方案,在解决 db 没有 version 情况下,我是可以用的

    ---
    cache 一定要做到,读没有代价,写稍微带来代价,尽量异步,允许缓存不一致窗口,但是不要永久缓存
    cdc 这种后台订阅,事件驱动,刷新 cache 的方案,对读写都是没啥代价的
    imchuncai
        12
    imchuncai  
    OP
    PRO
       4 days ago
    @cloudzhou 你可能没理解我 10 楼说的情况,我说的就是 db 有 version 列的情况下,没法达到最终一致。我画个图:
    ::

    客户端 redis 数据库
    1.客户端 A:get key
    |------------------------->|
    | 2.未命中
    3.返回 miss |
    |<-------------------------|

    4.客户端 A:get key
    |--------------------------------------------->| 5. version = 1
    |<---------------------------------------------|

    6. 客户端 B:update key
    |---------------------------------------------->|
    | 7. 更新成功,version = 2
    |<----------------------------------------------|

    8.CDC:set key
    |------------------------->| 9.set value, version = 2
    |<-------------------------|

    10.redis 因为压力导致驱逐了 key

    11.客户端 A: 因为 miss 触发 set key
    |------------------------->| version = 1 (因为 version = 2 的条目被驱逐了)
    |<-------------------------|

    最终 key 的 version 在 redis 为 1
    cloudzhou
        13
    cloudzhou  
       4 days ago
    @imchuncai 没错,我是理解你这个意思,和你的图一样的,核心无非是高版本被驱逐,低版本自然就占据

    8.CDC:set key ,这时候是不是已经设置到 redis ,然后又被驱逐,然后 version = 1 设置进去,对吧

    1. redis 驱逐本身不合理,意味着 cache 有丢失
    2. 我们监听 redis Keyspace Notifications ,驱逐事件,根据 id 重新读取 version = 2 数据进行重放,覆盖更新 cache
    imchuncai
        14
    imchuncai  
    OP
    PRO
       4 days ago
    @cloudzhou 监听 redis Keyspace Notifications 是改不了现状的,只是延长了步骤 10 的时间,只要二次驱逐发生在步骤 11 之前,就没有意义,因为步骤 11 的 version 是在第 5 步拿到的。引入 redis Keyspace Notifications 只是把极限的情况拉到更加极限罢了,此时业务又在不知不觉中变复杂了。另外 Umem-cache 是一个缓存,它有内存上限,如果把 Redis 当作数据库用而不是缓存,我们的讨论没有意义,因为此时 Umem-cache 和 Redis 就是截然不同的东西。而在把 Redis 当作数据库使用的情况下,再引入 DB 和 CDC 我感觉也没什么意义了。
    imchuncai
        15
    imchuncai  
    OP
    PRO
       4 days ago
    @cloudzhou 如果 key 被驱逐了就重新 Set ,只会反复触发驱逐。
    cloudzhou
        16
    cloudzhou  
       4 days ago
    @imchuncai 你有没有想过驱逐这种情况,接下去所有的 update 都可能是失败的

    你的设计,无非只是用 raft 做一个全局 lock 而已,lock 成功,当时写失败(驱逐)
    cache 一样长期都是失败的

    缓存讨论驱逐,几乎没有意义的,时序完全无法保证
    cloudzhou
        17
    cloudzhou  
       4 days ago
    @imchuncai 读写 cache 失败,导致的并发问题,vs cache 逻辑的设计,是两个问题
    imchuncai
        18
    imchuncai  
    OP
    PRO
       4 days ago
    @cloudzhou 在我的步骤里读写 cache 没有失败,只是有延迟。缓存最重要的就是驱逐的设计,而数据库是没有驱逐的。
    cloudzhou
        19
    cloudzhou  
       4 days ago
    @imchuncai 你这里根本没有涉及到真实的 cache 存储啊,除非你存储到内存缓存?你要是接上 redis 或者任何远程 cache ,这写 cache 不就是会出现吗?还是说你用 raft 作为 cache store ?
    cloudzhou
        20
    cloudzhou  
       4 days ago
    我大概理解你,你这里就充当了 cache 的角色,通过 rpc 然后触发 cache 读取/更新,存储 cache 的就是你这服务,通过 raft lock 的机制,保证了读写的时序和并发安全。

    然而,你这个机制略显尴尬:

    1. to c 端,大流量大并发,几乎不可用,raft 用来保证集群仲裁而已,连 kv 都谨慎使用,没见过谁敢接 c 端请求的。讲究啥一致性,cache 周期一过能刷最新值就好了

    2. to b 端,尤其是银行等强一致性,db 走起,硬件堆上,让 db 自己做好 cache 缓存就好了

    你这套机制,适用场景,非常少。
    imchuncai
        21
    imchuncai  
    OP
    PRO
       4 days ago
    @cloudzhou Umem-cahce 是 Redis 作为缓存的替代品,我设计它的初衷就是提供强一致性,这是 Memcached 和 Redis 都做不到的
    valord577
        22
    valord577  
       3 days ago
    如果是推广的话 技术思路值得学习; 经历过电商库存超售的事件 我还是会选择一楼的方案 `到了这一级别的高一致性,简单依赖 db 就好了` 只依赖 db 记录的库存数 宁可查询慢一点
    imchuncai
        23
    imchuncai  
    OP
    PRO
       3 days ago
    @valord577 不明白你说的,缓存只是加速查询。更新本来就是只依赖 db ,也就是 select for update , 是绝不可能从缓存查数据的。
    valord577
        24
    valord577  
       3 days ago
    @imchuncai 你要推广一个库或者一个组件 你得解决实际的业务问题。在什么业务 什么场景下 用你的技术方案解决了 并且对比竞品有什么更好点 这样才能推广地更加顺利。

    看了下你和 @cloudzhou 的对话 (纯个人主观结论): cloudzhou 业务经验很丰富 他在用实际业务经验说明什么技术方案最合适 尤其是 20 楼的描述 很符合行业的解决方案; 而 op 你的描述给我的感觉是: 你只看重技术本身 而没有结合业务。

    所以我说 你的代码 只值得学习技术思路 做不了业务解决方案
    cloudzhou
        25
    cloudzhou  
       3 days ago
    @valord577 作为技术方案探讨,也很不错了,毕竟,ai 时代,已经不提技术了 :~

    他这套方案,有应用场景,但不是 cache ,而是分布式 lock
    lock 要做细腻,也有很多细节可思考:
    1. 乐观/悲观,读/写锁,最高并发锁?令牌桶?(问号概念是我拍脑袋的)
    2. 锁周期,不能导致资源长期不可用
    3. 顺序,以及锁关联的 do once
    等等

    借助 raft 机制下,做一个全局的分布锁 lib ,用在敏感领域,大家用起来如同本地 lock

    @imchuncai
    昨天有几个问题没有回答:

    Q: 而你的方案所有的操作都会对缓存进行写入,我的方案对热点数据更友好,冷数据的修改可能永远都不会触发写入缓存
    A: 其实这是策略问题,我收到 update 事件,也可以根据是否在缓存里然后更新,缓存依然是按需的

    Q: 驱逐的问题
    A: 依然是策略问题
    - 可以根据 ttl 尽量避免驱逐(现实中采取方案,因为 cache 驱逐是非常不好的行为,无法确定驱逐的数据是否重要)
    - 在驱逐情况下,你提到的并发问题,可以根据监听 redis Keyspace Notifications 然后一个延迟周期,只更新 cache 已有的 key ,这样避免持久相互驱逐,同时更新并发竞态数据
    imchuncai
        26
    imchuncai  
    OP
    PRO
       3 days ago
    @cloudzhou 酒逢知己千杯少 这是我写的 Umem-cache: https://github.com/imchuncai/umem-cache
    第一个问题:你说的是对的,我昨天也是想过了,确实是策略问题,CDC 甚至可以直接选择 delete 操作,就跟我的方案一样了

    第二个问题:我设计 Umem-cache 是直接不支持 ttl ,我认为这很烦人,很多时候我根本就不知道要设多久。我是统一采用了 S3-FIFO 算法,按热度自动淘汰,我的测试中是显示命中率比 Memcached 高出 10%,比 redis 的 lfu 策略高出更多,我的测试项目也是在 github 开源的。然后你提到的驱逐情况下,只更新 cache 已有的 key ,这其实相当于就是什么也不做,因为监听的就是驱逐事件,此时 key 已经不在 redis 了。在我所列的流程里,就是 key 不在 redis 会导致旧数据被缓存起来,导致没法实现最终一致,它有些极端但是理论上存在。

    至于锁周期只在更新的时候存在,而能被放在缓存的数据应该是读得多极少要改动的,不然完全是不应该缓存起来的。

    最后是集群问题,我也是有意在这避而不谈,因为 raft 也是很复杂,特别是它是为持久化集群设计的,我把它引入到缓存是要做一些适配,讨论起来我不知从何说起,评论区会变得混乱。因为我是深入了解过 raft ,在我的项目里也是有文档说明了我对 raft 存在的不足之处的一些见解,甚至我都没法翻译到中文来,专业术语太多了。我所有的设计都是为了一个最终的目标,就是简单性,我提供极少的配置,然后我也不想用户去引入其他的系统去为一致性和缓存击穿擦屁股,防缓存击穿在我这套方案里也是浑然天成的,不需要额外的语义。
    imchuncai
        27
    imchuncai  
    OP
    PRO
       2 days ago
    @cloudzhou
    第一个问题:我又想了下,不 set 会有更严重的并发问题,相当于此时没法用 version 去避免覆盖了
    1. 客户端 get key ,miss ,查数据库 version=1
    2. 数据库更新,version=2
    3. CDC 触发更新,redis 不存在该 key,所以不更新
    4. 第 1 步在查询完成后 set redis version = 1

    第二个问题:监听驱逐事件会触发数据库的读取,会增加数据库压力。
    cloudzhou
        28
    cloudzhou  
       2 days ago
    @imchuncai

    Q 不 set 会有更严重的并发问题
    A 你这个问题,和监听驱逐事件是一个类型,就是你说的竞争态:
    “在短时间,高版本事件的不设置(不管是驱逐还是没有不处理 update 事件到 cache ),导致低版本进行覆盖”

    所以这里我有一个简单处理方案:
    *** 引入一个延迟周期,只更新 cache 已有的 key ,这样避免持久相互驱逐,同时更新并发竞态数据 ***
    等于你的 3. CDC 触发更新,redis 不存在该 key,所以不更新,这一步,是有一个延迟周期的,确保 “理论上” 覆盖 1 ,4 这个 T 时间周期

    一句话总结,按需写 cache 操作(驱逐、更新触发),延迟一个周期,以便覆盖可能历史低版本(读触发)

    ---

    当然,这不是真正并发安全。
    但是如果真要做到一致性,这个是好做的。逻辑如你实现方案
    你看:
    version=1 ,事件 1 ,4 ,是一个事务
    version=2 ,事件 2 ,3 ,是一个事务

    这两个事务都是同一个 data ,在这里面做分布式锁就可以了

    ---

    更简单的工程实现:
    触发事件然后缓存集中处理,也就是没有并发态:
    1. 读触发 cache 更新,触发 event: (read, table, id)
    2. 驱逐、写触发 cache 可能更新,触发 event: (write/volatile, table, id)
    3. kafka 集中监控,按照 table 分区,同一个 table 事件顺序处理
    4. 无论是 1 ,2 事件,从 db 拉去新数据,read 操作直接 cas 更新 cache ,write/volatile 一样,cas and only exists

    mysql 在 select by id 这一块,速度高的超过你估计,这些事件都意味着对应 table, id 刚访问过,再次读取都只是从 db 的 cache 获取
    无论是 ttl ,volatile 都可控

    你看这个实际的工程事件:
    1. 无论读写,对业务都没任何影响,分发事件可以搞个异步,goroutine channel with max length ,消费不了直接丢弃
    2. cache 动态可控,消费点做逻辑,无论是 ttl 还是 update 事件是否进入 cache
    imchuncai
        29
    imchuncai  
    OP
    PRO
       2 days ago
    @cloudzhou 你给的方案太多了,我有点绕不过来了,而且越来越复杂,引入的组件越来越多。
    zzhirong
        30
    zzhirong  
       1 day ago
    @imchuncai 强一致本身就很难实现,你给出的方法大体可行,代价就是丢失了性能,读写都需要锁,几乎等同于串行,如果涉及到集群,就更复杂了。

    @cloudzhou #1 基于 Redis 给出的最终一致性(允许短暂的不一致)方案,即便在这个基础上再做一些修改,还是无法保证强一致性,优点就是性能更好。
    imchuncai
        31
    imchuncai  
    OP
    PRO
       1 day ago
    @zzhirong 绝大部分的读都是缓存命中,不会涉及数据库的锁。然后修改数据,数据库先持有写锁也很必要,能避免并发的修改,这一点不管你用不用缓存都是要这样操作的。

    然后我跟 @cloudzhou 讨论的内容是不涉及强一致性的,他的修改是由 CDC 提交到 redis 的,强一致是不可能达成的。我一直要表的的观点是他的方案保证不了最终一致。
    cloudzhou
        32
    cloudzhou  
       1 day ago
    @imchuncai
    我的强一致是不可能达成这是对的,包括短期的不一致,但是我这个方案,是可以做到最终一致的,你可以看我的 append

    数据库持有写锁,和 raft 的锁,差距极大,数据库性能其实比我们想象高很多,还有锁范围也是不同。
    反而是 raft ,涉及到多点协商一致,这个 qps 非常低的,etcd 早期都被诟骂,因为有人把它当 kv 用。
    raft 作为选举、仲裁大脑,不适合做高 qps 的行为。有很多测评,raft 集群 qps 低于单体 mysql 。

    所以我说你这个方案,本质没问题,但是不应该用在 cache 。
    cache 这玩意是为了什么,无非就是快,而你把一个 raft 包一下,这,快不起来的。
    imchuncai
        33
    imchuncai  
    OP
    PRO
       1 day ago
    @cloudzhou
    1. 你的延迟周期我是不认同的,它是很侥幸的,没有任何保证的。

    2. 我不能同意你说的 mysql 在 select by id 这一块,速度高的,因为我一旦同意这个,缓存就没有存在的意义了。

    3. Umem-cache 和 etcd 是不一样的东西。etcd 的本质是持久化的数据库,它的集群中的每一个节点都存相同的数据,它的命令都是要靠 leader 来执行,它要求多数节点完成执行,而且这个执行是要确定持久化到磁盘上,它碰磁盘,它自然慢。而 Umem-cache 是绝不会碰磁盘的,Umem-cache 的集群的每个节点存的也是完全不相同的数据,资源的利用率是很高的。

    4. 我的 raft 设计跟 etcd 是不一样的,这是得益于缓存的特性:允许少数据。在集群中执行的每个命令要向领导者请求认证权威,这个操作其实就是 leader 向集群发一次 heartbeat ,不需要携带业务数据,只要多数节点响应,我们就可以确认 leader 的权威。而且这步权威认证我是支持多个命令打包一起确认的。然后缓存命令也就是 GetOrSet 和 Delete 是可以异步执行的因为客户端可以缓存集群的状态,而且集群大多数情况下是稳定的。集群发生变更的情况下,重新执行就行了。缓存命令的压力是分散到集群每个节点的,效率不是 leader 统一处理可以比的。

    5. 我的 raft 是没有锁的,你关于 raft 锁的说法我不明白。在集群中,锁的情况和单节点是一样的,都是在节点上的。

    6. 关于集群的测试我还没做,现在还没条件做。所以我没有数据去分析 qps ,将来有测试结果了我会再发帖的。
    cloudzhou
        34
    cloudzhou  
       1 day ago
    @imchuncai

    1. 我的 append 还有两个实现,那个是解决最终一致
    2. mysql by id 是真的快,尤其是已经在 innodb cache ,问题是 db 很宝贵,让 db 做事务严格一致的行为
    3. etcd 只是一个例子,因为他们就是 raft 协议,只要你要 raft 就存在多节点协商,其实你这个 case ,没有用 raft 的需要
    4. raft 所谓的锁,等于一个写指令,对你这里来说也等于多点协商
    5. qps 就是最重要的事情,衡量一个技术方案是否可行,对 qps 要有一个非常直观的了解,经典的 Latency Numbers Every Programmer Should Know
    https://gist.github.com/jboner/2841832
    同样可扩展到对于 raft 、redis 、mysql 读写的 qps 的时延衡量
    zzhirong
        35
    zzhirong  
       1 day ago
    @imchuncai 看具体业务吧,“读都是命中缓存”只适合读多写少的情况,但在更新和删除场景,你的方案也无法保证强一致性,因为数据库的事务作用域无法跨越到外部缓存。比如在更新流程中,如果客户端在删除了缓存之后、提交数据库事务之前发生异常崩溃或回滚,此时数据库数据未变,但外部缓存已经被改了/删了。

    你认为 cloudzhou 给出的方案会在缓存驱逐时无法达到最终一致,实际这种情况只有在非常极端情况才会发生(缓存空间太小,导致刚更新的缓存立马遭到驱逐),退一步来讲,如果 key_v2 真遭到驱逐,key_v1 被缓存成功,给 key_v1 设置一个可以最大可以忍受不一致的过期时间就可以了,最终一致性还是可以达到的。
    imchuncai
        36
    imchuncai  
    OP
    PRO
       1 day ago
    @zzhirong 我是保证强一致的。第一,Umem-cache 不提供修改的选项,更新和删除在 Umem-cache 都是删除
    第二,提交数据库事务之前发生异常崩溃或回滚,Umem-cache 的数据最多是被删掉了,不影响一致性。

    你这个过期时间确实可以达到最终一致,这跟定时清空缓存差不多,只要缓存没数据,就是一致的。你一旦提供 ttl ,你就没法处理用户会续期的行为,只要用户持续续期,这个不一致就会影响很久。

    @cloudzhou 你可能要重新描述以下整体的实现流程,我才能跟上你的解决最终一致性的思路。然后,我这下理解你说的 raft 锁了,我的 heartbeat 其实就是相当于一个写指令,只不过是什么都没写。

    我是有用 raft 的需要的,因为我需要协商集群的状态,也就是当前是哪些节点组成了整个集群。集群的 qps 我目前是还没有设备去测,但是就单节点的性能我已经是碾压 Redis 了,集群我是有信心的。
    cloudzhou
        37
    cloudzhou  
       1 day ago
    @imchuncai

    你一旦提供 ttl ,你就没法处理用户会续期的行为
    cache ttl 是没有续约概念的,只会中途刷新,或者到期消失,影响范围只有 ttl 时间

    我再次总结不出现 ttl 范围 cache 不一致的设计:

    触发事件然后缓存集中处理,也就是没有并发态:
    1. 读触发 cache 更新,触发 event: (read, table, id)
    2. 驱逐、写触发 cache 可能更新,触发 event: (write/volatile, table, id)
    3. 事件流式处理,同一个 table 事件只有一个进程顺序处理
    4. 无论是 1 ,2 事件,从 db 拉去新数据,read 操作直接 cas 更新 cache ,write/volatile 一样,cas and only exists

    重点:同一个 table 只有一个处理点,消息不管先后乱序,都是 select by id 拉最新数据和 cache 同步
    imchuncai
        38
    imchuncai  
    OP
    PRO
       1 day ago
    @cloudzhou 我的续期的意思就是 GETEX 传入相同的过期时间

    然后你的方案:
    1. 客户端 A:Get cache miss, Get DB version=1
    3. 客户端 B:Update DB version=2, Update cache version=2, 发送 event X
    5. event X 触发, Update cache version=2
    6. cache 驱逐, 发送 event Y
    7. event Y 触发,cache not exists ,什么也不做
    8. 客户端 A: Update cache version=1
    9. 客户端 A:由于网络波动也好,硬件损坏也好,发送 event Z 失败。
    cloudzhou
        39
    cloudzhou  
       1 day ago
    @imchuncai

    续期的意思就是 GETEX 传入相同的过期时间
    过期时间是否相同一点都没影响啊,下次更新就是新的数据了,我们的核心是,旧数据最多 ttl 时间内
    你的意思是每次 get 的时候,还延续 ttl ?这个我的实践上,没有这么做的

    ---
    你不能把发送 event z 失败,这个事情纳进来,正如 raft 脑裂整个系统失败了一样。
    这么说的话不用举这么复杂的例子,只要 version=1 事件设置 cache 成功,version=2 事件因为各种原因失败就行了,缓存已经不一致

    CAP 理论里,只能有两个,而 cache ,实践上都是纳入弱一致性范围。
    imchuncai
        40
    imchuncai  
    OP
    PRO
       1 day ago
    @cloudzhou 我的系统就是放弃了可用性来换取一致性,而你的方案是放弃了一致性来换取可用性。就是取舍的问题,我的观点就是你的方案最终一致也不能做到。
    cloudzhou
        41
    cloudzhou  
       1 day ago
    @imchuncai 除了你说的,“发送 event Z 失败” 这种情况,目前最终一致是可以做到的,你再看看我的方案。

    而我的意思,你不能把这个失败也纳进来讨论,可以说失去讨论意义了。
    imchuncai
        42
    imchuncai  
    OP
    PRO
       1 day ago
    @cloudzhou 在所有服务和网络都正常的情况下,你的方案是可以做到最终一致的,因为缓存变更和数据库变更的后面,都会跟一个序列化的缓存和数据库同步操作。
    imchuncai
        43
    imchuncai  
    OP
    PRO
       1 day ago
    @cloudzhou 在所有服务和网络都正常的前提下,可以简化一下你的方案,看看对不对:

    Get cache miss:
    1. Get DB
    2. set Cache
    3. Get DB
    4. set cache cas and only exists

    Update:
    5. Update DB
    6. set Cache cas and only exists
    cloudzhou
        44
    cloudzhou  
       1 day ago
    Get cache miss:
    1. Get DB
    2. set Cache
    3. Get DB
    4. set cache cas and only exists

    这个不知道什么逻辑? get 两次?
    imchuncai
        45
    imchuncai  
    OP
    PRO
       1 day ago
    @cloudzhou 是 get 两次,防止中间有被驱逐的更新
    cloudzhou
        46
    cloudzhou  
       1 day ago
    @imchuncai 严格从并发安全看这个不够的,这是我之前一个妥协方案,“引入一个延迟周期”,只是说尽量避免
    cloudzhou
        47
    cloudzhou  
       1 day ago
    @imchuncai 你很适合做一致性高的业务,这个考验很多设计细节,很多人都不会考虑一致性的
    imchuncai
        48
    imchuncai  
    OP
    PRO
       1 day ago
    @cloudzhou 我就是有强迫症的,我才设计了 Umem-cache 来引入强一致的方案。我是很满意这个方案的,同时解决了一致性和缓存击穿的问题。
    zzhirong
        49
    zzhirong  
       19h 39m ago
    @imchuncai 你的方案中,关于缓存更新或删除场景下,我好像找到了一个竞态:在删除 key 之后(第 4 步)到提交事务(第 5 步)的短暂窗口,会存在竞态,删除 key 之后,可能存在其他客户端执行缓存未命中路径,因为事务还未被提交,所以其他客户端看到的可能(取决于 db 的隔离级别)是旧数据,写入缓存的也是旧数据。

    关于 cloudzhou 在 #37 给出的最终方案,我的理解是,最开始的方案(由 cdc(update/delete) + 客户端(cache miss)更新缓存)会导致竞争,后来的改进的方案(#37 )是统一由事件消费者异步串行完成缓存填充/更新,从而避免了竞争。
    imchuncai
        50
    imchuncai  
    OP
    PRO
       12h 54m ago
    @zzhirong 这个竟态在我的方案里是被数据库的锁保护住了的,因为事务提交之前更新操作持有该 key 的写锁,其他客户端缓存未命中要获取读锁,会被阻塞,因为要等写锁释放。

    #37 的方案没有避免掉竞争,而是在每一个可能导致不一致的操作后面都跟了一个同步 cache 和 db 数据的操作。
    zzhirong
        51
    zzhirong  
       10h 32m ago
    @imchuncai

    你关于缓存更新或删除给出的流程图中,我并没有看到释放锁的步骤,只看到“4. 删除 key 或关闭持有 key 锁的连接”后提交事务,我的理解是,删除 key 之后,读写锁应该释放了吧,不然你还能对一个不存在的 key 加读写锁么?

    关于 #37 的方案,所有的对缓存的写入都集中到了同一地方(事件消费者),但你认为还存在竞争,我实在想不出具体什么情况还会出现竞争?
    imchuncai
        52
    imchuncai  
    OP
    PRO
       9h 52m ago
    @zzhirong 一共有两个锁。第 4 步的 key 锁是在 Umem-cache 上的。而事务里拿到的写锁是在数据库的,通过 select for update 拿到的,这个写锁在事务提交后才会自动释放。你说的竟态是被数据库的锁保护住了的。任何试图缓存的客户端都要先读数据库才能取到数据,它会被更新占有的写锁阻塞。

    #37 的方案:
    我现在有点不理解你说的`竞争`是什么意思了,我一直是把它当作`不一致`来理解的。你可以看#38 我写的流程,在第 8 步的时候,数据库里的 version=2,而缓存里的 version=1,是不一致的。但是后面会跟着一个擦屁股的第 9 步,它发送的 event Z 会同步 cache 和 db ,使得它们再次一致。
    zzhirong
        53
    zzhirong  
       9h 1m ago
    @imchuncai 我这里描述的“竞争”就是,同时会有两个程序在无法保证时序的情况下对同一个数据写入(不太严谨)。cloudzhou 提出的方案(仅仅是我的理解)是,只有一个程序(事件消费者)负责写入缓存,任何其他想写入缓存的程序都向它发送事件,你在 #38 中提到的第 8 步,客户端 A 无法直接操作缓存,他最多也就是向 Kafka 发送一个 event: (read, table, id),最终还是由消费者响应更新缓存操作,所以无法把 version=1 设置成功,因为数据库中的数据已经是最新的了。

    看了一下,select for update 加上 (cache miss)select for share 确实可以实现写时阻塞读,所以我(在 #49)说的竞态确实不存在。
    imchuncai
        54
    imchuncai  
    OP
    PRO
       8h 25m ago
    @zzhirong 按你的理解其实也是一样,这个方案的问题就是向 kafka 的请求是不允许失败的:
    1. 客户端 A:Get cache miss, 发送 event A
    2. event A 执行,set cache version = 1
    3. 客户端 B: Update DB version=2,发送 event B 失败
    zzhirong
        55
    zzhirong  
       6h 9m ago
    @imchuncai 我理解你的意思,你是想说两个步骤,数据库更新和向 Kafka 发送消息,无法做到原子化(要么全做,要么不做)吧,目前的方案确实可能存在不一致。但是也有补救方案,比如由 CDC 中间件去发送更新/删除的消息。
    imchuncai
        56
    imchuncai  
    OP
    PRO
       5h 22m ago
    @zzhirong 数据库更新和向 Kafka 发送消息是可以做到原子化的,方案其实就是我 update 的方案,把向 Kafka 发送消息放进数据库更新的事务里。

    然后你说的补救方案是没问题的,前提是要按你的理解,不让客户端去操作 redis 。但是这样一来你又处理不了缓存击穿的问题了。于是你又得让客户端可以去 set cache ,你一旦打开这个口子,我就有办法让它 set 进去过时的数据去破坏最终一致性。

    所以在我看来,这个方案的最终归宿就是我的方案,而且我的方案不需要 kafka 。
    About   ·   Help   ·   Advertise   ·   Blog   ·   API   ·   FAQ   ·   Privacy   ·   Solana   ·   2374 Online   Highest 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 41ms · UTC 12:26 · PVG 20:26 · LAX 05:26 · JFK 08:26
    ♥ Do have faith in what you're doing.