插入时先写DB后写Redis?分布式中传统双写模式的缺陷

在分布式架构中,将 "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 触发回滚
}

最终一致性崩溃过程:

  1. 数据状态断裂 :MySQL 事务回滚,tb_follow 表中无记录。然而 Redis 已处理 SADD 指令,缓存中显示 Zilin 已关注该大 V。
  2. 前端反馈偏差 :当用户刷新页面时,系统通过 isFollow 接口优先查询 Redis,返回 true。前端显示"已关注"。
  3. 核心业务报错:当用户点击查看"关注列表详情"时,由于列表详情需要从 MySQL 进行分页查询或多表关联,查询结果为空。
  4. 死锁逻辑 :此时用户尝试点击"取消关注"来修复状态,但由于 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);
            }
        }
    }
}
相关推荐
数据知道1 小时前
MongoDB分片集群备份与恢复:复杂环境下的数据保护方案详解
数据库·mongodb
桌面运维家2 小时前
Linux VHD 虚拟磁盘更新指南:高效管理与优化
linux·运维·数据库
宇灬宇2 小时前
Oracle 到 PostgreSQL迁移(ora2pg)
数据库·postgresql·oracle
泯仲2 小时前
从零起步学习MySQL 第九章:从数据页的角度看B+树及MySQL中数据的底层存储原理
数据库·b树·mysql
TTc_2 小时前
对于子查询语句多条sql报错排查
数据库·sql·mybatis
gp3210262 小时前
开放自己本机的mysql允许别人连接
数据库·mysql·adb
高铭杰2 小时前
Postgresql源码(155)Redo系列CLOG Redo (RM_CLOG_ID = 3)
数据库·postgresql·redo·clog
原来是猿2 小时前
MySQL【表的约束下】
数据库·mysql
6+h2 小时前
【MySQL】索引原理详解
数据库·mysql