背景:C端读接口流量大,不能直接读数据库,故引入Redis缓存,用空间换时间,这也意味着数据同时存在于多个空间(MySQL和Redis),但最权威最全的数据还是在 MySQL 里的。而万一 Redis 数据没有得到及时的更新(例如数据库更新了没更新到 Redis),就出现了数据不一致
首先,缓存不一致性无法客观地完全消灭
理想情况下,我们需要在数据库更新后将最新数据同步到缓存中,确保读请求能获取新数据而非旧数据(脏数据)。然而,由于数据库和 Redis 之间缺乏事务保证,我们无法确保数据库写入成功后 Redis 的写入也必定成功。即使 Redis 最终能写入成功,在数据库写入完成到 Redis 更新完成的这段时间内,Redis 中的数据也必然与 MySQL 存在不一致。
在Redis写入成功前的这个时间窗口,我们无法完全消灭,除非使用加锁等方式维持强一致性,但这样也会造成系统性能的大幅下降,得不偿失。
通常情况下,在查询逻辑中,使用缓存的逻辑如下:
// GetUserInfo 获取用户信息
func (s *Service) GetUserInfo(ctx context.Context, userID int64) (*User, error) {
// 1. 先查询Redis缓存
user, err := s.redis.GetUser(ctx, userID)
if err == nil {
// 命中缓存,直接返回
return user, nil
}
if err != redis.Nil {
// 如果是其他错误而不是key不存在,记录日志
log.Errorf("redis get error: %v", err)
}
// 2. 缓存未命中,查询MySQL数据库
user, err = s.db.GetUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("db get user error: %w", err)
}
// 3. 将数据库查询结果写入Redis缓存
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := s.redis.SetUser(ctx, user, 24*time.Hour); err != nil {
// 缓存写入失败只记录日志,不影响返回结果
log.Errorf("redis set error: %v", err)
}
}()
return user, nil
}
// 获取用户信息
public User getUserInfo(Long userId) {
try {
// 1. 先查询Redis缓存
User user = redisTemplate.opsForValue().get(getUserKey(userId));
if (user != null) {
// 命中缓存,直接返回
return user;
}
// 2. 缓存未命中,查询MySQL数据库
user = userMapper.selectById(userId);
if (user == null) {
return null;
}
// 3. 异步将数据库查询结果写入Redis缓存
CompletableFuture.runAsync(() -> {
try {
redisTemplate.opsForValue().set(
getUserKey(userId),
user,
24,
TimeUnit.HOURS
);
} catch (Exception e) {
// 缓存写入失败只记录日志,不影响返回结果
log.error("Redis set error", e);
}
});
return user;
} catch (Exception e) {
log.error("Get user info error", e);
throw new ServiceException("获取用户信息失败");
}
}
private String getUserKey(Long userId) {
return String.format("user:%d", userId);
}
在查询逻辑中,这样做是没有问题的,但如果在写逻辑中,就会存在要先更新缓存还是先更新数据库,缓存是更新还是直接删除的选择。
更新数据库之后,删除缓存是更好的策略,因为出现不一致的情况更低。
但是,存在这样一种情况,如果是写多读少的场景,由于频繁写入,可能导致缓存反复被删除,导致缓存的作用微乎其微,针对这种场景,更新数据库后再更新缓存是更好的选择。