DTC 解决的业务痛点
1. 缓存数据库一致性问题
传统解决方案
全量数据刷新缓存
- 数据库的数据,全量刷入缓存(不设置失效时间)。
- 写请求只更新数据库,不更新缓存。
- 启动一个定时任务,定时把数据库的数据,更新到缓存中。
优点:
- 读请求可以直接命中缓存,不需查库,所以性能高。
缺点:
- 缓存利用率低: 不经常使用的数据还一直留在缓存中,全量数据,耗费缓存空间;
- 数据不一致: 定时刷新缓存,缓存中数据的更新节点完全依赖于定时任务频率和执行效率。
优化缓存利用率低和一致性
- 写请求只写数据库
- 读请求先读缓存,如果缓存不存在,则从数据库读取,并更新缓存。
- 同时,写入缓存的数据,都设置失效时间。
优点:
- 缓存中设置了过期时间,这样,缓存中保存的都是热点数据,解决了缓存利用率问题.
缺点:
异常引发数据不一致问题(这里分两种情况讨论):
① 「先更新缓存,后更新数据库」
如果缓存更新成功了,但数据库更新失败,那么此时缓存中是最新值,但数据库是「旧值」。
虽然此时读请求可以命中缓存,拿到正确的值,但是一旦「缓存失效」。
就会从数据库中读取「旧值」, 这时更新进缓存的也是这个旧值。这时,用户就会发现之前修改过的数据,突然又「变回去」了。
② 「先更新数据库,后更新缓存」
数据库更新成功,写缓存失败,那么数据库是最新值,缓存中是「旧值」。
这时用户从缓存中读到的全是旧数据,直到「缓存失效」后,读数据库才能读到最新的值。
这时用户发现,自己修改的值,迟迟不能生效。
并发引发数据不一致问题:
① 「先更新缓存,后更新数据库」
接下来我们看,即使数据库和缓存都更新成功,会不会就没什么问题了?
假设现在又线程 A 和线程 B 两个线程,需要更新「同一条」数据,时序如下:
1
2
3
4T1): 线程 A 更新数据库 (X = 1)
T2): 线程 B 更新数据库 (X = 2)
T3): 线程 B 更新缓存 (X = 2)
T4): 线程 A 更新缓存 (X = 1)最后结果:缓存中(X=1) 数据库(X=2),从而造成数据不一致。
② 「先更新数据库,后更新缓存」
同 ① ,这里不在详述。
缓存利用率低
因为该方案是每次数据库发生变更,都会去写缓存,但是缓存中的数据很多都不会被访问到,留在内存中耗费资源。
旁路缓存策略方案
- 读请求先读缓存,缓存不命中,再读库
- 写请求做两个动作:写数据库+删除数据缓存
缺点:
异常引发数据不一致问题(这里分两种情况讨论):
这里的场景和 ii.中的异常场景相同,两步中只要有其中一步发生失败,就会引发数据不一致。
并发引发数据不一致问题:
① 「先删除缓存,后更新数据库」
如果有 2 个线程要并发「读写」 数据,可能会发生以下场景:
1
2
3
4
5
6
7
8
9
10
11T1): 线程 A 要更新 X = 2 (原值 X = 1)
T2): 线程 A 删除缓存
T3): 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
T4): 线程 A 将新值写入数据库 (X = 2)
T5): 线程 B 将旧值写入缓存 (X = 1)
T6): X 的值在缓存中(X = 1),在数据库中 (X = 2), 发生不一致。可见,先删除缓存,再更新数据库,当发生「读+写」 并发时,存在数据不一致的情况。
② 「先更新数据库,后删除缓存」
依旧是 2 个线程要并发「读写」 数据。
1
2
3
4
5
6
7
8
9
10
11T1): 缓存中 X 不存在(数据库 X = 1)
T2): 线程 A 读取数据库,得到旧值 (X = 1)
T3): 线程 B 更新数据库(X = 2)
T4): 线程 B 删除缓存
T5): 线程 A 将旧值写入缓存 (X = 1)
T6): X 的值在缓存中(X = 1),在数据库中 (X = 2), 也发生不一致。其实这个方案就是算是「旁路缓存」策略的实现方案;
仔细思考以下,这种情况理论来说是可能发生的,但实际上发生的概率是「极低」的。
因为它必须满足 3 个条件:
1
2
3
4
51-: 缓存刚好失效
2-: 读请求 + 写请求并发
3-: 更新数据库 + 删除缓存的时间(T3+T4) > 读数据库 + 写缓存时间 (T2 + T5)因为写数据库通常有加锁操作,所以写数据库通常要比读数据库的时间要长,所以,条件3发生的概率极低。
这个策略也是我用在「成长值账户」中的缓存策略;________________________________________________________________________________________________________________________ 前面说过异常情况,无论是更新缓存还是删除缓存,只要第二步发生失败,就会导致数据库和缓存不一致。
那么这里我们再回过头来讨论下「旁路缓存策略」 - 「先更新数据库,后删除缓存」 策略异常情况下,我们如何保证第二步执行成功?
重试
1
2
3
4
5如果更新数据库成功,删除缓存失败,这里我们就可以无脑的在业务代码中,一直尝试删除缓存;
但是这种方案往往存在以下问题:
1-: 失败后立即重试,大概率还会「失败」(网络抖动,或者服务异常);
2-: 「重试次数」我们要设置多少才是合理值?
3-: 重试会一直「占用」线程资源,无法服务其它请求。基于以上结论,我们发现这种「同步重试」方式往往不能解决根本问题。
异步重试
1
2
3
4异步重试步骤:
1-: 更新数据库后,发消息到消息队列。
2-: 消费者消费队列消息。
3-: 删除对应的缓存信息。这个方案除了需要维护一个重的「消息队列」服务外,看似好像是无懈可击的方案,但是该方案也有一个致命的漏洞,
那就是,数据库主从延迟,基于前面我们讨论的并发情况,写数据库往往是操作主库,查库往往是操作从库,所以,
会存在,主库处理玩,然后缓存清理后,从库还是旧数据的情况,那么用户从从库中读取旧数据,更新到缓存中时,还是
会出现数据不一致的情况。基于以上总总方案,所以我们引入最终的解决方案:
订阅数据库变更日志,再操作缓存
引入binlog 监控组件 canal 监控binlog变更日志。
优点:
- 业务只操作数据,无需考虑缓存以及其它相关业务是失败情况,只要写库成功,就会有binlog,
余下的工作旧交由下游业务处理,使得业务更加轻量化和简洁化。 - canal 不作为master的slave,而是作为一个slave的slave,规避主从延迟造成的数据不一致。
- 利用binlog 本身的有序性,完全避免并发中的数据不一致问题。
- 业务只操作数据,无需考虑缓存以及其它相关业务是失败情况,只要写库成功,就会有binlog,