LocalCache最佳实践 学习笔记

286 阅读8分钟

前言

在面对高并发、低延迟的场景,特别是在抖音、快手这类短视频应用的推荐与搜索功能场景,我们发现 Redis 作为缓存解决方案存在一定的局限性。这是因为 Redis 主要支持基本的 GET 操作,对于复杂的业务逻辑处理能力有限。为了达成个性化推荐的效果,单个请求通常都得从 Redis 中调取大量数据,然后在服务本地开展个性化的过滤与排序等工作,处理比较耗时。

我们很容易能够想到,那借助增加local cache本地缓存来减少延时,可本地缓存从 Redis回源的这个过程,还是会使响应时间变长,导致延时无法达到性能要求。

对于这类场景,我们更倾向于采用服务本地主动缓存全量数据的策略,由于服务所有实例缓存了全量数据,这一方式还天然规避了 Redis的热 Key和大 Key问题。

但是存在的痛点是,在本地缓存全量数据时,如何处理缓存启动加载、数据一致性以及数据量过大的问题?

如何解决慢加载问题?

如果采用本地缓存全量数据的方案,那么当程序启动时,我们就需要加载所有数据。最简单的方法就是当程序启动时,轮询从数据库拉取所有数据,并写入本地缓存。

对于数据量较小的场景,这种方法是可行的。然而,随着数据量的增加,这种从数据库拉取全量数据的方式,会导致程序的启动时间显著变长。一旦服务出现问题,很难快速完成发布与回滚操作。

为了优化启动性能,我们可以采用本地文件加载和数据库轮询加载相结合的策略。

image.png 如图所示,关键流程如下:

  1. 我们可以用 Sqoop等 DTS数据传输服务工具,以一天或者一小时为周期,将数据库中的数据导入 Hive 表;
  2. 接着,我们用一个定时任务,定时将 Hive 表的数据处理成我们需要的格式,并传到远程文件存储系统;
  3. 然后,在服务编译时,我们的编译脚本需要从远程存储拉取这些预处理的文件,并与程序的二进制文件一起打包,在发布时将它们部署到目标机器上;
  4. 在服务启动时,我们的代码应当优先从本地文件加载数据,以便快速完成绝大部分数据的加载;
  5. 考虑到文件生成与发布之间存在时间差,数据库中可能已经产生了新增或修改的数据。因此,我们还需要根据文件中记录的最新时间戳,从数据库中轮询拉取这个时间戳之后的所有增量数据

这样一来,由于我们只从数据库中拉取这部分增量数据,避免了全量数据从数据库拉取,就能大幅缩短服务的启动时间。

如何解决实时性和一致性问题?

当数据库里的数据完成更新后,我们怎样才能将这些更新同步到本地缓存里呢?

思路一:定时任务轮询

我们以固定时间间隔轮询的方式,去拉取在这个时间戳之后,一定时间内数据库表中发生更改的记录,从而实现对本地缓存数据的更新,确保本地缓存与数据库数据的同步。

这个定时任务具体的实现方式,可以考虑采用一些分布式的定时任务框架,也可以直接在内存中实现一个轮询任务,只需要维持一个最新时间戳值,然后异步常驻协程运行即可,例如

var lastUpdateTime int64 // 最新一次从数据库里拉取数据的时间戳
func init(){
    //  启动全量加载数据并更新lastUpdateTime
    
    // 启动协程,后台轮询更新数据
    go func(){
        PollDBForData()
    }()
}
// 定时轮询从数据库拉数据
func PollDBForData() {
    // 轮询间隔时间(单位:秒),可按需调整
    interval := 5
  
    for {
        // 获取当前时间戳并减去interval秒,防止主从延迟
        now := time.Now()
        secondsAgo := now.Add(-interval * time.Second)

        // 准备查询语句,假设数据库表中有个名为'update_time'的时间戳列,按实际情况修改表名和列名
        query := fmt.Sprintf("SELECT * FROM your_table WHERE update_time>%d and  update_time<= %d",lastUpdateTime,tenSecondsAgo)
        rows, err := db.Query(query)
        
        // 做数据处理
        更新local cache的具体逻辑。。。
        
        // 更新时间戳
        lastUpdateTime=secondsAgo 
        
        // 等待间隔时间
        time.Sleep(time.Duration(interval) * time.Second)
    }
}

当然,这种方式也有一个明显的缺点,那就是采用固定时间间隔的轮询机制,意味着数据库中的新增或修改操作需要经过一定的延迟才能反映到本地缓存中。这种延迟是由于轮询周期的限制,可能导致本地数据在一段时间内不是最新的。

那么,如何才能让数据更新能尽可能实时更新到本地缓存中,减少数据延迟对业务的影响?

思路二:广播触发更新

我们可以在数据写入数据库后,异步地将数据同步更新到local cache中,这个具体实现可以是简单的开个协程,也可以借助消息队列。

由于很多时候我们的后端服务是集群架构,那么可以采用RocketMQ,因为他支持了广播消费模式,同一条消息可以被服务端的每个实例所对应的消费者进程接收并处理。这样,每个服务实例都能够根据接收到的消息内容,及时地更新其本地缓存。 image.png

然而,直接在dao层数据库操作中集成 MQ写入逻辑,虽然能够实现数据的实时更新,但这种做法会增加dao层代码的复杂性。

而且,有时候一些修数据的场景,我们是直接绕过服务接口,直接通过DML更新了数据库中的数据,这种时候就会导致本地缓存无法同步更新,需要重启服务来解决这一问题。

有一种优化方案,我们可以利用像 Canal这样的开源中间件,来达成数据同步目标。 image.png

Canal 能模拟 MySQL主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送数据转储(dump)请求。MySQL 收到这些请求后,就会开始向 Canal 推送 Binlog。Canal 接收到 Binlog 字节流后,会解析这些日志并将其转换成易于读取的结构化数据,然后我们监听到DB数据是否变更,将这些更新数据写入 RocketMQ中,再去广播消费。

这种方法不仅减少了对现有数据库操作代码的侵入,还可以确保直接对数据库进行操作时,本地缓存与数据库变更能保持同步,而无需重启服务。

当然,使用 RocketMQ 广播消费的方式,虽然能够提升数据同步的实时性,但同时也可能面临数据丢失的风险,例如写入 RocketMQ时失败或者 RocketMQ本身出现故障。

最终一致性保障

实际上不管采用哪种异步方式去同步数据,都可能存在数据丢失的风险。为此我们需要建立一种保障机制。一种有效的方法是实施定期对账机制

具体来说,我们可综合考量数据一致性要求、服务负载状况以及数据库压力等因素,来设定一个合适的周期,每隔一段时间就从数据库中检索出在该周期内新增或修改的数据。如果发现这些数据比本地缓存中的数据更新,我们就更新本地缓存,通过这种周期性的对账和数据同步,我们就能够实现本地缓存与数据库之间的最终一致性

如何解决本地缓存数据量过大的问题?

由于单机内存规格是有限的,随着数据变多,单机内存可能存不下全量数据。这个时候,我们该怎么办呢?

我们可以借鉴数据库分库分表的设计思路,将数据按一定规则拆分成多份,并放入不同的服务集群中,上游服务根据规则将请求路由到相应的集群,这就是所谓的分片集群策略image.png 例如,我们可以将服务拆分成两个集群,集群 1 加载 user_id 模 2 为 0 的数据,集群 2 加载 user_id 模 2 为 1 的数据。然后上游服务根据请求的 user_id,请求我们的不同集群。通过这种方式,我们能够有效减少单个服务实例所需加载的数据量。

类似数据库分库分表规则,对于集群的分片规则,较为常用的同样是范围路由、hash 路由以及配置路由这三种类型,你可以根据自己的场景选择。

参考

《go服务开发高手课》