锁住了检查,锁不住延迟——MySQL 读写分离架构下双重检查模式失效实录

目录

    • 问题本质
    • 场景还原
      • [第一步:Docker 起一套 MySQL 主从](#第一步:Docker 起一套 MySQL 主从)
      • 第二步:最小复现代码
      • [第三步:运行结果(真实 MySQL 主从实测输出)](#第三步:运行结果(真实 MySQL 主从实测输出))
      • 关键点
    • 根因分析
    • 解决方案
      • [方案 A:锁内幂等查询强制走主库](#方案 A:锁内幂等查询强制走主库)
      • [方案 B:数据库唯一索引兜底 --- 落库最后防线](#方案 B:数据库唯一索引兜底 — 落库最后防线)
      • [方案 C:多实例下按业务键串行化 --- 可选的冲突削峰层](#方案 C:多实例下按业务键串行化 — 可选的冲突削峰层)
      • [方案 D:外部副作用的端到端幂等](#方案 D:外部副作用的端到端幂等)
      • 选型建议
    • 设计原则
      • [原则 1:幂等设计必须假设"写后读不一定一致"](#原则 1:幂等设计必须假设"写后读不一定一致")
      • [原则 2:防御性设计优先于"精美"的并发控制](#原则 2:防御性设计优先于"精美"的并发控制)
    • 延伸思考
    • 参考资料

问题本质

某条业务记录被重复保存了两次。表面上是"并发线程同时通过了重复检查",但根因是双重检查模式(Double-Check Pattern)在读写分离架构下的假设失效------代码假设"写后立即可读",但 MySQL 读写分离中间件把 INSERT 发到主库、SELECT 发到从库,主从间异步复制有延迟,锁内的二次查询读不到刚写入的数据。

问题来自两个设计在组合时的冲突:

  1. 应用层:ReentrantLock + 双重检查做幂等,依赖"锁内查询能读到最新已提交数据"
  2. 基础设施层:MySQL 读写分离中间件把读写分到不同实例,主从异步复制存在延迟

两个设计分开看都没问题,组合起来就出事。

场景还原

结论先说:和飞书无关,和具体业务无关。ReentrantLock + 双重检查 + 读写分离,当锁内查询被路由到尚未应用前一笔写入的只读节点,且后续请求落在复制延迟窗口内时,双重检查必然失效。笔者用真实的 MySQL 8.0 主从复现它------两个实例组成读写分离环境,主库写入、从库读取,中间隔着异步复制延迟。

第一步:Docker 起一套 MySQL 主从

docker compose 拉起两个 MySQL 8.0 实例(主库 23306、从库 23307),完整 docker-compose.yml

yaml 复制代码
services:
  mysql-master:
    image: mysql:8.0
    container_name: ms-master
    environment:
      MYSQL_ROOT_PASSWORD: root123
    command:
      - --server-id=1
      - --log-bin=mysql-bin
      - --binlog-format=ROW
      - --gtid-mode=ON
      - --enforce-gtid-consistency=ON
    ports: ["23306:3306"]
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot123"]
      interval: 2s
      timeout: 3s
      retries: 30

  mysql-slave:
    image: mysql:8.0
    container_name: ms-slave
    depends_on:
      mysql-master:
        condition: service_healthy
    environment:
      MYSQL_ROOT_PASSWORD: root123
    command:
      - --server-id=2
      - --gtid-mode=ON
      - --enforce-gtid-consistency=ON
      - --skip-log-bin
    ports: ["23307:3306"]
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot123"]
      interval: 2s
      timeout: 3s
      retries: 30

启动后配置 GTID 复制,并给从库设置 3 秒人为复制延迟------MySQL 8 原生的 SOURCE_DELAY 参数,用于模拟生产环境高负载下的异步复制 lag。先在主库执行:

sql 复制代码
-- 在主库(ms-master)执行:创建复制账号 + 建业务表
-- 注意:故意不加唯一索引,模拟真实的幂等缺失场景
CREATE USER IF NOT EXISTS 'repl'@'%' IDENTIFIED BY 'repl123';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';

CREATE DATABASE IF NOT EXISTS biz_demo;
CREATE TABLE biz_demo.payment_record (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  record_id VARCHAR(64) NOT NULL,
  biz_type INT NOT NULL DEFAULT 2,
  is_notify INT NOT NULL DEFAULT 1,
  is_deleted INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;

再在从库执行(GET_SOURCE_PUBLIC_KEY=1 是因为 MySQL 8 默认的 caching_sha2_password 认证需要先获取公钥,不加会报 Authentication requires secure connection):

sql 复制代码
-- 在从库(ms-slave)执行:建立复制链路,SOURCE_DELAY=3 模拟 3 秒主从延迟
CHANGE REPLICATION SOURCE TO
  SOURCE_HOST='mysql-master', SOURCE_USER='repl',
  SOURCE_PASSWORD='repl123', SOURCE_AUTO_POSITION=1,
  GET_SOURCE_PUBLIC_KEY=1, SOURCE_DELAY=3;
START REPLICA;

配置完成后不要直接运行 Java 程序,先确认复制链路正常:

sql 复制代码
SHOW REPLICA STATUS\G

至少应确认 Replica_IO_RunningReplica_SQL_Running 均为 YesLast_IO_Error / Last_SQL_Error 为空,且 SQL_Delay3。如果查询失败或复制未就绪,应先修复环境,不能把查询异常当成"记录不存在"继续实验。

第二步:最小复现代码

完整代码,保存为 DuplicateInsertDemo.java 就能编译运行(需要 mysql-connector-j):

java 复制代码
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 最小复现:MySQL 读写分离架构下,ReentrantLock + 双重检查的幂等防重失效。
 *
 * 前提:docker-compose 启动的 MySQL 8.0 主从(见上文),
 *       从库配置了 3 秒人为复制延迟(SOURCE_DELAY=3)。
 *
 * 读写分离的路由规则(真实系统中由 ProxySQL / 云 RDS 代理完成):
 *  - masterConn:只执行 INSERT / UPDATE / DELETE(写请求 → 主库)
 *  - slaveConn :只执行 SELECT(读请求 → 从库)
 *
 * 现象:即使 ReentrantLock 保证了互斥(锁内二次检查),
 *       锁内 SELECT 走从库,仍看不到刚写入主库的数据 → 重复插入。
 * 运行环境需要 mysql-connector-j:
 *   javac -cp .:mysql-connector-j-*.jar DuplicateInsertDemo.java
 *   java  -cp .:mysql-connector-j-*.jar DuplicateInsertDemo
 */
public class DuplicateInsertDemo {

    // ===== 读写分离:两个连接分别指向主库 / 从库 =====
    static final String MASTER_URL = "jdbc:mysql://127.0.0.1:23306/biz_demo?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC";
    static final String SLAVE_URL  = "jdbc:mysql://127.0.0.1:23307/biz_demo?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC";
    static final String USER = "root";
    static final String PWD  = "root123";

    /** 应用层互斥锁(单 JVM 内有效,对应真实系统的 sendMessage 锁) */
    static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) throws Exception {
        // 每轮使用新的业务键,避免上一轮数据复制到从库后干扰结果
        String recordId = "rec_" + System.currentTimeMillis();

        // 3 个上游事件(record_added / record_edited ×2)并发处理同一 recordId
        // 线程名取 1~4 的随机数(与真实系统的线程池编号风格一致),同名线程是正常的
        ExecutorService pool = Executors.newFixedThreadPool(3, r ->
                new Thread(r, "event-thread-" + ThreadLocalRandom.current().nextInt(1, 5)));
        List<Future<?>> tasks = new ArrayList<>();
        for (int i = 1; i <= 3; i++) {
            tasks.add(pool.submit(() -> handleEvent(recordId)));
        }
        pool.shutdown();
        if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
            pool.shutdownNow();
            throw new IllegalStateException("并发任务未在规定时间内完成");
        }
        // submit() 会把子线程异常封装在 Future 中;必须 get() 才能让主线程感知实验失败。
        for (Future<?> task : tasks) {
            task.get();
        }

        // 所有线程已执行完,直接从主库查最终结果(主库是写入节点,数据一定是最新的)
        try (Connection c = open(MASTER_URL);
             PreparedStatement ps = c.prepareStatement(
                     "SELECT COUNT(*) FROM payment_record WHERE record_id=?")) {
            ps.setString(1, recordId);
            ResultSet rs = ps.executeQuery();
            rs.next();
            int total = rs.getInt(1);
            System.out.println("\n======== 最终结果(以主库为准) ========");
            System.out.println("payment_record 中同一 recordId 的记录数: " + total);
            System.out.println(total > 1 ? "❌ 重复保存!同一 recordId 被插入了 " + total + " 次" : "✅ 幂等生效");
        }
        System.exit(0);
    }

    /** 处理单个上游事件:锁外检查 → 耗时操作 → 进入锁内二次检查 + 插入 */
    static void handleEvent(String recordId) {
        // 锁外第一次检查(读从库)
        if (checkMessageSend(recordId)) {
            System.out.println(Thread.currentThread().getName() + " 锁外检查命中,直接返回");
            return;
        }
        sleep(150);   // 模拟耗时操作:构建消息 / 调用第三方
        sendMessage(recordId);
    }

    static void sendMessage(String recordId) {
        lock.lock();
        try {
            // 锁内二次检查(仍然读从库!)
            if (checkMessageSend(recordId)) {
                System.out.println(Thread.currentThread().getName() + " 锁内检查命中,防重拦截");
                return;
            }
            // INSERT 写主库
            try (Connection c = open(MASTER_URL);
                 PreparedStatement ps = c.prepareStatement(
                         "INSERT INTO payment_record(record_id, biz_type, is_notify, is_deleted) VALUES (?, 2, 1, 0)")) {
                ps.setString(1, recordId);
                ps.executeUpdate();
                System.out.println(Thread.currentThread().getName() + " INSERT 成功 → 写主库");
            } catch (SQLException e) {
                throw new IllegalStateException("INSERT 失败,终止本次处理", e);
            }
        } finally {
            lock.unlock();
        }
    }

    /** 幂等检查:SELECT 走从库连接(读写分离中间件的路由规则) */
    static boolean checkMessageSend(String recordId) {
        try (Connection c = open(SLAVE_URL);
             PreparedStatement ps = c.prepareStatement(
                     "SELECT COUNT(*) FROM payment_record WHERE record_id=? AND biz_type=2 AND is_notify=1 AND is_deleted=0")) {
            ps.setString(1, recordId);
            ResultSet rs = ps.executeQuery();
            rs.next();
            int total = rs.getInt(1);
            System.out.println(Thread.currentThread().getName() + " checkMessageSend → SELECT 从库 Total: " + total);
            return total > 0;
        } catch (SQLException e) {
            // 幂等检查必须 fail-closed:连接失败、表不存在、权限错误等
            // 都不能被解释为"记录不存在"。
            throw new IllegalStateException("幂等检查失败,终止本次处理", e);
        }
    }

    static Connection open(String url) throws SQLException {
        return DriverManager.getConnection(url, USER, PWD);
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

重复运行说明:上面的程序每次生成新的 recordId,因此无需删除旧数据即可重复验证。如果依然使用固定的 rec_xxx,则每轮实验前需清理主库数据,并等待该清理操作复制到从库后再运行;否则从库上一轮的数据会干扰结果。

第三步:运行结果(真实 MySQL 主从实测输出)

复制代码
event-thread-2 checkMessageSend → SELECT 从库 Total: 0
event-thread-3 checkMessageSend → SELECT 从库 Total: 0
event-thread-4 checkMessageSend → SELECT 从库 Total: 0
event-thread-3 checkMessageSend → SELECT 从库 Total: 0
event-thread-3 INSERT 成功 → 写主库
event-thread-4 checkMessageSend → SELECT 从库 Total: 0
event-thread-4 INSERT 成功 → 写主库
event-thread-2 checkMessageSend → SELECT 从库 Total: 0
event-thread-2 INSERT 成功 → 写主库

======== 最终结果(以主库为准) ========
payment_record 中同一 recordId 的记录数: 3
❌ 重复保存!同一 recordId 被插入了 3 次

关键点

注意 event-thread-3INSERT 成功 → 写主库 之后,event-thread-4 的锁内二次 checkMessageSend 返回的还是 Total: 0。锁保证了线程互斥(thread-4 一定在 thread-3 释放锁之后才进入临界区),但管不了查询的数据源能不能看到刚提交的数据。核心矛盾就在这里:锁管住了"谁先执行",管不住"读哪里"。

对照真实业务场景:最小复现里 3 个事件密集提交,且从库固定延迟 3 秒,3 次全部赶在延迟窗口内,所以插入了 3 条;真实系统中事件到达有时间差(record_added 之后用户隔了几秒才编辑),第三次事件处理时复制已经追上,被成功拦截,所以只重复插入了 id=8145、id=8146 两条。复现比真实更极端,但恰好把问题暴露得更彻底。

根因分析

第一层:并发模型------事件如何被并发处理

真实系统中,事件链路是"飞书 Webhook → 监听器分发 → 线程池异步提交 → 业务 Service"。链路不短,但决定并发行为的关键只有两个点,最小复现里都保留了:

  1. 线程池异步提交(对应最小复现的 ExecutorService + handleEvent()
java 复制代码
// 真实系统:InvestmentListener.doHandle(), 线程池异步提交
threadPoolTaskExecutor.submit(() -> {
    statisticsMessageService.calculate(eventId, data, eventInfo);
});
java 复制代码
// 最小复现:同样通过线程池并发处理 3 个事件
ExecutorService pool = Executors.newFixedThreadPool(3);
for (int i = 1; i <= 3; i++) {
    pool.submit(() -> handleEvent("rec_xxx"));
}

异步线程池意味着多个事件可能在多个线程上并发处理,高吞吐场景下这是合理的设计。问题出在,同一业务主键的记录可能被多个事件同时命中(record_added 之后用户立刻编辑,record_edited 紧跟着来),这种时候才需要幂等保护。

  1. 读写数据源分离(对应最小复现代码的 MASTER_URL / SLAVE_URL 两个连接)
java 复制代码
// 最小复现:INSERT 走 masterConn,SELECT 走 slaveConn,中间隔着复制延迟
open(MASTER_URL).prepareStatement("INSERT INTO ...");  // INSERT → 主库
open(SLAVE_URL).prepareStatement("SELECT COUNT(*) ..."); // SELECT → 从库

真实系统中这行代码背后的路由由 MySQL 读写分离中间件完成(详见第三层),但在并发模型视角下二者等价:写入和读取不在同一个数据节点上,中间隔着异步复制延迟。

第二层:锁机制与双重检查的失效

sendMessage() 中使用 ReentrantLock 保证同一 JVM 内的互斥访问。锁内做二次检查(double-check),这是典型的"先检查后执行"并发控制模式:

java 复制代码
// 最小复现 sendMessage()(完整代码见第二步,这里省略了 JDBC 细节)
lock.lock();                                       // ① 获取互斥锁
try {
    if (checkMessageSend(recordId)) { return; }    // ② 锁内二次检查(读从库)
    // ③ INSERT 写主库(完整代码中为 open(MASTER_URL) 后执行 INSERT)
} finally {
    lock.unlock();                                 // ④ 释放锁
}

从真实日志精确还原的时序如下:

复制代码
20:56:01.602  biz-thread-3  sendMessage() 锁内消息发送步骤
20:56:02.267  biz-thread-1  findRecordEntity() 中
20:56:02.274  biz-thread-1  checkMessageSend() 锁外第一次 → Total: 0 ✅
20:56:02.4x   biz-thread-3  INSERT INTO payment_record (id=8145) ✅  ← 第一次插入
20:56:02.534  biz-thread-3  calculate() 完成,释放锁
20:56:02.534  biz-thread-1  checkMessageSend() 锁内第二次 → Total: 0 ❌  ← 关键!
20:56:02.560  biz-thread-1  INSERT INTO payment_record (id=8146) ✅  ← 第二次插入
20:56:05.724  biz-thread-4  checkMessageSend() 锁内 → Total: 2 ✅  ← 复制追上,第三次事件被拦截

锁本身是生效的:biz-thread-3 释放锁后,biz-thread-1 才拿到锁。可锁内查询还是返回 Total: 0,biz-thread-3 明明 2.4x 秒前刚 INSERT 完。

说白了,这不是锁失效,也不是双重检查写错了。双重检查模式成立的前提是"锁内 READ 一定能看到前一个持锁者写入的数据"------这个前提在单库架构下由数据库的可见性保证,在读写分离架构下却不成立,因为 READ 走的不是同一个数据节点。

第三层:真正的根因------MySQL 读写分离架构

数据库连接采用了读写分离架构,应用通过 MySQL 读写分离中间件(如 ProxySQL、MySQL Router 或云平台的读写分离代理)连接数据库,中间件根据 SQL 类型自动路由:

复制代码
应用 → MySQL 读写分离中间件
                    │
        ┌───────────┴───────────┐
        │   读写分离中间件        │
        │  (SQL 类型路由)        │
        └───────────┬───────────┘
                    │
    ┌───────────────┼───────────────┐
    │               │               │
写请求路由       读请求路由       读请求路由
    │               │               │
    ▼               ▼               ▼
┌─────────┐   ┌──────────┐   ┌──────────┐
│ 主实例   │   │ 只读实例1 │   │ 只读实例2 │
│(Master) │   │ (Slave1) │   │ (Slave2) │
└─────────┘   └──────────┘   └──────────┘
    │               ▲
    │   异步复制     │
    └───────────────┘
      (存在延迟!)

关键行为:

  • INSERT INTO payment_record → 路由到主实例
  • SELECT FROM payment_record → 路由到只读实例
  • 主从之间是异步复制,存在毫秒~秒级的延迟

再回头看日志里的现象,就全对上了:

  1. biz-thread-3 在锁内执行 INSERT → 写入主库
  2. biz-thread-3 释放锁
  3. biz-thread-1 获取锁,执行 SELECT → 查询从库
  4. 主从复制延迟导致从库上还没有新数据 → Total: 0
  5. biz-thread-1 认为没有已发送记录,执行第二次 INSERT

第三次事件(biz-thread-4)能成功拦截,是因为它到 20:56:05.724 才执行查询,距离第一次 INSERT 已经约 3 秒,复制早追上了,从库上能看到两条记录。

解决方案

下面几种手段解决的是不同层次的问题,不是简单的三选一:

  1. 强制读主库,解决本文直接暴露的"锁内读到旧数据"。
  2. 数据库唯一约束,为并发落库提供一个原子裁决点。
  3. 分布式锁,在多实例下降低同一业务键的并发冲突,但不替代数据库约束。
  4. 端到端幂等,处理"数据库只有一条,但外部消息可能发了两次"的副作用问题。

方案 A:锁内幂等查询强制走主库

把幂等检查这条关键 SELECT 强制路由到主库,保证「写后立即读」能读到最新已提交数据。各中间件/平台的实现方式不同,本质都是「给 SQL 打标记,让代理路由到主库」:

中间件 / 平台 强制走主库的写法 Hint 位置
阿里云 RDS MySQL(高可用/集群系列) /*FORCE_MASTER*/ SQL 语句最前面
阿里云 PolarDB /*FORCE_MASTER*/ SQL 语句最前面
ProxySQL mysql_query_rules 按 SQL 特征强制路由(无需改 SQL) ---

这里有两个需要注意的点:

  1. RDS MySQL 与 PolarDB MySQL 当前官方文档均使用 /*FORCE_MASTER*/。但 Hint 是云平台代理层能力,是否支持、适用哪种连接地址以及对代理版本的要求,都应以目标实例的官方文档为准。
  2. Hint 要放在 SQL 语句最前面。MyBatis-Plus 的 wrapper.last() 是把片段拼到 SQL 末尾(... WHERE ... /*FORCE_MASTER*/),放那里代理不识别,应直接写在 Mapper XML 或注解 SQL 里:
xml 复制代码
<!-- 阿里云 RDS MySQL:注释形式 Hint,放 SELECT 最前面 -->
<select id="countByRecord" resultType="int">
  /*FORCE_MASTER*/ SELECT COUNT(*) FROM payment_record
  WHERE record_id = #{recordId} AND biz_type = 2 AND is_notify = 1 AND is_deleted = 0
</select>
xml 复制代码
<!-- 阿里云 PolarDB:SQL 注释形式,同样放 SELECT 最前面 -->
<select id="countByRecord" resultType="int">
  /*FORCE_MASTER*/ SELECT COUNT(*) FROM payment_record
  WHERE record_id = #{recordId} AND biz_type = 2 AND is_notify = 1 AND is_deleted = 0
</select>

注:/*FORCE_MASTER*/ 是部分云平台代理层支持的 Hint,落地前务必按自己平台、产品系列和代理版本确认。ProxySQL 等中间件需通过 mysql_query_rules 或其自身提供的机制指定路由。

在最小复现环境中做等价验证------把 checkMessageSend()SLAVE_URL 改为 MASTER_URL(模拟强制走主库),其余代码不变,运行结果:

复制代码
event-thread-2 checkMessageSend → SELECT 主库 Total: 0
event-thread-1 checkMessageSend → SELECT 主库 Total: 0
event-thread-4 checkMessageSend → SELECT 主库 Total: 0
event-thread-2 checkMessageSend → SELECT 主库 Total: 0
event-thread-2 INSERT 成功 → 写主库
event-thread-1 checkMessageSend → SELECT 主库 Total: 1
event-thread-1 锁内检查命中,防重拦截
event-thread-4 checkMessageSend → SELECT 主库 Total: 1
event-thread-4 锁内检查命中,防重拦截

======== 最终结果(以主库为准) ========
payment_record 中同一 recordId 的记录数: 1
✅ 幂等生效

同一个程序,只改查询的数据源(从库 → 主库),结果从「重复插入 3 次」变成「幂等生效,1 条记录」。注意 event-thread-2 写主库后,event-thread-1 的锁内检查读主库立即看到了 Total: 1------读的是同一个节点,没有复制延迟。这组对照实验证明了本次故障的直接原因是锁内仍然读到了复制延迟下的旧数据。在这个单 JVM 实验中,ReentrantLock 负责串行化竞争者,主库查询负责提供对前一笔已提交写入的可见性,两个条件同时成立才得到当前结果。它并不证明"只要读主库就天然具有幂等性"。

适用场景:能控制代码且使用 MySQL 读写分离架构的团队

代价:低,仅需给关键查询加一个强制主库 Hint

局限:依赖特定云平台或中间件的语法,迁移到其他环境需要适配。更重要的是,它只在"所有竞争请求都被同一把应用锁串行化"的前提下修复当前问题。部署多实例后,两个实例仍可能先后在主库查到"不存在",因此不能把强制读主库当成通用幂等保证。

方案 B:数据库唯一索引兜底 --- 落库最后防线

payment_record 表上添加联合唯一索引,即使应用层检查失效,数据库层面也能阻止重复插入:

sql 复制代码
ALTER TABLE payment_record
ADD UNIQUE INDEX uk_record_notify
    (record_id, biz_type, is_notify, is_deleted);

配合应用层捕获 DuplicateKeyException

java 复制代码
try {
    paymentRecordMapper.insert(entity);
} catch (DuplicateKeyException e) {
    log.warn("Duplicate insert detected, ignored: {}", entity.getRecordId());
    // 只能确认已有竞争者成功落库;
    // 如果落库前已有外部副作用,还需按业务设计补偿或对账。
}

适用场景:业务能够定义稳定唯一键的数据库落库防重场景

代价:低,仅需 DDL 变更 + 异常捕获

优势:不依赖任何云平台特性,跨云/自建 MySQL 通用;能作为数据库落库层面的最终防重约束

边界:唯一索引把 is_notify/is_deleted 状态列也纳入了约束。若业务后续会翻转这些状态(软删除后重插、is_notify 从 1 翻 0 再触发重发),唯一索引的约束范围会随之变化------定索引前要先想清楚「什么状态组合下才算同一条记录」,否则可能拦不住或误拦。

重要边界:唯一索引只能保证"相同唯一键最多成功落一条数据库记录"。如果流程是先调用飞书或其他第三方接口,再插入 payment_record,两个线程仍可能都已经完成外部发送,只是第二次落库被唯一索引拒绝。因此它不能单独保证"外部消息只发送一次"。涉及外部副作用时,还需要第三方幂等键、Outbox/本地消息表、可恢复状态机或等价的一致性设计。

方案 C:多实例下按业务键串行化 --- 可选的冲突削峰层

如果将来部署多实例,ReentrantLock 只能保护单 JVM 内的并发,跨实例无效。可以用 Redis 分布式锁,也可以通过消息队列按 recordId 分区,让同一业务键的事件按顺序处理。

如果选择分布式锁,锁粒度应是稳定的业务幂等键,不应用一把全局锁串行化所有记录:

java 复制代码
// 使用 Redis 分布式锁替代 ReentrantLock
String lockKey = "lock:payment:send:" + entity.getRecordId();
boolean locked = redisLock.tryLock(lockKey, 5, TimeUnit.SECONDS);
if (!locked) {
    // 不能默默丢弃事件:进入有界重试、延迟队列或人工补偿流程
    scheduleRetry(entity);
    return;
}
try {
    if (checkMessageSendOnMaster(entity)) {
        return;
    }
    // ... 发送消息 ...
    paymentRecordMapper.insert(entity);
} finally {
    redisLock.unlock(lockKey);
}

适用场景:多实例下,同一业务键确实需要串行执行,或希望降低唯一键冲突和重复外部调用。

局限:仅靠分布式锁不足以解决读写分离延迟,也不应成为数据正确性的唯一来源。实际落地还要处理租约过期、自动续期、锁所有权校验、获锁失败后的重试/补偿以及进程崩溃等边界。即使使用了分布式锁,锁内查询仍应走主库,数据库仍应保留唯一约束。

方案 D:外部副作用的端到端幂等

如果真正要保护的是"飞书消息只发送一次",而不只是"payment_record 只插入一条",那么方案 A、B、C 都不能单独给出完整保证。典型风险时序是:

text 复制代码
外部消息发送成功
        ↓
进程崩溃或数据库插入失败
        ↓
事件重试,外部消息再次发送

这一层的具体方案取决于下游能力:

  • 下游支持幂等键:使用稳定的业务键作为请求幂等键,重试时保持不变。
  • 下游不支持幂等键:可以先在本地事务中写入业务记录和 Outbox 任务,再由可重试的后台任务发送;配合明确的 PENDING / SENDING / SENT / FAILED 状态、超时恢复和对账机制。

这类设计通常不承诺网络世界中严格的"exactly once",而是通过至少一次投递 + 幂等消费/可对账补偿获得业务上可接受的一次效果。

选型建议

手段 主要解决的问题 是否必选 不能单独解决的问题
A:锁内强制读主库 主从延迟导致的锁内误判 当前单 JVM 架构下建议立即修复 多实例竞争、落库前的外部副作用
B:数据库唯一约束 并发插入的原子裁决 只要能定义稳定唯一键,就应优先采用 外部消息重复发送
C:分布式锁/按键串行队列 多实例下的同键并发和冲突削峰 可选,按吞吐量和业务时序要求决定 陈旧读、锁失效后的数据兜底
D:下游幂等键/Outbox 状态机 外部副作用的可重试与可恢复 如果幂等范围包括消息发送,则需要 不代替数据库唯一约束

落到本文场景,建议按以下顺序推进:

  1. 紧急止血:将锁内幂等查询强制路由到主库,并保留 fail-closed 的异常处理。
  2. 落库兜底:先定义稳定的业务幂等键,清理存量重复数据后再添加唯一约束,并正确处理 DuplicateKeyException
  3. 端到端幂等:检查外部消息是在落库前还是落库后发送。如果存在"已发送但未成功记录"窗口,引入下游幂等键或 Outbox/状态机。
  4. 多实例扩展:只在确有按键串行的业务需求时引入分布式锁或按键分区队列,不把它作为唯一正确性保障。

设计原则

2 条可复用的经验:

原则 1:幂等设计必须假设"写后读不一定一致"

在分布式系统中,应用层代码不能假设"刚写入的数据立即可被读到"。写操作和读操作可能被路由到不同的存储节点,中间存在复制延迟。

反例:本文案例中的双重检查模式,依赖"锁内 SELECT 能读到刚 INSERT 的数据"的假设。

正例:

  • 使用数据库唯一约束/唯一索引作为落库层面的最终防重保障
  • 写操作时返回唯一标识(如自增 ID),后续操作基于该标识判断
  • 使用分布式 ID 生成器保证业务主键唯一,避免重复写入

原则 2:防御性设计优先于"精美"的并发控制

在涉及数据一致性的场景中,数据库层面的硬约束(唯一索引、外键、CHECK 约束)比应用层的并发控制更可靠。应用层代码可以优化性能,但数据库层必须兜底。

反例:完全依赖应用层的 ReentrantLock + 双重检查来保证幂等,没有数据库层的唯一约束。

正例:数据库唯一索引 + 应用层并发控制,两层防护。应用层负责拦截绝大多数重复请求,数据库层负责兜底剩余的并发冲突。

延伸思考

写后读不一致是分布式系统里的老问题,很多场景都在同一个点上做取舍:

  • MySQL 主从架构 + 业务读写分离:写主库后立即读从库,可能读到旧数据,常见做法是"强制走主库"或"读请求延迟"。
  • CQRS(命令查询职责分离):Command 写入事件存储,Query 从读模型读取,最终一致性意味着写后的读可能返回旧状态。比如 Axon 就用事件溯源 + 投影来管理这个一致性窗口。
  • 缓存 + 数据库双写:先更新库再删缓存,还是先删缓存再更新库?Cache Aside 也面临写后读一致性问题,通常用延迟双删或订阅 Binlog 异步同步来解决。

这些场景的解法思路是一样的:应用层做并发控制,尽量别把冲突压到数据库;数据库层用约束兜底,接住应用层漏掉的。这不算过度设计,是防御性编程该有的底线。

参考资料

相关推荐
Wang's Blog1 小时前
PostgreSQL笔记59:行列级权限机制与数据脱敏实践
数据库·笔记·postgresql
Roadinforest2 小时前
Claude Code 架构深度解析:从 Agent Loop 到 Tool、MCP 与 Context
ai·架构·llm·agent·anthropic·claudecode
zhaodezhu16882 小时前
四维技术全域赋能 一网推重构企业数字营销增长新范式
安全·架构·泰兴geo优化·可靠公司·苏州geo技术·四维技术·全域赋能
橘色的喵2 小时前
PySide6 工业上位机的实时帧链、零拷贝与跨语言架构
c++·架构·图像·pyside
tachibana22 小时前
把RAGAS跑起来
数据库·人工智能·ai·架构·大模型·llm·rag
蒸蒸yyyyzwd2 小时前
cpp选手秋招学习笔记day14
数据库·oracle
数据库小学妹3 小时前
MySQL 8.4还是9.7 LTS?8.0 EOL后升级怎么选
数据库·mysql·dba·版本升级·数据库运维
存在morning3 小时前
【PySpark 学习笔记 三】DataFrame API 入门
android·java·数据库
m0_547486663 小时前
《数据库原理及应用教程MySQL 8.0》全套PPT课件2026
数据库·mysql