在混合使用MySQL和Redis的现代应用架构中,数据一致性问题是每个开发者迟早要面对的"必修课"。我经历过多次生产环境的数据不一致事故,最严重的一次导致电商平台出现超卖现象。这种架构下,MySQL作为持久化存储的"单一数据源"(Single Source of Truth),Redis作为高性能缓存层,两者之间的数据同步存在天然的时序难题。
核心矛盾在于:MySQL的写入操作需要经过事务保证ACID特性,而Redis的写入则是异步、非事务性的。当用户更新MySQL中的数据后,如果缓存更新失败或延迟,后续请求读取到的就是脏数据。更复杂的是,在高并发场景下,多个线程可能同时触发缓存更新,导致竞态条件(Race Condition)。
关键认知:数据不一致不是"是否发生"的问题,而是"何时发生"和"如何控制影响范围"的问题。我们需要的是最终一致性策略,而非强一致性。
在实践中,我测试过所有主流缓存更新策略,每种方案都有其适用场景和潜在陷阱:
| 策略 | 写操作顺序 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside | 先写DB,再删缓存 | 实现简单,读多写少性能好 | 存在缓存穿透风险 | 通用场景 |
| Write-Through | 先写缓存,缓存同步写DB | 强一致性保证 | 写入性能差,缓存故障影响持久化 | 金融交易等强一致性场景 |
| Write-Behind | 先写缓存,异步批量写DB | 写入性能极高 | 数据丢失风险大 | 日志、metrics收集 |
| Refresh-Ahead | 定期预加载即将过期的热点数据 | 读性能最优 | 实现复杂,预测准确性要求高 | 热点数据预加载 |
这是设计时第一个需要明确的决策点。我的经验法则是:
一个实际案例:在用户画像系统中,用户基础信息(如头像URL)变更采用更新策略,因为读取频率极高;而用户行为统计采用删除策略,因为写入频繁且计算成本低。
这是我在电商系统中验证有效的方案,核心流程:
java复制public void updateProduct(Product product) {
// 第一次删除
redis.del("product:" + product.id);
// 数据库更新
db.update(product);
// 延迟二次删除
executor.schedule(() -> {
redis.del("product:" + product.id);
}, 500, TimeUnit.MILLISECONDS);
}
关键参数说明:
实测数据:在QPS 3000的压力下,该方案将不一致时间窗口从平均2s缩短到200ms以内
对于核心业务数据,我推荐使用Canal监听MySQL binlog的方案:
python复制def process_binlog_event(event):
if event.type == UPDATE or event.type == DELETE:
redis_key = f"{event.table}:{event.primary_key}"
redis.delete(redis_key)
python复制last_delete_time = {}
def process_binlog_event(event):
key = f"{event.table}:{event.primary_key}"
now = time.time()
if now - last_delete_time.get(key, 0) > 1.0: # 1秒内不重复删除
redis.delete(key)
last_delete_time[key] = now
在秒杀场景中,我遇到过由于缓存频繁失效导致的"缓存击穿"问题。最终采用的方案是:
java复制RLock lock = redisson.getLock("product_lock:" + productId);
try {
lock.lock();
// 查询数据库
Product product = db.query(productId);
// 更新缓存
redis.setex("product:" + productId, 3600, serialize(product));
} finally {
lock.unlock();
}
java复制// 使用Caffeine作为二级缓存
LoadingCache<String, Product> localCache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.SECONDS)
.build(productId -> {
return redis.get("product:" + productId);
});
在一次大促前的压测中,我们模拟出缓存集群宕机时数据库直接被流量打挂的情况。最终实施的方案包括:
多级缓存架构:
熔断降级策略:
java复制// 使用Hystrix实现熔断
@HystrixCommand(fallbackMethod = "getProductFallback")
public Product getProduct(String id) {
return productService.getById(id);
}
public Product getProductFallback(String id) {
return getFromLocalCache(id); // 降级到本地缓存
}
我们开发了定时扫描系统来主动发现不一致问题:
python复制def check_consistency():
# 扫描最近更新的1000条记录
recent_updates = db.query("SELECT id FROM products ORDER BY update_time DESC LIMIT 1000")
for product in recent_updates:
db_data = db.query("SELECT * FROM products WHERE id = ?", product.id)
redis_data = redis.get(f"product:{product.id}")
if not deep_equal(db_data, redis_data):
alert(f"Inconsistency found on product {product.id}")
# 自动修复逻辑
redis.set(f"product:{product.id}", serialize(db_data))
在Grafana中配置的核心监控面板包括:
经过多个项目的实践验证,我总结了这些黄金法则:
缓存TTL设置原则:
重试策略的最佳实践:
java复制// 使用指数退避重试
RetryPolicy retryPolicy = new ExponentialBackoffRetry(100, 3, 1000);
redisTemplate.executeWithRetry(retryPolicy, () -> {
redis.delete(key);
});
缓存key设计规范:
压测时的观察重点:
在实际项目中,我们通过上述方案将核心业务的数据不一致时间窗口控制在500ms以内,缓存命中率长期保持在98%以上。记住,没有银弹方案,需要根据业务特点组合使用这些策略。