一、缓存与数据库的爱恨纠葛
大家好,今天咱们来聊聊一个让无数程序员又爱又恨的话题 - 缓存和数据库的一致性保障。这就像一对欢喜冤家,缓存想快,数据库要稳,怎么让它们和谐共处可真是个技术活。
想象一下这个场景:你刚在电商网站下单买了最新款手机,回头一看购物车居然还躺着那部手机。这就是典型的缓存和数据库不同步造成的尴尬。缓存本是为了提升性能,但要是处理不好更新策略,反而会带来更多麻烦。
二、常见的缓存更新策略
1. Cache Aside Pattern(旁路缓存)
这是最常见的策略,简单来说就是:
- 读的时候先读缓存,缓存没有就读数据库,然后写入缓存
- 写的时候先更新数据库,再删除缓存
// Java示例 - 使用Spring Boot和Redis
@Repository
public class ProductRepository {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
// 读取商品信息
public Product getProductById(Long id) {
// 1. 先查缓存
Product product = (Product) redisTemplate.opsForValue().get("product:" + id);
if (product != null) {
return product;
}
// 2. 缓存没有,查数据库
product = jdbcTemplate.queryForObject(
"SELECT * FROM products WHERE id = ?",
new Object[]{id},
new BeanPropertyRowMapper<>(Product.class));
// 3. 写入缓存
if (product != null) {
redisTemplate.opsForValue().set("product:" + id, product, 1, TimeUnit.HOURS);
}
return product;
}
// 更新商品信息
public void updateProduct(Product product) {
// 1. 更新数据库
jdbcTemplate.update(
"UPDATE products SET name = ?, price = ? WHERE id = ?",
product.getName(), product.getPrice(), product.getId());
// 2. 删除缓存
redisTemplate.delete("product:" + product.getId());
}
}
2. Write Through(写穿透)
这个策略下,所有写操作都先经过缓存,再由缓存负责同步到数据库。
// Java示例 - Write Through实现
public class ProductCacheStore {
private Map<Long, Product> cache = new ConcurrentHashMap<>();
public Product get(Long id) {
return cache.get(id);
}
public void put(Product product) {
// 先更新缓存
cache.put(product.getId(), product);
// 再由缓存同步到数据库
updateDatabase(product);
}
private void updateDatabase(Product product) {
// 实际数据库更新逻辑
}
}
3. Write Behind(写回)
这个策略更激进,写操作只更新缓存,然后定期批量同步到数据库。
三、策略对比与选择指南
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Cache Aside | 实现简单,可靠性高 | 存在短暂不一致窗口 | 读多写少 |
| Write Through | 强一致性 | 写性能较差 | 写一致性要求高的场景 |
| Write Behind | 写性能极佳 | 可能丢失数据 | 写密集且容忍数据丢失 |
四、进阶问题与解决方案
1. 缓存穿透问题
当查询一个不存在的数据时,每次都会打到数据库。解决方案是缓存空对象或使用布隆过滤器。
// Java示例 - 使用布隆过滤器防止缓存穿透
public class ProductService {
private BloomFilter<Long> productFilter;
public Product getProduct(Long id) {
// 先检查布隆过滤器
if (!productFilter.mightContain(id)) {
return null; // 肯定不存在
}
// 继续正常流程...
}
}
2. 缓存雪崩问题
大量缓存同时失效导致数据库压力激增。解决方案是设置不同的过期时间或使用互斥锁重建缓存。
// Java示例 - 使用互斥锁防止缓存雪崩
public Product getProductWithLock(Long id) {
Product product = getFromCache(id);
if (product == null) {
String lockKey = "product:lock:" + id;
try {
// 获取分布式锁
if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS)) {
// 从数据库加载
product = loadFromDb(id);
// 写入缓存
setToCache(id, product);
} else {
// 等待其他线程加载
Thread.sleep(100);
return getProductWithLock(id);
}
} finally {
redisTemplate.delete(lockKey);
}
}
return product;
}
五、实战中的注意事项
删除缓存失败怎么办? 可以采用重试机制或设置较短的过期时间作为兜底。
先删缓存还是先更新数据库? 通常建议先更新数据库再删缓存,但这不是绝对的。
如何保证操作的原子性? 对于关键业务,可以考虑使用分布式事务。
// Java示例 - 使用事务保证原子性
@Transactional
public void updateProductWithTransaction(Product product) {
// 更新数据库
productRepository.update(product);
// 删除缓存
redisTemplate.delete("product:" + product.getId());
}
六、总结与最佳实践
经过上面的探讨,我们可以得出一些最佳实践:
- 对于大多数场景,Cache Aside模式是最佳选择
- 写操作建议先更新数据库再删缓存
- 设置合理的缓存过期时间
- 针对特殊问题使用对应的解决方案
- 监控是关键,要密切关注缓存命中率和数据库负载
记住,没有银弹,最适合你业务的方案才是最好的方案。希望这篇文章能帮助你在缓存和数据库一致性的道路上少踩些坑!
评论