• WildNova930

    原文:consistency.rst https://github.com/imchuncai/umem-cache/tree/master/Documentation/translations/zh_CN/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

    1. 释放 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


    |结束|
    -----。

    主楼
  • LDa

    确实 深有同感。

    #1
  • cloudzhou

    一直讨论类似缓存一致性问题,直到我选择了更简单的方式
    你的这个实现方式太重度了,以至于无法验证正确性

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

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

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


    你这套机制,除非高一致性才需要
    但是,到了这一级别的高一致性,简单依赖 db 就好了,没必要这么复杂。

    #2
  • imchuncai

    你说的多个 cache 更新的问题是在我流程里是不存在的,因为第一步就是 select for update 去拿写锁,所有要改的都已经查出来了。
    至于你说的前面一套方案,你还要考虑很多问题,第一你的业务和 cdc 是需要对 key 的格式有约定的,这一点是需要同步的;第二数据库需要维护 version 字段,业务上 insert/update/delete 这些操作你都需要额外得去维护 version 字段,万一忘记更新就会变成永久的不一致,连最终一致性都达不到。
    其实这些都不是问题,只要你能接受短暂的不一致,只要你够小心。我的观点是如果缓存能直接提供强一致性,你不需要考虑额外的东西,业务的实现会变得简单,也不需要部署额外的 cdc ,考虑 cdc 的可用性。

    #3
  • cloudzhou

    他这里还有一个非常难解决的问题,就是 update 然后其实隐含多个 cache 更新
    比如 update order set xxx where yyy ,导致多个 order 修改
    这时候,触发的不是单个 order 更新,是批量更新
    cdc + 版本号,解决这个问题。

    #4
  • imchuncai

    不好意思,我说的 redis 不支持跨节点的 lua 脚本的问题是不存在的,因为你的方案是一条一条独立更新的。

    #5
  • imchuncai

    另外在部署集群的情况下多个 cache 更新,redis 连最终一致性都保证不了,因为 redis 不支持跨节点的 lua 脚本。

    #6
  • cloudzhou
    1. 因为第一步就是 select for update 去拿写锁,所有要改的都已经查出来了。

    你这一步,对业务的开发,已经影响很大了,不管是模式还是性能,开发肯定习惯 update/delete 一个 sql 搞定

    1. cdc 是需要对 key 的格式有约定的

    这就太简单了,${table_name}_${id},约定大于配置即可

    1. 第二数据库需要维护 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 加大点。

    #7
  • imchuncai

    我的方案只有 Get 操作才会对缓存进行写入,而你的方案所有的操作都会对缓存进行写入,我的方案对热点数据更友好,冷数据的修改可能永远都不会触发写入缓存。

    #8
  • imchuncai

    如果是中途引入 redis 呢?你这种方案应该怎么操作?应该会很比较复杂吧。

    #9
  • imchuncai

    你可能没理解我 10 楼说的情况,我说的就是 db 有 version 列的情况下,没法达到最终一致。我画个图:
    ::

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

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

    1. 客户端 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。

    #10
  • imchuncai

    还有个一理论上存在比较极限的一个情况,比中途引入 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。
    #11
  • cloudzhou

    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 的方案,对读写都是没啥代价的。

    #12
  • cloudzhou

    严格从并发安全看这个不够的,这是我之前一个妥协方案,“引入一个延迟周期”,只是说尽量避免。

    #13
  • imchuncai

    有时间看看 README 也不至于写这么大一段。

    1. cache 命中和吞吐的优势不是我张嘴就来的,talk is cheap 。README 有基准测试项目的链接,谁都可以复现,你也可以去看详细测试结果。而且这个基准测试是得到同行认可的: https://github.com/kividbio/kividb/issues/202
    1. Umem-cache 就是一个缓存,不是什么分布式锁,认为它像只是你的一厢情愿
    1. Umem-cache 能提供强一致的方案,而这一点是别人做不到的。你想要强一致性就可以参考我的方案,你不想要完全是随你心意。你完全可以不给数据库加任何锁,你也完全可以不在事务里调 DELETE ,Umem-cache 甚至都不关心它后面是不是一个数据库。
    1. 数据库的隔离级别跟锁有什么的关系?更别提跟锁范围有什么关系了。隔离级别可以用锁来实现也可以用快照,但都跟我用锁来控制并发无关。我的方案里用锁,根本不受隔离级别节制。再次地声明,select for update 是强一致的方案建议的,并不是必须的,你可以不使用强一致方案。
    1. 缓存是为了给数据库的读操作提速,“增加缓存是为了给数据库增加容错”这也是你的一厢情愿。而且这文章的一开始就说了如果你选择强一致,代价就是牺牲可用性。你要可用性,就别使用一致性方案,还要跟你解释 CAP 定理吗?或者从盘古开天辟地开始讲?
    1. 缓存为什么要带过期? CPU 的缓存有带过期时间吗?缓存需要过期的能力也是你的一厢情愿。参考#26
    1. Umem-cache 没有读锁只有写锁,写锁的过期时间是一个配置项,用于防止最差情况,不需要你看源码,只要你看 README:TCP_TIMEOUT 。
    1. update 中删除 key 失败的情况下,事务是不能提交的,当然是回滚。之前的连接发送的 delete 当然可以成功,任何时候删除 key 都不会影响强一致。另一个连接拿到这个 key 锁的情况下,之前的连接就算重试也不可能拿到锁。就算还是他拿到锁,它是个读缓存的连接,删除 key 跟它有半毛钱的关系?
    1. 集群的方案当然没有那么简单,认为我的集群方案简单也是你的一厢情愿,不看 README ,自以为是,才觉得我的设计简单。主从当然会同步,同步的是集群成员的信息,谁跟你说数据会同步?同一个 key 就不会同时出现在两个不同节点上,跟谁同步呢?#33 也提到过
    1. 谁告诉你集群中的节点能重启?只要你看了 README ,就能看到我写明了“警告:不支持直接重启节点,应将节点先踢出再重新加入”,这是我不碰持久化存储的代价。

    总结:你自以为是,而我,可不是菜逼。

    #14
  • notwaste

    也是感觉很久没看到过关于技术的讨论内容了。

    #15
  • cloudzhou

    你有没有想过驱逐这种情况,接下去所有的 update 都可能是失败的

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

    缓存讨论驱逐,几乎没有意义的,时序完全无法保证。

    #16
  • imchuncai

    在我的步骤里读写 cache 没有失败,只是有延迟。缓存最重要的就是驱逐的设计,而数据库是没有驱逐的。

    #17
  • cloudzhou

    没错,我是理解你这个意思,和你的图一样的,核心无非是高版本被驱逐,低版本自然就占据

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

    1. redis 驱逐本身不合理,意味着 cache 有丢失
    2. 我们监听 redis Keyspace Notifications ,驱逐事件,根据 id 重新读取 version = 2 数据进行重放,覆盖更新。
    #18
  • imchuncai

    你给的方案太多了,我有点绕不过来了,而且越来越复杂,引入的组件越来越多。

    #19
  • imchuncai

    在所有服务和网络都正常的前提下,可以简化一下你的方案,看看对不对:

    Get cache miss:

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

    Update:

    1. Update DB

    6.。

    #20
  • zzhirong

    我理解你的意思,你是想说两个步骤,数据库更新和向 Kafka 发送消息,无法做到原子化(要么全做,要么不做)吧,目前的方案确实可能存在不一致。但是也有补救方案,比如由 CDC 中间件去发送更新/删除的消息。

    #21
  • imchuncai

    酒逢知己千杯少 这是我写的 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 存在的不足之处的一些见解,甚至我都没法翻译到中文来,专业术语太多了。我所有的设计都是为了一个最终的目标,就是简单性,我提供极少的配置,然后我也不想用户去引入其他的系统去为一致性和缓存击穿擦屁股,防缓存击穿在我这套方案里也是浑然天成的,不需要额外的语义。

    #22
  • zzhirong

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

    关于 #37 的方案,所有的对缓存的写入都集中到了同一地方(事件消费者),但你认为还存在竞争,我实在想不出具体什么情况还会出现竞争?

    #23
  • imchuncai

    我就是有强迫症的,我才设计了 Umem-cache 来引入强一致的方案。我是很满意这个方案的,同时解决了一致性和缓存击穿的问题。

    #24
  • imchuncai

    在所有服务和网络都正常的情况下,你的方案是可以做到最终一致的,因为缓存变更和数据库变更的后面,都会跟一个序列化的缓存和数据库同步操作。

    #25
  • imchuncai

    不明白你说的,缓存只是加速查询。更新本来就是只依赖 db ,也就是 select for update , 是绝不可能从缓存查数据的。

    #26
  • valord577

    你要推广一个库或者一个组件 你得解决实际的业务问题。在什么业务 什么场景下 用你的技术方案解决了 并且对比竞品有什么更好点 这样才能推广地更加顺利。

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

    所以我说 你的代码 只值得学习技术思路 做不了业务解决方案。

    #27
  • imchuncai

    第一个问题:我又想了下,不 set 会有更严重的并发问题,相当于此时没法用 version 去避免覆盖了

    1. 客户端 get key ,miss ,查数据库 version=1
    2. 数据库更新,version=2
    3. CDC 触发更新,redis 不存在该 key,所以不更新
    4. 第 1 步在查询完成后 set redis version = 1

    第二个问题:监听驱逐事件会触发数据库的读取,会增加数据库压力。

    #28
  • zzhirong

    我这里描述的“竞争”就是,同时会有两个程序在无法保证时序的情况下对同一个数据写入(不太严谨)。cloudzhou 提出的方案(仅仅是我的理解)是,只有一个程序(事件消费者)负责写入缓存,任何其他想写入缓存的程序都向它发送事件,你在 #38 中提到的第 8 步,客户端 A 无法直接操作缓存,他最多也就是向 Kafka 发送一个 event: (read, table, id),最终还是由消费者响应更新缓存操作,所以无法把 version=1 设置成功,因为数据库中的数据已经是最新的了。

    看了一下,select for update 加上 (cache miss)select for share 确实可以实现写时阻塞读,所以我(在 #49)说的竞态确实不存在。

    #29
  • cloudzhou

    我的设计之前没有说的很清楚,就是 “由 CDC 中间件去发送更新/删除的消息”,消息二次汇总然后统一一个处理点,不管是 读写过期,都是一个 get by id 然后 cas 刚才我让 ai 帮我分析了一下,发现你 #43 的设计除了短暂不一致,居然是可行的,整个就简化很多了,有点意思。

    #30
  • liuhuan475

    确实,AI 时代很少看到讨论技术的帖子了。

    #31
  • imchuncai

    数据库更新和向 Kafka 发送消息是可以做到原子化的,方案其实就是我 update 的方案,把向 Kafka 发送消息放进数据库更新的事务里。

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

    所以在我看来,这个方案的最终归宿就是我的方案,而且我的方案不需要 kafka 。

    #32
  • lesismal

    #70

    gpt 总结的不咋地,它圆滑、爱说好话。

    如果理解我说的,你就会发现,OP 这个论题本身就是根本上走错路,所以前面很多楼讨论这种方案本身就都是在跑偏、没有意义。

    另外,gpt 说我的结论过于绝对,是因为我前面楼层偏口语语境个别措辞没那么严谨比如 redis ,我的本意是 redis lua 一组操作能搞更多并且抛开持久化之类的单纯功能上几乎等于在 redis 自己这层数据的数据库事务性保障、远远不限于 GetOrSet 这种无用设计,gpt 分析人类语言的时候不考虑语境做更细的解读实际是偏离了我本意的。
    我总不能论坛聊天还得像写论文似的那么严谨吧。。。

    #33
  • cloudzhou

    你这里根本没有涉及到真实的 cache 存储啊,除非你存储到内存缓存?你要是接上 redis 或者任何远程 cache ,这写 cache 不就是会出现吗?还是说你用 raft 作为 cache store ?

    #34
  • WildDeer421

    这种实现方式在高并发场景下锁竞争会很严重,整个系统的吞吐量可能会被拉低不少。

    #35
  • cloudzhou

    我的强一致是不可能达成这是对的,包括短期的不一致,但是我这个方案,是可以做到最终一致的,你可以看我的 append

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

    所以我说你这个方案,本质没问题,但是不应该用在 cache 。
    cache 这玩意是为了什么,无非就是快,而你把一个 raft 包一下,这,快不起来的。

    #36
  • zzhirong

    你的方案中,关于缓存更新或删除场景下,我好像找到了一个竞态:在删除 key 之后(第 4 步)到提交事务(第 5 步)的短暂窗口,会存在竞态,删除 key 之后,可能存在其他客户端执行缓存未命中路径,因为事务还未被提交,所以其他客户端看到的可能(取决于 db 的隔离级别)是旧数据,写入缓存的也是旧数据。

    关于 cloudzhou 在 #37 给出的最终方案,我的理解是,最开始的方案(由 cdc(update/delete) + 客户端(cache miss)更新缓存)会导致竞争,后来的改进的方案(#37 )是统一由事件消费者异步串行完成缓存填充/更新,从而避免了竞争。

    #37
  • cloudzhou
    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 的时延衡量。

    #38
  • lesismal

    锁状态和锁临界区差异很大。incr 这种命令还可以利用阈值设计业务逻辑、通过一个命令实现前后值的状态对比进而实现业务的一致性,虽然不如锁临界区能做的业务复杂,但是锁粒度小而且确实对不少类型业务有效。
    但是 OP 说的这种 GetOrSet ,只临界状态的瞬间一致性,而真实的并发业务场景,任一并发在任意瞬间 Get 到的数据在获取后但使用前、就可能被其他并发改变了,所以这种指令设计没有太大实际意义,远不如 redis lua 来得实在。

    #39
  • imchuncai

    这个竟态在我的方案里是被数据库的锁保护住了的,因为事务提交之前更新操作持有该 key 的写锁,其他客户端缓存未命中要获取读锁,会被阻塞,因为要等写锁释放。

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

    #40
  • cloudzhou

    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 事件是否进入。
    #41
  • imchuncai

    查看#33 的第 4 点。

    #42
  • nanwangnongfu

    不管怎么说,现在这个技术讨论贴,的确很棒。

    #43
  • cloudzhou

    作为技术方案探讨,也很不错了,毕竟,ai 时代,已经不提技术了 :~

    他这套方案,有应用场景,但不是 cache ,而是分布式 lock
    lock 要做细腻,也有很多细节可思考:

    1. 乐观/悲观,读/写锁,最高并发锁?令牌桶?(问号概念是我拍脑袋的)
    2. 锁周期,不能导致资源长期不可用
    3. 顺序,以及锁关联的 do once

    等等

    借助 raft 机制下,做一个全局的分布锁 lib ,用在敏感领域,大家用起来如同本地 lock
    昨天有几个问题没有回答:

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

    Q: 驱逐的问题
    A: 依然是策略问题

    • 可以根据 ttl 尽量避免驱逐(现实中采取方案,因为 cache 驱逐是非常不好的行为,无法确定驱逐的数据是否重要)
    • 在驱逐情况下,你提到的并发问题,可以根据监听 redis Keyspace Notifications 然后一个延迟周期,只更新 cache 已有的 key ,这样避免持久相互驱逐,同时更新并发竞态数据。
    #44
  • cloudzhou

    读写 cache 失败,导致的并发问题,vs cache 逻辑的设计,是两个问题。

    #45
  • cloudzhou

    除了你说的,“发送 event Z 失败” 这种情况,目前最终一致是可以做到的,你再看看我的方案。

    而我的意思,你不能把这个失败也纳进来讨论,可以说失去讨论意义了。

    #46
  • cloudzhou

    你很适合做一致性高的业务,这个考验很多设计细节,很多人都不会考虑一致性的。

    #47
  • imchuncai

    我的系统就是放弃了可用性来换取一致性,而你的方案是放弃了一致性来换取可用性。就是取舍的问题,我的观点就是你的方案最终一致也不能做到。

    #48
  • imchuncai

    如果 key 被驱逐了就重新 Set ,只会反复触发驱逐。

    #49
  • imchuncai

    我的续期的意思就是 GETEX 传入相同的过期时间

    然后你的方案:

    1. 客户端 A:Get cache miss, Get DB version=1
    2. 客户端 B:Update DB version=2, Update cache version=2, 发送 event X
    3. event X 触发, Update cache version=2
    4. cache 驱逐, 发送 event Y
    5. event Y 触发,cache not exists ,什么也不做
    6. 客户端 A: Update cache version=1
    7. 客户端 A:由于网络波动也好,硬件损坏也好,发送 event Z 失败。
    #50

发表回复

登录后回复