缓存一致:读多写少时,如何解决数据更新缓存不同步?

我们之前提到过,互联网大多数业务场景的数据都属于读多写少,在请求的读写比例中,写的比例会达到百分之一,甚至千分之一。而对于用户中心的业务来说,这个比例会更大一些,毕竟用户不会频繁地更新自己的信息和密码,所以这种读多写少的场景特别适合做读取缓存。通过缓存可以大大降低系统数据层的查询压力,拥有更好的并发查询性能。但是,使用缓存后往往会碰到更新不同步的问题,下面我们具体看一看。

缓存性价比

是的,缓存的确有可能被滥用,特别是在像用户中心这样对数据准确性要求很高的场景中。你提到在对用户中心进行优化时,首要想到的就是将用户信息放入缓存,以提高性能。这确实是一个常见的优化思路,因为缓存能够显著减少数据库的访问频率,提升系统响应速度。

# 表结构 
CREATE TABLE `accounts` ( 
`id` int(10) unsigned NOT NULL AUTO_INCREMENT, 
`account` varchar(15) NOT NULL DEFAULT '', 
`password` char(32) NOT NULL, 
`salt` char(16) NOT NULL, 
`status` tinyint(3) NOT NULL DEFAULT '0' 
`update_time` int(10) NOT NULL DEFAULT '0', 
`create_time` int(10) NOT NULL DEFAULT '0', 
  PRIMARY KEY (`id`), 
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; 
# 登录查询 
select id, account, update_time from accounts  
where account = 'user1' 
and password = '6b9260b1e02041a665d4e4a5117cfe16' 
and status = 1

确实,这是一个简单的查询需求。乍一看,似乎将 2000 万条用户数据都放入缓存可以极大地提升性能,但实际上并不完全如此。虽然缓存能提供高性能的服务,但其性价比并不一定高。这个表主要用于账号登录的查询,而登录操作本身即使频繁,也不会对系统带来巨大的流量压力。因此,即便将所有用户数据放入缓存,大部分时间这些数据都处于闲置状态。这样一来,缓存资源反而被浪费,我们也不必要将并发量不高的数据缓存起来,从而增加预算开销。

这就引出一个核心问题:缓存的使用需要考虑性价比。如果花费大量时间和资源将某些数据放入缓存,但对系统性能并没有显著的提升,甚至增加了额外的成本,那么这样的缓存策略就是不合理的。缓存的效果需要经过评估,通常来说,只有热点数据才值得放入缓存。

临时热缓存

在推翻了将所有账号信息都放入缓存的方案后,我们将目标转向那些被频繁查询的信息上,比如用户信息。用户信息的使用频率非常高,尤其是在论坛等场景中,常常需要频繁展示,例如用户的头像、昵称和性别等。不过,由于这些数据量较大,全部缓存起来不仅浪费空间,还不具备性价比。

针对这种情况,我们可以考虑使用一种临时缓存的策略:当某个用户信息首次被访问时,将其存入缓存;在短时间内,若有类似查询请求,就可以直接从缓存中获取。这样既可以有效地降低数据库查询压力,又不会占用过多的缓存空间。以下是一个常用的实现临时缓存的代码示例:

# 示例代码 
def get_user_info(user_id): 
# 首先尝试从缓存中获取用户信息 
    user_info = cache.get(user_id) 
if user_info: 
return user_info 
# 如果缓存中没有,查询数据库 
    user_info = db.query_user_info(user_id) 
# 将查询到的信息存入缓存,并设置一个合理的过期时间 
    cache.set(user_id, user_info, timeout=300)  # 缓存五分钟 
return user_info

正如我们看到的,这种策略将数据临时放入缓存,在 60 秒过期后自动淘汰。如果在这段时间内再次查询相同数据,我们的代码会重新将数据填入缓存,继续提供使用。这种临时缓存策略非常适合数据量大但热点数据较少的场景,有助于缓解数据库的查询压力。

设置缓存的 TTL(Time-to-Live)是为了更有效地利用内存资源。当数据在指定时间内未被再次访问,就会被自动清除,这样我们就能避免购买过多内存。通过这种方式,可以在节省成本的同时,提高缓存的性价比,且实现起来简单,维护也方便,是一种很常用的策略

缓存更新不及时问题

临时缓存是有 TTL 的,如果 60 秒内修改了用户的昵称,缓存是不会马上更新的。最糟糕的情况是在 60 秒后才会刷新这个用户的昵称缓存,显然这会给系统带来一些不必要的麻烦。其实对于这种缓存数据刷新,可以分成几种情况,不同情况的刷新方式有所不同,接下来我给你分别讲讲。

1. 单条实体数据缓存刷新

单条实体数据缓存更新是最简单的一个方式,比如我们缓存了 9527 这个用户的 info 信息,当我们对这条数据做了修改,我们就可以在数据更新时同步更新对应的数据缓存:

Type UserInfo struct { 
  Id         int    `gorm:"column:id;type:int(11);primary_key;AUTO_INCREMENT" json:"id"` 
  Uid        int    `gorm:"column:uid;type:int(4);NOT NULL" json:"uid"` 
  NickName   string `gorm:"column:nickname;type:varchar(32) unsigned;NOT NULL" json:"nickname"` 
  Status     int16  `gorm:"column:status;type:tinyint(4);default:1;NOT NULL" json:"status"` 
  CreateTime int64  `gorm:"column:create_time;type:bigint(11);NOT NULL" json:"create_time"` 
  UpdateTime int64  `gorm:"column:update_time;type:bigint(11);NOT NULL" json:"update_time"` 
} 
//更新用户昵称 
func (m *UserInfo)UpdateUserNickname(ctx context.Context, name string, uid int) (bool, int64, error) { 
//先更新数据库 
  ret, err := m.db.UpdateUserNickNameById(ctx, uid, name) 
if ret { 
//然后清理缓存,让下次读取时刷新缓存,防止并发修改导致临时数据进入缓存 
//这个方式刷新较快,使用很方便,维护成本低 
    Redis.Del("user_info_" + strconv.Itoa(uid)) 
  } 
return ret, count, err 
}

总体来说,我们可以先识别出被修改的数据 ID,然后根据这些 ID 删除相应的数据缓存。在下次请求到来时,系统会重新获取最新的数据并更新到缓存中,这样可以有效减少并发操作将脏数据写入缓存的可能性。

除了这种方法,我们还可以向队列发送更新消息,让子系统处理更新,或者开发中间件,将数据操作发送到子系统,让其自行决定需要更新的数据范围。然而,通过队列更新消息时,我们可能会遇到一个问题——条件批量更新时,可能无法直接确定具体有多少个 ID 发生了变化。常见的解决方法是:首先按照相同的条件查询出所有受影响的 ID,然后执行更新操作,最后使用这些相关的 ID 更新具体的缓存。

2. 关系型和统计型数据缓存刷新

首先,有一种人工维护缓存的方式。众所周知,关系型数据或统计结果的缓存刷新具有一定的难度,主要原因在于这些统计数据通常是基于多条数据计算得出的。当我们需要刷新这类数据的缓存时,很难准确识别出需要更新的关联缓存。

为了解决这个问题,可以通过人工方式,在集中管理的地方记录或定义特定的刷新逻辑,以实现关联缓存的更新。

不过这种方式比较精细,如果刷新缓存很多,那么缓存更新会比较慢,并且存在延迟。而且人工书写还需要考虑如何查找到新增数据关联的所有 ID,因为新增数据没有登记在 ID 内,人工编码维护会很麻烦。除了人工维护缓存外,还有一种方式就是通过订阅数据库来找到 ID 数据变化。如下图,我们可以使用 Maxwell 或 Canal,对 MySQL 的更新进行监控。

在这种方案中,变更信息会被推送到 Kafka。我们可以根据表名和具体的 SQL 确认哪些数据 ID 发生了更新,然后依据脚本中设定的逻辑,对相关缓存 key 进行更新。比如,当用户更新了昵称,缓存更新服务就能够识别需要更新 user_info_9527 这个缓存,同时根据配置找到并删除其他相关的缓存。这种方法的优势在于,可以快速地更新简单的缓存,并且核心系统可以向子系统广播数据变更信息,代码实现也相对简单。不过,对于复杂的关联关系刷新,仍然需要人工书写逻辑来实现。

如果表内数据更新较少,还可以考虑使用版本号缓存策略。这种方法比较直接:一旦有任何更新,表中所有数据缓存都会过期。例如,可以为 user_info 表设置一个版本号 key,比如 user_info_version。当表数据发生更新时,直接将 user_info_version 自增 1。写入缓存时,同时记录当前版本号;读取时,业务逻辑会检查缓存版本号与表版本号是否一致。如果不一致,就更新缓存数据。需要注意的是,如果版本号频繁更新,缓存命中率会大幅下降,因此该方法更适合数据更新不频繁的表

当然,我们还可以对这个表做一个范围拆分,比如按 ID 范围分块拆分出多个 version,通过这样的方式来减少缓存刷新的范围和频率。

此外,关联型数据更新还可以通过识别主要实体 ID 来刷新缓存。这要保证其他缓存保存的 key 也是主要实体 ID,这样当某一条关联数据发生变化时,就可以根据主要实体 ID 对所有缓存进行刷新。这个方式的缺点是,我们的缓存要能够根据修改的数据反向找到它关联的主体 ID 才行。

最后,还有一种方法是通过异步脚本遍历数据库来刷新所有相关缓存。这种方式适用于在两个系统之间进行数据同步,能够减少系统之间的接口交互频率。其缺点是,在数据被删除后,还需要手动删除相应的缓存,因此更新存在一定延迟。不过,如果结合订阅更新消息广播机制,这种方案可以实现近乎同步的数据更新。

长期热数据缓存

回过头来看之前提到的临时缓存方案,虽然它能解决大部分问题,但有个潜在风险需要考虑:当 TTL 到期时,如果有大量缓存请求未命中,透传的流量可能会给数据库带来巨大的压力,甚至可能导致数据库崩溃。这就是业内常说的缓存穿透问题。如果发生大规模的并发穿透,服务可能宕机。因此,如果数据库无法承受日常流量,就不能依赖临时缓存方案来设计缓存系统,而应该采用长期缓存的方式来实现热点缓存,以避免缓存穿透对数据库的影响。

要实现长期缓存,需要更多的人工操作来保证缓存与数据表的一致性。长期缓存的普及主要得益于 NoSQL 技术的发展,它与临时缓存不同,需要业务几乎不依赖数据库,所有在服务运行期间所需的数据都必须在缓存中可用,并确保缓存不会在使用期间丢失。这带来的挑战是,我们需要精确知道缓存中的数据,并提前对这些数据进行预热。如果数据规模较小,还可以考虑将所有数据缓存起来,这样的实现会相对简单一些。

总结

并不是所有数据放入缓存都会带来良好的收益,因此我们需要从数据量、使用频率和缓存命中率三个方面进行分析。对于读多写少的数据,虽然将其缓存能够降低数据层的压力,但仍需根据一致性需求来更新缓存中的数据。

在这方面,单条实体数据的缓存更新相对容易实现,但对于需要条件查询的统计结果,实时更新则较为困难。因此,在设计缓存策略时,需综合考虑这些因素,以确保缓存的有效性和数据的一致。

3