一次Redis缓存引发的线上事故,让我重新认识了缓存三兄弟
一次Redis缓存引发的线上事故,让我重新认识了缓存三兄弟
凌晨三点,客服群里反馈:部分用户下单后看到的订单状态是错的,明明已支付,页面显示待支付。查了一圈发现是缓存问题,但同一个缓存key,用户A看到正确数据,用户B看到错误数据。
这不是典型的缓存穿透或缓存雪崩,而是更隐蔽的缓存不一致问题。
事故现场
用户下单流程:
- 支付完成后,调用订单服务更新订单状态为"已支付"
- 更新数据库
orders表status = 'PAID' - 更新 Redis 缓存
order:12345的状态
当时缓存更新代码:
public void updateOrderStatus(Long orderId, String status) {
// 更新数据库
orderDao.updateStatus(orderId, status);
// 更新缓存
redisTemplate.opsForValue().set(
"order:" + orderId,
status,
30, TimeUnit.MINUTES
);
}
这个代码的问题在于:先更新数据库,再更新缓存。如果在两者之间有请求进来,会读到旧数据。
但更严重的问题是 Redis 主从切换时的数据一致性。
根因分析
当时的情况:
- 订单服务部署在两个机房:机房A和机房B
- Redis 是主从结构:机房A是主,机房B是从
- 写操作都打到机房A,读操作读机房B(读写分离)
流程是这样的:
- 用户在机房B下单,支付成功
- 订单服务(机房B)调用机房A的Redis主库更新缓存 → 成功
- 但机房B的Redis从库还没同步到这条数据
- 后续有读请求打到机房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());
}
注意:写操作时,我们选择删除缓存而不是更新缓存。这是有讲究的。
为什么删除而不是更新?
假设写操作顺序是:
- 线程A删除缓存
- 线程B读缓存,没读到,从数据库读到旧数据
- 线程B把旧数据写入缓存
- 线程A把新数据写入数据库
这种情况下,最终缓存里是旧数据。但如果我们用"先更新数据库再更新缓存":
- 线程A更新数据库为新数据
- 线程B来更新缓存,设置了旧数据
- 缓存里又是旧数据了
先删缓存后写数据库,至少能保证:缓存被删除后、数据库更新前这段时间内,读请求会穿透到数据库,但不会读到脏数据。
但这不解决主从延迟问题。
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:
- 延迟双删:增加复杂度,但能覆盖大多数场景
- 读写锁:强一致,但牺牲并发
- 读主库:简单,但增加主库压力
根据业务对一致性的要求选择合适的方案。
