在分布式架构中,将 "DB 写入与 Redis 写入" 强耦合在同一个业务逻辑内(即传统的"双写"模式),其核心弊端源于本地事务无法覆盖分布式资源。
以下是该模式在高性能社交场景(如:关注功能)中的两个致命缺陷分析。
分布式双写模式:缺陷深度剖析
1. 原子性幻觉
在分布式环境下,网络不再是透明的。当调用 Redis 操作时,存在"请求丢失"、"处理超时"和"响应丢失"三种状态。
- 缺陷表现:若 Redis 操作已在服务端执行成功,但响应在回传给 Java 应用时发生丢包或超时,Java 端会抛出异常并触发 MySQL 事务回滚。
- 结果:数据库中的记录被撤销,但 Redis 中的缓存数据已永久生效且无法自动回滚。系统进入"数据库无记录、缓存有数据"的非一致性状态。
2. 事务可见性窗口带来的脏读
MySQL 的事务隔离级别(通常为 RR 或 RC)决定了数据在未 COMMIT 前对其他线程不可见,而 Redis 操作是即时生效的。
- 缺陷表现 :线程 A 在事务中执行了
DB 插入和Redis 写入。此时 Redis 已更新完毕,但 MySQL 事务尚未完成最终提交。 - 并发冲突:此时线程 B(查询线程)访问 Redis,判定为"已关注",随即根据业务需求去 MySQL 查询关注详情或关联信息。由于线程 A 事务未提交,线程 B 将查询到空数据或旧数据。
实例演练:社交系统"关注功能"失效分析
假设用户点击"关注"一名大 V,系统执行如下伪代码:
java
@Transactional
public void follow(Long starId) {
// 步骤 1: MySQL 插入关注记录 (执行成功)
followMapper.insert(userId, starId);
// 步骤 2: Redis SADD 写入缓存 (执行成功,但响应因网络抖动超时)
redis.sadd("follows:" + userId, starId);
// 步骤 3: 逻辑抛出 TimeoutException,MySQL 触发回滚
}
最终一致性崩溃过程:
- 数据状态断裂 :MySQL 事务回滚,
tb_follow表中无记录。然而 Redis 已处理SADD指令,缓存中显示 Zilin 已关注该大 V。 - 前端反馈偏差 :当用户刷新页面时,系统通过
isFollow接口优先查询 Redis,返回true。前端显示"已关注"。 - 核心业务报错:当用户点击查看"关注列表详情"时,由于列表详情需要从 MySQL 进行分页查询或多表关联,查询结果为空。
- 死锁逻辑 :此时用户尝试点击"取消关注"来修复状态,但由于 MySQL 中不存在该记录,取关逻辑(
DELETE)执行受阻或返回异常,系统陷入无法自愈的僵尸状态。
解决方案:架构重构
这种弊端的根源在于将 "高可靠的持久化(DB)" 与 "高性能的易失存储(Redis)" 放在了同一个线性执行流中。
要彻底解决该问题,业界标准的做法是将两者解耦:
- 方案 A(轻量级) :利用 Spring 事务同步器,将 Redis 操作移出本地事务,确保 DB 提交后才执行缓存更新。
- 方案 B(工程级) :利用 Binlog 监听(Canal) 或 可靠消息队列(MQ),由数据库的最终状态驱动缓存的更新,实现数据的最终一致性。
方案 A:Spring 事务同步器(解耦神器)
方案 A 的核心逻辑是:将 Redis 的写入操作"挂载"到当前事务上,只有当数据库物理提交(Commit)成功后,才触发 Redis 操作。
核心实现:TransactionUtils.java
它利用了 Spring 的 TransactionSynchronizationManager。
java
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
public class TransactionUtils {
/**
* 只有在当前事务成功提交(COMMIT)后,才执行传入的任务
* @param task 待执行的 Redis 或其他异步逻辑
*/
public static void doAfterCommit(Runnable task) {
// 1. 检查当前线程是否存在活跃的事务
if (TransactionSynchronizationManager.isActualTransactionActive()) {
// 2. 注册一个"钩子",监听事务状态
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// 3. 只有数据库 COMMIT 成功后,才回调此方法
task.run();
}
});
} else {
// 4. 如果当前没有事务(例如非增删改逻辑),直接同步执行
task.run();
}
}
}
改造后的业务伪代码
使用上述工具类后,关注逻辑变得极其稳健:
java
@Transactional
public void follow(Long starId) {
// 步骤 1: MySQL 插入记录
// 即使这里成功了,数据库还没真正 Commit,其他线程看不见
followMapper.insert(userId, starId);
// 步骤 2: 注册事务后置任务 (方案 A)
TransactionUtils.doAfterCommit(() -> {
// 只有步骤 3 成功后,才会执行这里
// 彻底解决了"数据库回滚但 Redis 写入"的问题
redis.sadd("follows:" + userId, starId);
});
// 步骤 3: 事务结束,Spring 自动执行 DB Commit
// 若此处发生异常,事务回滚,上面的 Lambda 表达式永远不会被调用
}
方案 B:Canal + RocketMQ 实现缓存最终一致性
通过 Canal 监听 Binlog 并投递到 RocketMQ,我们将同步逻辑从业务流中彻底剥离。
1. Canal Server 核心配置
要让 Canal 切换到 MQ 模式,需要修改两个核心配置文件。
① conf/canal.properties (全局配置)
设置输出模式为 RocketMQ,并配置服务器地址。
| 配置项 | 值 | 说明 |
|---|---|---|
canal.serverMode |
rocketmq |
开启 MQ 模式 |
rocketmq.namesrv.addr |
127.0.0.1:9876 |
RocketMQ 地址 |
canal.mq.flatMessage |
true |
关键:使用扁平 JSON 格式,方便解析 |
② conf/example/instance.properties (实例配置)
配置监听的数据库信息以及消息投递的 Topic。
properties
# MySQL 连接信息
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
# 消息过滤与投递
canal.mq.topic=canal-test-topic # 投递到此 Topic
2. Java 端集成实现
Canal已经集成了Producer端,Java 端不再需要主动连接 Canal,只需作为消费者监听 MQ 消息,并配合策略模式解耦多表逻辑。
① 通用策略接口 EntryHandler
定义统一的处理标准,后续增加新表同步只需实现此接口。
java
import com.alibaba.otter.canal.protocol.FlatMessage;
public interface EntryHandler {
void handle(FlatMessage flatMessage);
String getTableName();
}
② 核心消费者 CanalMqConsumer
负责接收 MQ 消息并根据表名分发给对应的 Handler。
java
@Slf4j
@Component
@RocketMQMessageListener(topic = "${canal.mq.topic}", consumerGroup = "canal-group")
public class CanalMqConsumer implements RocketMQListener<String> {
@Resource
private CanalHandlerContext handlerContext; // 策略分发上下文
@Override
public void onMessage(String message) {
FlatMessage flatMessage = JSON.parseObject(message, FlatMessage.class);
if (flatMessage == null) return;
EntryHandler handler = handlerContext.getHandler(flatMessage.getTable());
if (handler != null) {
try {
handler.handle(flatMessage);
} catch (Exception e) {
log.error("数据同步失败,触发 MQ 重试: {}", message, e);
throw new RuntimeException(e); // 抛出异常触发 MQ 重试
}
}
}
}
③ 业务处理器 FollowHandler (以关注表为例)
具体的 Redis 写入逻辑封装在这里。
java
@Component
public class FollowHandler implements EntryHandler {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public String getTableName() { return "tb_follow"; }
@Override
public void handle(FlatMessage flatMessage) {
String type = flatMessage.getType();
List<Map<String, String>> dataList = flatMessage.getData();
for (Map<String, String> rowData : dataList) {
String userId = rowData.get("user_id");
String followUserId = rowData.get("follow_user_id");
String redisKey = "follows:" + userId;
if ("INSERT".equals(type)) {
stringRedisTemplate.opsForSet().add(redisKey, followUserId);
} else if ("DELETE".equals(type)) {
stringRedisTemplate.opsForSet().remove(redisKey, followUserId);
}
}
}
}