Redis 作为一种高性能的内存型数据存储,经常被用作缓存,配合传统关系型数据库(如 MySQL)使用,以提升系统的查询性能。然而,由于 Redis 和数据库之间是两个独立的存储系统,如何保证它们的数据一致性成为一个关键问题。
常见场景
在系统中,Redis 通常作为缓存使用,与数据库形成 "读多写少" 的业务场景。以下是几个常见的操作场景:
-
查询数据:
- 优先从 Redis 缓存读取数据。
- 如果缓存中没有(即缓存穿透),则从数据库中查询,并将结果写入缓存。
-
更新数据:
- 数据更新后,需要同时更新 Redis 和数据库的数据。
-
删除数据:
- 删除数据库中的数据后,缓存中的数据也需要删除。
Redis 和数据库数据一致性问题详解
Redis 作为一种高性能的内存型数据存储,经常被用作缓存,配合传统关系型数据库(如 MySQL)使用,以提升系统的查询性能。然而,由于 Redis 和数据库之间是两个独立的存储系统,如何保证它们的数据一致性成为一个关键问题。
本文将从 常见场景、一致性问题分析 和 解决方案 三个方面进行探讨,并附带代码示例。
一、常见场景
在系统中,Redis 通常作为缓存使用,与数据库形成 "读多写少" 的业务场景。以下是几个常见的操作场景:
-
查询数据:
- 优先从 Redis 缓存读取数据。
- 如果缓存中没有(即缓存穿透),则从数据库中查询,并将结果写入缓存。
-
更新数据:
- 数据更新后,需要同时更新 Redis 和数据库的数据。
-
删除数据:
- 删除数据库中的数据后,缓存中的数据也需要删除。
二、一致性问题分析
先删除数据库再更新缓存 和 先更新缓存再更新数据库。这两种顺序各有优劣,同时也存在潜在问题。
先删除数据库,再更新缓存
执行流程
- 删除数据库中的数据。
- 更新缓存中的数据。
优势
- 操作简单:更新数据库后直接更新缓存,不涉及复杂的依赖或锁机制。
- 适用于读多写少的场景:因为数据在更新后立即写入缓存,下一次读取可直接命中缓存。
问题分析
-
并发问题:
- 如果在删除数据库和更新缓存之间有读取操作,会出现数据不一致的短暂窗口期。
- 示例场景:
- 线程 A 执行:更新数据库。
- 线程 B 在此时读取缓存,发现是旧值。
- 线程 A 更新缓存。
- 最终,线程 B 得到的仍是旧数据。
【另外一个策略存在的问题差不多 最终都可能会导致高并发情况下缓存和数据库不一致问题】
目前主流解决数据库和缓存不一致的方案
1.延时双删
延时双删策略主要用于解决在高并发场景下,由于网络延迟、并发控制等原因造成的数据库与缓存数据不一致的问题。
当更新数据库时,首先删除对应的缓存项,以确保后续的读请求会从数据库加载最新数据。
但是由于网络延迟或其他不确定性因素,删除缓存与数据库更新之间可能存在时间窗口,导致在这段时间内的读请求从数据库读取数据后写回缓存,新写入的缓存数据可能还未反映出数据库的最新变更。
所以为了解决这个问题,延时双删策略在第一次删除缓存后,设定一段短暂的延迟时间,如几百毫秒,然后在这段延迟时间结束后再次尝试删除缓存。这样做的目的是确保在数据库更新传播到所有节点,并且在缓存中的旧数据彻底过期失效之前,第二次删除操作可以消除缓存中可能存在的旧数据,从而提高数据一致性。
【存在的问题:两段删除之间的时间不好把握,且删除与删除之间的间隙内若存在请求查询,仍然会导致部分请求数据不一致问题】
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.TimeUnit;
@Service
public class DataService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private DatabaseService databaseService; // 假设有一个 service 用于操作数据库
/**
* 更新数据并执行延迟双删操作
* @param key 缓存的键
* @param newValue 更新后的新值
*/
public void updateDataWithDelayDoubleDelete(String key, Object newValue) {
// Step 1: 更新数据库
boolean updateDbSuccess = databaseService.updateData(key, newValue);
if (updateDbSuccess) {
// Step 2: 删除缓存
redisTemplate.delete(key);
// Step 3: 延迟 1 秒后再次删除缓存
new Timer().schedule(new TimerTask() {
@Override
public void run() {
redisTemplate.delete(key); // 再次删除缓存
}
}, 1000); // 延迟 1 秒
} else {
throw new RuntimeException("更新数据库失败!");
}
}
}
延迟双删的策略,通过对缓存的二次删除,尽量避免数据库与缓存之间的短暂不一致情况,虽然会引入一定的延迟,但可以在高并发环境下有效减少一致性问题。需要注意的是,延迟时间的选择需要根据实际业务场景来调整,以达到最佳的性能和一致性平衡。
2.监听并读取binlog异步删除缓存
上述延迟双删的主要缺陷在于对于两段删除的时间间隔很难进行一个合理的设定,而我们知道对数据库的所有操作都会记录到binlog中,那么我们可以使用一些异步中间件组件去监听binlog(例如使用Canal),当数据库数据发生变动的时候,将更新缓存的数据发送到消息队列,并由特定的消费者消费队列中的信息,异步的更新或删除缓存中的数据,从而确保缓存和数据库的数据一致。
实现步骤
- 搭建 Canal 服务:
- 首先需要搭建 Canal 服务器并配置好 MySQL binlog 监听。
- 配置 Canal 连接 MySQL 数据库,监听指定的数据库和表。
- 编写异步消费 binlog 的代码:
- 使用 Canal 的客户端 API 来异步消费 MySQL binlog 文件中的变更事件。
- 将接收到的变更事件进行处理,比如更新缓存或进行消息队列推送。
步骤:
1、需要MySQL开启 binlog 以支持 Canal 捕获数据变更,修改完配置后,重启 MySQL 服务。
-- 编辑 my.cnf 配置文件
[mysqld]
log-bin=mysql-bin
binlog-format=row
server-id=1 -- 设置唯一的服务器 ID
2、Canal 提供了两种方式来同步 MySQL 数据:Canal Server 和 Canal Client。一般来说,我们可以使用 Canal Server,它可以监听 MySQL 的 binlog,并提供数据变更的推送。
-
下载 Canal,解压并配置。
-
配置
instance.properties
(在conf
目录下):
3、添加 Canal 的依赖:
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.4</version> <!-- 使用 Canal 版本 -->
</dependency>
4、示例Demo
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.Client;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.Message;
import com.alibaba.otter.canal.protocol.CanalEntry;
import redis.clients.jedis.Jedis;
import java.util.List;
public class CanalRedisSync {
private static final String CANAL_SERVER_HOST = "127.0.0.1";
private static final int CANAL_SERVER_PORT = 11111;
private static final String REDIS_HOST = "127.0.0.1";
private static final int REDIS_PORT = 6379;
public static void main(String[] args) {
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress(CANAL_SERVER_HOST, CANAL_SERVER_PORT),
"example", "", "");
// 连接 Canal
connector.connect();
connector.subscribe(".*\\..*"); // 订阅所有数据库的所有表
Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT); // Redis 客户端
try {
while (true) {
Message message = connector.getWithoutAck(100); // 获取变更数据
long batchId = message.getId();
List<CanalEntry.Entry> entries = message.getEntries();
if (batchId == -1 || entries.isEmpty()) {
System.out.println("没有变更数据,继续等待...");
try {
Thread.sleep(1000); // 没有数据时休息1秒
} catch (InterruptedException e) {
e.printStackTrace();
}
continue;
}
// 处理每一条变更记录
for (CanalEntry.Entry entry : entries) {
if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) {
CanalEntry.RowChange rowChange = null;
try {
rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
} catch (Exception e) {
e.printStackTrace();
}
// 获取变更的数据
List<CanalEntry.RowData> rowDatas = rowChange.getRowDatasList();
for (CanalEntry.RowData rowData : rowDatas) {
// 根据操作类型进行 Redis 操作
switch (rowChange.getEventType()) {
case INSERT:
// INSERT 操作将数据添加到 Redis
handleInsert(rowData, jedis);
break;
case UPDATE:
// UPDATE 操作更新 Redis 中的值
handleUpdate(rowData, jedis);
break;
case DELETE:
// DELETE 操作删除 Redis 中的值
handleDelete(rowData, jedis);
break;
default:
break;
}
}
}
}
// 提交处理过的消息
connector.ack(batchId);
}
} finally {
connector.disconnect();
jedis.close();
}
}
// 插入操作处理
private static void handleInsert(CanalEntry.RowData rowData, Jedis jedis) {
String key = rowData.getAfterColumns(0).getValue(); // 假设第一个字段是主键
String value = rowData.getAfterColumns(1).getValue(); // 假设第二个字段是值
jedis.set(key, value); // 将数据插入 Redis
}
// 更新操作处理
private static void handleUpdate(CanalEntry.RowData rowData, Jedis jedis) {
String key = rowData.getAfterColumns(0).getValue(); // 假设第一个字段是主键
String value = rowData.getAfterColumns(1).getValue(); // 假设第二个字段是值
jedis.set(key, value); // 更新 Redis 中的值
}
// 删除操作处理
private static void handleDelete(CanalEntry.RowData rowData, Jedis jedis) {
String key = rowData.getBeforeColumns(0).getValue(); // 假设第一个字段是主键
jedis.del(key); // 删除 Redis 中的值
}
}
注意事项
-
数据一致性:Canal 实时同步 MySQL 的数据变化到 Redis,但由于 Canal 是基于异步推送的,可能会有延迟,因此需要在设计中考虑可能的数据一致性问题。例如,可以通过定时任务校验 MySQL 和 Redis 的数据是否一致,或者使用事务和消息队列进一步确保一致性。
-
处理数据量:如果表的更新频率很高,或者单次更新的数据量大,可能需要优化 Canal 客户端的处理逻辑。比如批量处理数据、使用异步 Redis 操作等。
-
性能调优:在大数据量的情况下,可以使用 Redis 的管道机制(pipeline)来提高性能,减少网络延迟。
-
Redis 数据结构的选择:根据应用场景选择适当的 Redis 数据结构,例如使用
Hash
、List
、Set
等,以提高操作效率。