背景:C端读接口流量大,不能直接读数据库,故引入Redis缓存,用空间换时间,这也意味着数据同时存在于多个空间(MySQL和Redis),但最权威最全的数据还是在 MySQL 里的。而万一 Redis 数据没有得到及时的更新(例如数据库更新了没更新到 Redis),就出现了数据不一致

首先,缓存不一致性无法客观地完全消灭

理想情况下,我们需要在数据库更新后将最新数据同步到缓存中,确保读请求能获取新数据而非旧数据(脏数据)。然而,由于数据库和 Redis 之间缺乏事务保证,我们无法确保数据库写入成功后 Redis 的写入也必定成功。即使 Redis 最终能写入成功,在数据库写入完成到 Redis 更新完成的这段时间内,Redis 中的数据也必然与 MySQL 存在不一致。

image.png

在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);
}

在查询逻辑中,这样做是没有问题的,但如果在写逻辑中,就会存在要先更新缓存还是先更新数据库,缓存是更新还是直接删除的选择。

  1. 更新数据库前更新缓存
    1. 如果缓存更新成功但数据库更新失败,会导致缓存中存在数据库中不存在的数据。
    2. 即使发现数据库更新失败之后,回滚缓存也可能失败的情况。
    3. 在并发写的情况下,比如A、B两个写请求,更新数据库时先更新A再更新B,这时数据库是B的数据;更新缓存时,如果先更新了B再更新成了A,那么就会存在数据库、缓存不一致的情况。如何解决:加锁
    4. 在实际开发过程中,应当避免这个方式。
  2. 更新数据库前删除缓存
    1. 并发写请求过来,无论怎么样的执行顺序,缓存最后的值也都是会被删除的,在并发写的请求下这样的处理没问题
    2. 在读写并发的场景下,比如A、B两个请求,A是写请求,B是读请求,在A删除缓存之后,B读不到缓存,从数据库读到A之前的数据并更新缓存,然后A再次更新数据库,就会导致数据库和缓存不一致。
  3. 更新数据库后删除缓存
    1. 并发写:无论怎么样的执行顺序,缓存最后的值也都是会被删除的,在并发写的请求下这样的处理没问题
    2. 读写并发的情况:
      1. 如果缓存命中了,写请求完成数据库更新成功后,尚未删除缓存,读请求有并发读请求会读到旧数据,概率很低,可以忽略
      2. 如果缓存没有命中,读请求不命中缓存,写请求处理完之后,读请求才回写缓存,此时缓存不一致
  4. 更新数据库后更新缓存
    1. 并发写:两个写请求A、B,更新数据库的顺序是先A后B,但更新缓存时顺序是先B后A,导致了数据库和缓存数据不一致
    2. 读写并发:写请求更新完数据库,但未更新缓存之前,读请求可能读到旧值,概率很低,可以忽略

更新数据库之后,删除缓存是更好的策略,因为出现不一致的情况更低。

但是,存在这样一种情况,如果是写多读少的场景,由于频繁写入,可能导致缓存反复被删除,导致缓存的作用微乎其微,针对这种场景,更新数据库后再更新缓存是更好的选择。

最终一致性如何保证