如何保持mysql和redis中数据的一致性?

26 阅读6分钟

强一致性做不到,除非你不用缓存。

用了缓存,就必然存在数据库和缓存不一致的窗口期。我们能做的是尽量缩短这个窗口,以及出了不一致能兜底


为什么会不一致

最简单的场景:

1. 用户A修改数据,更新了数据库
2. 还没来得及更新缓存
3. 用户B查询,命中缓存,拿到旧数据

这就是不一致。

更复杂的场景涉及并发:

线程1: 更新数据库 → 删除缓存
线程2: 查询缓存(未命中) → 查数据库 → 写缓存

如果时序是这样:
1. 线程1 更新数据库(新值)
2. 线程2 查数据库(拿到新值)
3. 线程1 删除缓存
4. 线程2 写缓存(新值)
这种情况没问题。

但如果是这样:
1. 线程2 查数据库(旧值)
2. 线程1 更新数据库(新值)
3. 线程1 删除缓存
4. 线程2 写缓存(旧值)
缓存里存的就是旧值,不一致了。

这种并发导致的不一致是最难处理的。


常用的几种做法

Cache Aside,最常用的:

:先读缓存,没有就读数据库,然后写缓存 :先更新数据库,再删除缓存

// 读
public User getUser(Long id) {
    // 1. 先查缓存
    User user = redis.get("user:" + id);
    if (user != null) {
        return user;
    }
    // 2. 缓存没有,查数据库
    user = db.selectById(id);
    if (user != null) {
        // 3. 写入缓存
        redis.setex("user:" + id, 3600, user);
    }
    return user;
}

// 写
public void updateUser(User user) {
    // 1. 先更新数据库
    db.update(user);
    // 2. 再删除缓存
    redis.del("user:" + id);
}

为什么是删除缓存而不是更新缓存?

因为更新缓存在并发场景下更容易出问题:

线程1更新数据为A,线程2更新数据为B
1. 线程1 更新数据库为A
2. 线程2 更新数据库为B
3. 线程2 更新缓存为B
4. 线程1 更新缓存为A
结果:数据库是B,缓存是A,不一致

删除缓存就没这个问题——不管谁先删,最终缓存都是空的,下次读会从数据库加载最新值。

延迟双删,针对并发问题加层保险:

public void updateUser(User user) {
    // 1. 先删缓存
    redis.del("user:" + id);
    // 2. 更新数据库
    db.update(user);
    // 3. 延迟一段时间再删一次
    Thread.sleep(500);  // 或者用消息队列延迟
    redis.del("user:" + id);
}

第一次删除是为了让并发的读请求去数据库拿最新数据。 延迟删除是为了清理掉在更新数据库期间可能被写入的旧缓存。

延迟时间怎么定?通常设置为业务读请求的耗时 + 几百毫秒buffer。

binlog同步,更可靠但也更重。监听MySQL的binlog,数据变更时自动删缓存:

MySQL → binlog → Canal/Maxwell → 消费程序 → 更新Redis
// Canal消费示例
@CanalEventListener
public void onEvent(CanalEntry entry) {
    if (entry.getTableName().equals("user")) {
        Long userId = entry.getColumn("id");
        if (entry.getEventType() == UPDATE || entry.getEventType() == DELETE) {
            redis.del("user:" + userId);
        }
    }
}

优点:

  • 业务代码不用关心缓存更新
  • 可靠性高,binlog不会丢

缺点:

  • 架构复杂,多了Canal这个组件
  • 有延迟(通常毫秒到秒级)

实际项目怎么选

大部分场景:Cache Aside就够了

读多写少的数据(用户信息、商品详情),Cache Aside + 合理的过期时间,基本够用。短暂的不一致业务上能接受。

写比较频繁:考虑延迟双删

比如库存、积分这种更新频繁的,加上延迟双删降低不一致的概率。

强一致性要求高:binlog同步

金融场景、交易相关的,上Canal。或者干脆不用缓存,直接读数据库。

一定能接受最终一致:设置过期时间兜底

不管用什么方案,一定要给缓存设置过期时间。就算前面的逻辑出问题了,过期后也能自动恢复一致。


一些踩过的坑

缓存击穿导致的不一致

热点数据过期瞬间,大量请求打到数据库,同时回写缓存。如果这时候正好有更新操作,很容易写入旧值。

解决:用分布式锁保证只有一个请求回写缓存。

public User getUser(Long id) {
    User user = redis.get("user:" + id);
    if (user != null) return user;
    
    // 加锁,只有一个请求去查数据库
    String lockKey = "lock:user:" + id;
    if (redis.setnx(lockKey, "1", 10)) {
        try {
            // 双重检查
            user = redis.get("user:" + id);
            if (user != null) return user;
            
            user = db.selectById(id);
            redis.setex("user:" + id, 3600, user);
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没拿到锁,等一下重试
        Thread.sleep(50);
        return getUser(id);
    }
    return user;
}

删除缓存失败

网络抖动导致删除失败,缓存里还是旧数据。

解决:删除失败时发到消息队列,异步重试。

public void updateUser(User user) {
    db.update(user);
    try {
        redis.del("user:" + id);
    } catch (Exception e) {
        // 删除失败,发消息异步重试
        mq.send("cache-delete-retry", "user:" + id);
    }
}

读写分离的坑

如果数据库是主从架构,写主库、读从库。从库有延迟,可能刚更新完主库,删了缓存,结果读请求去从库拿到旧数据又写回缓存。

解决:

  • 关键业务强制读主库
  • 或者延迟双删的时间设置长一点,覆盖主从延迟

一次真实的线上事故

去年遇到过一个case,分享一下。

电商系统,商品详情页用了缓存。某天运营反馈:改了商品价格,页面上还是老价格,过了好几分钟才变过来。

排查下来,问题出在这:

1. 运营在后台改价格,更新数据库
2. 代码里更新数据库后删缓存
3. 删缓存的时候,Redis连接超时了(那段时间Redis有点抖动)
4. 代码吞了异常,没重试
5. 缓存里还是老价格,要等30分钟过期才会更新

问题代码:

public void updatePrice(Long productId, BigDecimal price) {
    db.updatePrice(productId, price);
    try {
        redis.del("product:" + productId);
    } catch (Exception e) {
        log.error("删除缓存失败", e);  // 只打了个日志,没处理
    }
}

修复方案:

public void updatePrice(Long productId, BigDecimal price) {
    db.updatePrice(productId, price);
    
    // 删缓存,失败重试3次
    int retry = 0;
    while (retry < 3) {
        try {
            redis.del("product:" + productId);
            return;
        } catch (Exception e) {
            retry++;
            if (retry >= 3) {
                // 重试失败,发消息队列异步处理
                mq.send("cache-invalidate", "product:" + productId);
                log.error("删缓存失败,已发MQ", e);
            }
            Thread.sleep(100);
        }
    }
}

// 消费者兜底
@MQListener("cache-invalidate")
public void onCacheInvalidate(String key) {
    redis.del(key);
}

还加了个兜底:缓存过期时间从30分钟改成5分钟。就算前面都失败了,最多5分钟也能恢复。

这个事故给我的教训:删缓存失败一定要有兜底机制,不能只打个日志就完了。


总结

方案复杂度一致性适用场景
Cache Aside最终一致大部分读多写少场景
延迟双删最终一致(更好)写频繁场景
binlog同步最终一致(可靠)一致性要求高的场景
不用缓存-强一致金融核心交易

记住几个原则:

  1. 更新数据库后删缓存,不是更新缓存
  2. 一定设置过期时间兜底
  3. 能接受最终一致就行,别追求强一致