拉斯维加斯的故事
44.25M · 2026-03-17
Redis作为高性能缓存,用于减轻数据库压力,其数据最终来源于数据库,但由于两者是独立的存储系统,且存在“缓存操作”与“数据库操作”的先后顺序、网络延迟、并发读写、节点故障等问题,导致数据一致性被破坏,核心原因主要有以下4点:
操作顺序不合理:缓存与数据库的更新/删除操作没有遵循统一的顺序(如先更缓存再更数据库、先删缓存再更数据库),导致并发场景下出现数据偏差。
并发读写冲突:多个线程同时进行读写操作(如一个线程更新数据库,另一个线程读取缓存),由于操作执行的时序差异,导致读取到旧数据。
缓存异常:缓存过期、缓存击穿、缓存雪崩、缓存穿透等场景,会导致缓存无法提供正确数据,或数据库压力剧增后出现更新不及时。
节点故障:Redis集群节点宕机、数据库主从切换,或网络中断,导致缓存与数据库的操作无法同步执行,出现数据断层。
核心矛盾:缓存的“高性能”(异步、内存操作)与数据库的“持久性”(同步、磁盘操作)存在天然差异,无法通过单一操作保证两者实时一致,需通过特定方案平衡一致性与性能。
核心是通过“两次删除缓存”+“延迟等待”,解决“先更数据库、后删缓存”的时序问题,避免并发场景下旧缓存被读取。
补充说明:第二次删除的目的是清除“在第一次删除缓存后、数据库更新前”,被其他线程读取到的旧数据(这些旧数据会被重新写入缓存),通过延迟等待,确保数据库更新完成后,再清除残留的旧缓存
public void updateData(String key, Object data) {
// 第一次删除缓存
redisTemplate.delete(key);
// 更新数据库
database.update(data);
// 休眠(根据业务耗时估算)
Thread.sleep(500);
// 第二次删除缓存
redisTemplate.delete(key);
}
优化“先更库、后删缓存”的基础方案,核心是解决“删除缓存失败”导致的一致性问题,通过重试机制确保缓存删除操作最终执行成功。
补充说明:优先选择“先更库、后删缓存”,而非“先删缓存、后更库”,是因为“先删缓存、后更库”会导致更新期间,所有请求穿透到数据库,若更新耗时较长,数据库压力会急剧增加;而“先更库、后删缓存”,即使删除失败,缓存中仍有旧数据,可正常提供服务,只是存在短暂不一致,后续重试删除后可恢复一致
public void updateWithRetry(String key, Object data) {
// 1. 更新数据库(事务内)
transactionTemplate.execute(status -> {
database.update(data);
return true;
});
// 2. 尝试删除缓存
try {
redisTemplate.delete(key);
} catch (Exception e) {
// 3. 删除失败,发送到重试队列
sendToRetryQueue(key, data);
}
}
// 独立的重试消费者
@RabbitListener(queues = "cache.delete.retry")
public void processRetry(String key) {
// 带指数退避的重试逻辑
retryTemplate.execute(context -> {
redisTemplate.delete(key);
return null;
});
}
核心是利用数据库的Binlog(二进制日志),异步同步数据库的更新操作到Redis,实现缓存与数据库的最终一致性,无需在业务代码中耦合缓存操作。具体步骤:
部署Canal服务(阿里开源的数据库Binlog解析工具),让Canal模拟MySQL从库,订阅数据库的Binlog日志;
业务系统只更新数据库,不操作缓存,数据库更新后,会记录Binlog日志;
Canal解析Binlog日志,提取出数据库的更新、删除、插入操作,将操作信息(如表名、主键、更新后的数据)发送到消息队列;
消费者消息队列,根据操作信息,同步更新或删除Redis缓存,确保缓存与数据库数据一致。
补充说明:Canal支持MySQL、MariaDB等数据库,可解析Row模式的Binlog(最详细,能获取每行数据的变更),适合对一致性要求较高、业务代码不想耦合缓存操作的场景。
业务应用 --> MySQL
|
Binlog
↓
Canal Server
↓
消息队列(Kafka/RocketMQ)
↓
缓存更新服务
↓
Redis
// Canal客户端示例
@CanalEventListener
public class CacheSyncListener {
@ListenPoint(destination = "example", schema = "business_db",
table = {"product"}, eventType = {EventType.UPDATE, EventType.INSERT})
public void handleProductChange(Product product) {
// 将变更事件发送到MQ
mqTemplate.send("cache-sync-topic",
new CacheSyncMessage("product", product.getId(), product));
}
}
// MQ消费者更新缓存
@KafkaListener(topics = "cache-sync-topic")
public void syncCache(CacheSyncMessage message) {
String key = message.getTable() + ":" + message.getId();
redisTemplate.opsForValue().set(key, message.getData(), 1, TimeUnit.HOURS);
}
核心是通过“锁机制”强制控制并发读写的顺序,确保同一时间只有一个操作(读或写)执行,从而实现缓存与数据库的强一致性,适合对一致性要求极高的场景(如金融、支付)。具体分为两种实现:
读锁(共享锁):多个线程可同时获取读锁,读取缓存/数据库数据,互不干扰;
写锁(排他锁):只有一个线程可获取写锁,获取写锁后,其他线程无法获取读锁和写锁,确保写操作(更新数据库+更新缓存)原子执行;
执行流程:写操作先获取写锁,更新数据库,再更新缓存,释放写锁;读操作先获取读锁,读取缓存,若缓存为空,读取数据库,写入缓存,释放读锁。
无论读操作还是写操作,都需获取同一把互斥锁,同一时间只有一个线程能执行操作;
执行流程:线程获取互斥锁后,若为写操作,更新数据库+更新缓存;若为读操作,读缓存→缓存空则读库→写缓存,执行完成后释放锁。
补充说明:锁可基于Redis实现(如Redis的SETNX命令、Redisson分布式锁),确保分布式环境下的锁有效性。
public class ConsistentCacheService {
@Autowired
private RedissonClient redisson;
public void updateWithLock(String key, Object data) {
RReadWriteLock rwLock = redisson.getReadWriteLock("lock:" + key);
RLock writeLock = rwLock.writeLock();
writeLock.lock();
try {
// 1. 更新数据库
database.update(data);
// 2. 删除缓存(或更新)
redisTemplate.delete(key);
} finally {
writeLock.unlock();
}
}
public Object queryWithLock(String key) {
RReadWriteLock rwLock = redisson.getReadWriteLock("lock:" + key);
RLock readLock = rwLock.readLock();
readLock.lock();
try {
// 1. 读缓存
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
return value;
}
// 2. 读数据库并回写缓存
value = database.query(key);
redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);
return value;
} finally {
readLock.unlock();
}
}
}
在缓存数据中附带版本号或最后更新时间戳,更新时通过CAS(Compare and Swap)机制保证一致性。
{value: xxx, version: 123, updateTime: 1623456789}public class VersionedCacheService {
public void updateWithVersion(String key, Object data) {
// 1. 开启事务更新数据库,版本号+1
int newVersion = database.updateAndReturnVersion(data);
// 2. 构建带版本的数据
VersionedData versionedData = new VersionedData(data, newVersion);
// 3. 尝试更新缓存(只有缓存版本小于新版本才更新)
String luaScript =
"local current = redis.call('get', KEYS[1]) " +
"if current == false or cjson.decode(current).version < ARGV[1] then " +
" redis.call('set', KEYS[1], ARGV[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Arrays.asList(key),
String.valueOf(newVersion),
JSON.toJSONString(versionedData)
);
}
}
采用最终一致性思想,允许短暂不一致,但通过定时任务比对数据库和缓存的数据,发现不一致及时修复。
写请求 -> 更新数据库 -> 发送MQ -> 更新缓存
|
↓
定时对账服务 <---> Redis
| |
↓ ↓
数据库 缓存比对
| |
↓ ↓
不一致则触发修复
@Component
public class ReconciliationTask {
@Scheduled(fixedDelay = 60000) // 每分钟执行一次
public void checkConsistency() {
// 获取最近更新的数据ID列表
List<Long> recentlyUpdatedIds = getRecentlyUpdatedIds();
for (Long id : recentlyUpdatedIds) {
// 从数据库获取最新数据
Data dbData = database.query(id);
// 从缓存获取数据
Data cacheData = redisTemplate.opsForValue().get("data:" + id);
// 比对(忽略时间戳微小差异)
if (!isConsistent(dbData, cacheData)) {
// 触发修复
syncToCache(id, dbData);
log.warn("数据不一致已修复: id={}", id);
}
}
}
}
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 中小项目,并发不高 | 方案二:先更新DB再删缓存+重试 | 简单可靠,容易实现 |
| 大型项目,对一致性要求高 | 方案三:Binlog异步更新(Canal) | 业务解耦,可靠性高 |
| 需要最终一致性兜底 | 方案三 + 方案六 | 主流程+Canal,对账作为最后防线 |
| 强一致性要求(如金融) | 方案四:读写锁 | 牺牲性能换一致性,或直接读DB |
| 多副本同时更新 | 方案五:版本号/CAS | 防止旧数据覆盖新数据 |
| 缓存不可用容忍度低 | 方案二 + 本地缓存兜底 | 多级缓存提高可用性 |