一次Redis缓存引发的线上事故,让我重新认识了缓存三兄弟

一次Redis缓存引发的线上事故,让我重新认识了缓存三兄弟

凌晨三点,客服群里反馈:部分用户下单后看到的订单状态是错的,明明已支付,页面显示待支付。查了一圈发现是缓存问题,但同一个缓存key,用户A看到正确数据,用户B看到错误数据。

这不是典型的缓存穿透或缓存雪崩,而是更隐蔽的缓存不一致问题。

事故现场

用户下单流程:

  1. 支付完成后,调用订单服务更新订单状态为"已支付"
  2. 更新数据库 ordersstatus = 'PAID'
  3. 更新 Redis 缓存 order:12345 的状态

当时缓存更新代码:

public void updateOrderStatus(Long orderId, String status) {
    // 更新数据库
    orderDao.updateStatus(orderId, status);

    // 更新缓存
    redisTemplate.opsForValue().set(
        "order:" + orderId,
        status,
        30, TimeUnit.MINUTES
    );
}

这个代码的问题在于:先更新数据库,再更新缓存。如果在两者之间有请求进来,会读到旧数据。

但更严重的问题是 Redis 主从切换时的数据一致性。

根因分析

当时的情况:

  1. 订单服务部署在两个机房:机房A和机房B
  2. Redis 是主从结构:机房A是主,机房B是从
  3. 写操作都打到机房A,读操作读机房B(读写分离)

流程是这样的:

  1. 用户在机房B下单,支付成功
  2. 订单服务(机房B)调用机房A的Redis主库更新缓存 → 成功
  3. 但机房B的Redis从库还没同步到这条数据
  4. 后续有读请求打到机房B的从库 → 读到旧数据

这就是主从延迟导致的缓存不一致。

缓存三兄弟:Cache Aside、Read Through、Write Through

这个问题让我重新梳理了缓存的三种模式。

Cache Aside(旁路缓存)

最常用的模式:应用自己负责读写缓存,缓存不参与读写流程。

读操作

public Order getOrder(Long orderId) {
    // 1. 先读缓存
    String cached = redisTemplate.opsForValue().get("order:" + orderId);
    if (cached != null) {
        return JSON.parseObject(cached, Order.class);
    }

    // 2. 缓存没有,读数据库
    Order order = orderDao.findById(orderId);

    // 3. 写入缓存
    redisTemplate.opsForValue().set(
        "order:" + orderId,
        JSON.toJSONString(order),
        30, TimeUnit.MINUTES
    );

    return order;
}

写操作

public void updateOrder(Order order) {
    // 1. 先更新数据库
    orderDao.update(order);

    // 2. 再更新缓存(或者删除缓存)
    redisTemplate.delete("order:" + order.getId());
}

注意:写操作时,我们选择删除缓存而不是更新缓存。这是有讲究的。

为什么删除而不是更新?

假设写操作顺序是:

  1. 线程A删除缓存
  2. 线程B读缓存,没读到,从数据库读到旧数据
  3. 线程B把旧数据写入缓存
  4. 线程A把新数据写入数据库

这种情况下,最终缓存里是旧数据。但如果我们用"先更新数据库再更新缓存":

  1. 线程A更新数据库为新数据
  2. 线程B来更新缓存,设置了旧数据
  3. 缓存里又是旧数据了

先删缓存后写数据库,至少能保证:缓存被删除后、数据库更新前这段时间内,读请求会穿透到数据库,但不会读到脏数据。

但这不解决主从延迟问题。

Read Through(读穿透)

缓存自动加载数据,应用只操作缓存:

public Order getOrder(Long orderId) {
    // 应用只操作缓存,缓存负责从数据库加载
    return cache.get("order:" + orderId, Order.class, id -> {
        return orderDao.findById(id);
    }, 30, TimeUnit.MINUTES);
}

这个模式需要缓存组件支持"回源"功能。Redis本身不支持,需要用Spring Cache或Caffeine包装。

Write Through(写穿透)

写操作同时写缓存和数据库:

public void updateOrder(Order order) {
    // 缓存组件同时更新数据库和缓存
    cache.put("order:" + order.getId(), order);
}

这个模式保证了强一致性,但增加了写延迟。

解决主从延迟问题的方案

回到最初的问题,核心是主从延迟导致读写分离场景下读到旧数据。

方案一:读主库

对于写后立即读的场景,强制读主库:

public Order getOrder(Long orderId) {
    // 写操作后立即读取,走主库
    if (isJustWritten(orderId)) {
        return orderDao.findById(orderId);
    }

    // 其他情况读从库
    return readFromReplica(orderId);
}

这个方案简单但不完美:主库压力会增加,且无法完全覆盖所有场景。

方案二:延迟删除 + 重试

更新数据库后删除缓存,而不是更新缓存。下次读请求会从数据库加载最新数据:

public void updateOrderStatus(Long orderId, String status) {
    // 1. 更新数据库
    orderDao.updateStatus(orderId, status);

    // 2. 删除缓存(而不是更新)
    redisTemplate.delete("order:" + orderId);

    // 3. 延迟一会儿再删除一次(确保主从同步完成)
    CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS)
        .execute(() -> redisTemplate.delete("order:" + orderId));
}

为什么延迟删除?因为主从同步需要时间。500ms后再次删除,确保从库也同步完成后,新的读请求会触发缓存重建。

方案三:使用 Redisson 的读写锁

Redisson 提供了分布式锁,可以保证读写顺序:

public void updateOrderStatus(Long orderId, String status) {
    RReadWriteLock lock = redissonClient.getReadWriteLock("order:lock:" + orderId);

    // 写锁:确保写操作独占
    lock.writeLock().lock();
    try {
        orderDao.updateStatus(orderId, status);
        redisTemplate.delete("order:" + orderId);
    } finally {
        lock.writeLock().unlock();
    }
}

public Order getOrder(Long orderId) {
    RReadWriteLock lock = redissonClient.getReadWriteLock("order:lock:" + orderId);

    // 读锁:允许并发读,但写操作会被阻塞
    lock.readLock().lock();
    try {
        String cached = redisTemplate.opsForValue().get("order:" + orderId);
        if (cached != null) {
            return JSON.parseObject(cached, Order.class);
        }
        Order order = orderDao.findById(orderId);
        redisTemplate.opsForValue().set("order:" + orderId, JSON.toJSONString(order));
        return order;
    } finally {
        lock.readLock().unlock();
    }
}

这个方案能保证强一致性,但牺牲了并发性能。在高并发场景下可能不适用。

缓存与数据库一致性最佳实践

根据业务对一致性的要求不同,选择不同的策略:

强一致性场景(金融、订单)

Cache Aside + 延迟双删

public void updateOrder(Order order) {
    // 1. 删除缓存
    redisTemplate.delete("order:" + order.getId());

    // 2. 更新数据库
    orderDao.update(order);

    // 3. 延迟500ms再删除(应对主从延迟)
    CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS)
        .execute(() -> redisTemplate.delete("order:" + order.getId()));
}

最终一致性场景(商品信息、配置)

Cache Aside + TTL

public Product getProduct(Long productId) {
    String cached = redisTemplate.opsForValue().get("product:" + productId);
    if (cached != null) {
        return JSON.parseObject(cached, Product.class);
    }

    Product product = productDao.findById(productId);
    // 缓存时间短一些,容忍一定延迟
    redisTemplate.opsForValue().set(
        "product:" + productId,
        JSON.toJSONString(product),
        5, TimeUnit.MINUTES  // 5分钟TTL
    );
    return product;
}

高并发场景(秒杀、抢购)

本地缓存 + Redis 二级缓存:

public class LocalCacheProductService {

    private final Cache<Long, Product> localCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .build();

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public Product getProduct(Long productId) {
        // 1. 读本地缓存
        Product product = localCache.getIfPresent(productId);
        if (product != null) {
            return product;
        }

        // 2. 读Redis
        String cached = redisTemplate.opsForValue().get("product:" + productId);
        if (cached != null) {
            product = JSON.parseObject(cached, Product.class);
            localCache.put(productId, product);
            return product;
        }

        // 3. 读数据库
        product = productDao.findById(productId);
        redisTemplate.opsForValue().set("product:" + productId, JSON.toJSONString(product));
        localCache.put(productId, product);
        return product;
    }
}

这次事故的最终解决方案

针对订单状态这个场景,我们采用了"延迟双删 + 读主库"的混合方案:

public void updateOrderStatus(Long orderId, String status) {
    RReadWriteLock lock = redissonClient.getReadWriteLock("order:status:lock:" + orderId);
    lock.writeLock().lock();
    try {
        // 1. 先删除缓存
        redisTemplate.delete("order:" + orderId);

        // 2. 更新数据库(走主库)
        orderDao.updateStatus(orderId, status);

        // 3. 延迟500ms再删除缓存
        CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS)
            .execute(() -> redisTemplate.delete("order:" + orderId));
    } finally {
        lock.writeLock().unlock();
    }
}

public Order getOrder(Long orderId) {
    // 写后立即读,走主库
    Long lastWriteTime = lastWriteTimeMap.get(orderId);
    if (lastWriteTime != null
            && System.currentTimeMillis() - lastWriteTime < 1000) {
        return orderDao.findById(orderId);
    }

    // 其他情况读缓存
    String cached = redisTemplate.opsForValue().get("order:" + orderId);
    if (cached != null) {
        return JSON.parseObject(cached, Order.class);
    }

    Order order = orderDao.findById(orderId);
    redisTemplate.opsForValue().set("order:" + orderId, JSON.toJSONString(order));
    return order;
}

总结

缓存三兄弟各有特点:

  • Cache Aside:应用主导,最灵活,但容易出现一致性问题
  • Read Through:缓存自动加载,应用最简单,但需要缓存支持回源
  • Write Through:强一致,但写延迟高

解决主从延迟导致的缓存不一致,没有银弹,只有 trade-off:

  • 延迟双删:增加复杂度,但能覆盖大多数场景
  • 读写锁:强一致,但牺牲并发
  • 读主库:简单,但增加主库压力

根据业务对一致性的要求选择合适的方案。

最后更新 4/20/2026, 6:02:32 AM