Redis缓存与数据库数据一致性问题

解决方案与最佳实践

问题概述

在使用Redis作为缓存层时,当数据库中的数据发生变化,如果不及时更新或删除缓存,就会导致缓存数据与数据库数据不一致的问题。

解决模式

Cache-Aside模式(旁路缓存模式)

应用程序负责维护缓存和数据库之间的一致性。读取数据时先查缓存,缓存未命中时从数据库读取并更新缓存;写入数据时先更新数据库,然后使缓存失效。


// 读取数据
public Data get(String key) {
    // 1. 从缓存读取
    Data data = redisCache.get(key);
    
    // 2. 缓存未命中,从数据库读取
    if (data == null) {
        data = database.get(key);
        // 3. 将数据库数据写入缓存
        if (data != null) {
            redisCache.set(key, data);
        }
    }
    
    return data;
}

// 更新数据
public void update(String key, Data newData) {
    // 1. 更新数据库
    database.update(key, newData);
    
    // 2. 删除缓存
    redisCache.delete(key);
}
                        

Write-Through模式(直写模式)

写入数据时,同时更新缓存和数据库。读取数据时直接从缓存获取。


// 读取数据
public Data get(String key) {
    // 直接从缓存读取
    return redisCache.get(key);
}

// 更新数据
public void update(String key, Data newData) {
    // 同时更新数据库和缓存
    database.update(key, newData);
    redisCache.set(key, newData);
}
                        

Write-Behind模式(异步写入模式)

写入数据时,先更新缓存,然后异步批量更新数据库,提高写性能。


// 读取数据
public Data get(String key) {
    // 直接从缓存读取
    return redisCache.get(key);
}

// 更新数据
public void update(String key, Data newData) {
    // 1. 更新缓存
    redisCache.set(key, newData);
    
    // 2. 将更新操作放入队列,异步更新数据库
    updateQueue.add(new UpdateOperation(key, newData));
}

// 异步更新线程
public void asyncDatabaseUpdater() {
    while (true) {
        List batch = updateQueue.getBatch(MAX_BATCH_SIZE);
        if (!batch.isEmpty()) {
            database.batchUpdate(batch);
        }
        Thread.sleep(BATCH_INTERVAL);
    }
}
                        

Refresh-Ahead模式(预刷新模式)

系统智能地预测哪些数据可能会被访问,在数据过期之前主动刷新缓存。


// 定时刷新任务
@Scheduled(fixedRate = 60000) // 每分钟执行一次
public void refreshHotData() {
    List hotKeys = accessStatistics.getTopAccessedKeys(100);
    
    for (String key : hotKeys) {
        CacheEntry entry = redisCache.getWithMeta(key);
        
        // 如果数据即将过期(例如 TTL < 5分钟),提前刷新
        if (entry != null && entry.getTtl() < 300) {
            Data freshData = database.get(key);
            redisCache.set(key, freshData);
        }
    }
}
                        

具体策略

双删策略

为了解决更新数据库后,可能其他线程把旧数据读入缓存的问题,可以采用双删策略。


// 更新数据
public void update(String key, Data newData) {
    // 1. 先删除缓存
    redisCache.delete(key);
    
    // 2. 更新数据库
    database.update(key, newData);
    
    // 3. 延时再次删除缓存,避免其他线程将旧数据写入缓存
    executor.schedule(() -> {
        redisCache.delete(key);
    }, 500, TimeUnit.MILLISECONDS);
}
                        

消息队列保证最终一致性

使用消息队列在缓存和数据库操作之间进行解耦,确保最终一致性。


// 更新数据
public void update(String key, Data newData) {
    // 1. 更新数据库
    database.update(key, newData);
    
    // 2. 发送消息到消息队列
    messageQueue.send(new CacheInvalidateMessage(key));
}

// 消息消费者
@KafkaListener(topics = "cache-invalidate")
public void processCacheInvalidation(CacheInvalidateMessage message) {
    // 收到消息后删除对应的缓存
    redisCache.delete(message.getKey());
}
                        

TTL策略

为缓存设置合理的过期时间,过期后自动从数据库加载新数据。


// 读取数据
public Data get(String key) {
    // 从缓存读取
    Data data = redisCache.get(key);
    
    // 缓存未命中,从数据库读取
    if (data == null) {
        data = database.get(key);
        // 设置缓存并添加过期时间
        if (data != null) {
            redisCache.set(key, data, 5, TimeUnit.MINUTES);
        }
    }
    
    return data;
}

// 更新数据
public void update(String key, Data newData) {
    // 只需要更新数据库,缓存会自动过期
    database.update(key, newData);
}
                        

动态演示

方案对比

策略 优点 缺点 适用场景
Cache-Aside
  • 实现简单
  • 读多写少场景高效
  • 可能产生短暂不一致
  • 首次访问延迟高
读多写少的通用场景
Write-Through
  • 保证强一致性
  • 读取性能高
  • 写入延迟增加
  • 可能存在冷数据
需要强一致性的场景
Write-Behind
  • 写入性能极高
  • 可批量处理提升效率
  • 一致性保证弱
  • 数据可能丢失
高并发写入、对一致性要求不高的场景
双删策略
  • 解决并发读写问题
  • 实现简单
  • 延迟删除可能失败
  • 增加系统复杂性
读写并发较高的场景
消息队列
  • 系统解耦
  • 保证最终一致性
  • 引入消息队列增加复杂度
  • 一致性有延迟
高可用分布式系统
TTL策略
  • 实现最简单
  • 自动过期机制
  • 不一致窗口期较长
  • 过期设置需要权衡
对一致性要求不高、数据变化较少的场景