1. 当缓存遇上多线程:一个快递柜引发的思考
想象一下这样的场景:小区快递柜被多个快递员同时使用,A快递员正在往3号柜存包裹时,B快递员刚好扫码打开了3号柜。这种"存取冲突"在Redis多线程读写时每天都在上演。我们的缓存就像这个快递柜系统,当多个线程同时读写时,如果没有合适的协调机制,就可能出现数据错乱、旧数据残留等问题。
典型的异常场景包括:
- 线程A更新数据库后删除缓存时,线程B却读取到了旧数据
- 高频更新导致缓存频繁失效,引发缓存雪崩
- 写后立即读操作获取到更新前的脏数据
2. 问题根源剖析:多线程缓存操作的陷阱
2.1 数据覆盖危机
// Java示例使用Spring Data Redis
public void updateProduct(Product product) {
// 线程A开始更新
redisTemplate.delete("product:" + product.getId()); // 删除旧缓存
db.update(product); // 更新数据库(耗时操作)
// 在线程A更新数据库期间...
// 线程B查询相同商品
Product cached = redisTemplate.opsForValue().get("product:" + product.getId());
if(cached == null) {
cached = db.get(product.getId()); // 此时获取到的是旧数据
redisTemplate.opsForValue().set("product:" + product.getId(), cached);
}
// 最终缓存被旧数据覆盖
}
技术栈:Java + Spring Data Redis
注释说明:
- 线程A先删缓存后更新数据库
- 在线程A更新数据库期间,线程B查询导致旧数据重新加载到缓存
- 最终数据库是新数据,缓存却是旧数据
3. 解决方案大比武
3.1 读写锁方案:给缓存操作上把"智能锁"
// 使用ReentrantReadWriteLock
private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public Product getProduct(Long id) {
lock.readLock().lock();
try {
// 读锁保证并发读不受限
Product product = redisTemplate.opsForValue().get("product:"+id);
if(product == null) {
// 释放读锁,获取写锁(防止多个线程同时查库)
lock.readLock().unlock();
lock.writeLock().lock();
try {
// 双重检查
product = redisTemplate.opsForValue().get("product:"+id);
if(product == null) {
product = db.get(id);
redisTemplate.opsForValue().set("product:"+id, product, 30, TimeUnit.MINUTES);
}
} finally {
lock.writeLock().unlock();
// 重新获取读锁保持方法对称
lock.readLock().lock();
}
}
return product;
} finally {
lock.readLock().unlock();
}
}
技术栈:Java并发库 + Spring Data Redis
优势:
- 读操作并发性能高
- 写操作互斥保证安全
缺点: - 锁粒度过粗会影响性能
- 需要处理锁升级问题
3.2 版本号控制:给数据贴上"身份证"
> SET product:1001_version 1
> INCR product:1001_version # 每次更新递增版本号
# 数据存储结构
{
"id": 1001,
"name": "智能手表",
"version": 3,
"...": "..."
}
public void updateProduct(Product newProduct) {
// 获取当前版本
Long currentVersion = redisTemplate.opsForValue().get("product:"+newProduct.getId()+"_version");
// 乐观锁控制
if(newProduct.getVersion() <= currentVersion) {
throw new OptimisticLockException("版本过期,请刷新重试");
}
// 原子操作更新
redisTemplate.execute(new SessionCallback<>() {
public Object execute(RedisOperations operations) {
operations.watch("product:"+newProduct.getId());
operations.multi();
operations.opsForValue().set("product:"+newProduct.getId(), newProduct);
operations.opsForValue().increment("product:"+newProduct.getId()+"_version");
return operations.exec();
}
});
}
技术栈:Redis事务 + 版本控制
适用场景:
- 高频更新业务(如库存秒杀)
- 需要保证最终一致性的场景
4. 进阶方案:组合拳的威力
4.1 延时双删策略:给缓存更新加个"缓冲期"
public void updateProduct(Product product) {
// 第一次删除
redisTemplate.delete("product:"+product.getId());
// 数据库更新
db.update(product);
// 延时二次删除(使用异步线程)
CompletableFuture.runAsync(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500); // 根据业务设置合理延时
redisTemplate.delete("product:"+product.getId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
注意事项:
- 延时时间需要根据业务负载动态调整
- 必须配合重试机制防止删除失败
5. 技术选型指南:没有银弹,只有合适的鞋
5.1 方案对比矩阵
方案 | 适用QPS | 数据一致性 | 实现复杂度 | 额外开销 |
---|---|---|---|---|
读写锁 | < 5k | 强一致 | 高 | 中等 |
版本控制 | > 10k | 最终一致 | 中等 | 低 |
延时双删 | 1k-5k | 最终一致 | 低 | 低 |
6. 避坑指南:血泪经验总结
- 锁粒度控制:某电商平台曾因全局锁导致性能下降80%,后改为按商品ID哈希分桶加锁
- 版本号存储:建议与业务数据分开存储,使用Redis Hash结构管理
- 监控告警:必须监控缓存命中率、锁等待时间等关键指标
- 压测验证:某社交APP在版本更新后未做压测,导致缓存击穿引发宕机
7. 总结:在一致性与性能的天平上
通过本文的多个实战方案可以看到,缓存一致性问题的解决需要根据具体业务场景选择合适策略。对于交易类系统,可能需要牺牲部分性能换取强一致性;而对于资讯类平台,可以接受短暂的不一致来换取更高的吞吐量。
关键决策点:
- 业务对数据时效性的容忍度
- 系统的读写比例
- 基础设施的监控能力
- 团队的技术储备
未来随着Redis 6.0多线程模型的普及,还需要关注IO线程与worker线程的协同问题。缓存一致性这场攻防战,永远在性能与正确性之间寻找最佳平衡点。