DTC 解决的业务痛点


1. 缓存数据库一致性问题

  • 传统解决方案

    1. 全量数据刷新缓存

      • 数据库的数据,全量刷入缓存(不设置失效时间)。
      • 写请求只更新数据库,不更新缓存。
      • 启动一个定时任务,定时把数据库的数据,更新到缓存中。

      优点:

      • 读请求可以直接命中缓存,不需查库,所以性能高。

      缺点:

      1. 缓存利用率低: 不经常使用的数据还一直留在缓存中,全量数据,耗费缓存空间;
      2. 数据不一致: 定时刷新缓存,缓存中数据的更新节点完全依赖于定时任务频率和执行效率。
    2. 优化缓存利用率低和一致性

      • 写请求只写数据库
      • 读请求先读缓存,如果缓存不存在,则从数据库读取,并更新缓存。
      • 同时,写入缓存的数据,都设置失效时间。

      优点:

      • 缓存中设置了过期时间,这样,缓存中保存的都是热点数据,解决了缓存利用率问题.

      缺点:

      • 异常引发数据不一致问题(这里分两种情况讨论):

        「先更新缓存,后更新数据库」

        如果缓存更新成功了,但数据库更新失败,那么此时缓存中是最新值,但数据库是「旧值」

        虽然此时读请求可以命中缓存,拿到正确的值,但是一旦「缓存失效」
        就会从数据库中读取「旧值」, 这时更新进缓存的也是这个旧值。

        这时,用户就会发现之前修改过的数据,突然又「变回去」了。

        「先更新数据库,后更新缓存」

        数据库更新成功,写缓存失败,那么数据库是最新值,缓存中是「旧值」

        这时用户从缓存中读到的全是旧数据,直到「缓存失效」后,读数据库才能读到最新的值。

        这时用户发现,自己修改的值,迟迟不能生效。

      • 并发引发数据不一致问题:

        「先更新缓存,后更新数据库」

        接下来我们看,即使数据库和缓存都更新成功,会不会就没什么问题了?

        假设现在又线程 A 和线程 B 两个线程,需要更新「同一条」数据,时序如下:

        1
        2
        3
        4
        T1): 线程 A 更新数据库 (X = 1) 
        T2): 线程 B 更新数据库 (X = 2)
        T3): 线程 B 更新缓存 (X = 2)
        T4): 线程 A 更新缓存 (X = 1)

        最后结果:缓存中(X=1) 数据库(X=2),从而造成数据不一致。

        「先更新数据库,后更新缓存」

        同 ① ,这里不在详述。

      • 缓存利用率低

        因为该方案是每次数据库发生变更,都会去写缓存,但是缓存中的数据很多都不会被访问到,留在内存中耗费资源。

    3. 旁路缓存策略方案

      • 读请求先读缓存,缓存不命中,再读库
      • 写请求做两个动作:写数据库+删除数据缓存

      缺点:

      • 异常引发数据不一致问题(这里分两种情况讨论):

        这里的场景和 ii.中的异常场景相同,两步中只要有其中一步发生失败,就会引发数据不一致。

      • 并发引发数据不一致问题:

        「先删除缓存,后更新数据库」

        如果有 2 个线程要并发「读写」 数据,可能会发生以下场景:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        T1): 线程 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
        11
        T1): 缓存中 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
        5
        1-: 缓存刚好失效

        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 本身的有序性,完全避免并发中的数据不一致问题。

2. 数据变更业务埋点

  • 券变更通知用户业务

    1. 传统做法

      • 在每一个涉及券变动的业务中设置业务埋点
      • 动态的更新缓存

    缺点:

    1. 极大的增加业务代码的复杂度。
    2. 极大的增加业务出错概率。
    3. 缓存不一致问题。

    解决方案: canal + kafka

    1. 出错率低
    2. kafka 持久化,失败重试。
    3. 和业务主流程解耦。