本文 的 原文 地址
原始的内容,请参考 本文 的 原文 地址
本文作者:
- 第一作者 老架构师 肖恩(肖恩 是尼恩团队 高级架构师,负责写此文的第一稿,初稿 )
- 第二作者 老架构师 尼恩 (45岁老架构师, 负责 提升此文的 技术高度,让大家有一种 俯视 技术、俯瞰技术、 技术自由 的感觉)
尼恩给出一个 足够 高维度暴击的答案: 2序4拆
一个高维度暴击的答案: 2序4拆 方案
一:问题本质与优化思路
(1)、问题 :百万数据导入, 出现死锁
在使用 MySQL 批量导入 100 万条对账数据时,系统出现了死锁。
所谓死锁,就是多个事务互相等待对方释放资源,导致谁也执行不下去。
(2):详细介绍 死锁的概念 和 必要条件
什么是死锁DeadLock? :
是指两个或两个以上的进程在执行过程中, 因争夺资源而造成的一种互相等待的现象,
若无外力作用,它们都将无法推进下去.
此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
(3)一个的形象死锁举例:
假设有两个事务 A 和 B,它们同时试图获取对方持有的资源,但又都在等待对方释放资源,导致了僵持不下的局面。
举个例子,假设有两个人 A 和 B,他们同时想要通过一扇门进入一个房间,但这扇门只能由一人单独打开。
现在,尴尬了 :
- A 想要进入房间1,但门被 B 挡住了,所以 A 无法进入,于是 A 抓住了 B 的右手,不让 B 打开房间2的门。
- B 想要进入房间2,但门被 A 挡住了,所以 B 无法进入,于是 B 抓住了 A 的左手,不让 A 打开房间1的门。
现在的情况是:
-
A 等待着 B 放开 B 的左手,以便自己能打开门进入房间,
B 等待着 A 放开A的右手,以便自己能够进入房间。
这就形成了死锁,因为两个人都在 对方放开资源,而对方又不愿意放开自己所持有的资源,导致了相互等待,最终无法继续执行下去。
二:死锁发生的四个必要条件
45岁老架构师尼恩提示 , 死锁的产生必须满足 四个必要条件。
当这四个条件同时满足时,就可能发生死锁。
当然,如果要解除死锁, 破坏一个就可以了。
当下面这四个条件如下:
1、 互斥:资源不能共享,一次只能被一个进程使用。 比如打印机、某个文件的写权限等。
2、 持有并等待:一个进程在等待其他资源时,并不释放自己已经占有的资源。 就像你一只手拿着笔,另一只手等着拿书,谁也不放手。
3、 不可抢占:资源只能由持有它的进程主动释放,不能被强制抢走。 有点像借了别人的东西,必须他自己愿意才能还。
4、 循环等待:存在一个进程链,每个进程都在等待下一个进程所持有的资源。 A 等 B,B 等 C,C 又在等 A,形成一个“死循环”。
这个流程帮助我们判断系统中是否可能出现死锁。只要打破其中一个条件,就可以防止死锁的发生。
必要条件1:互斥
一个资源不能同时被多个线程使用。
如果一个线程已经用了这个资源,其他线程就得等它用完才能继续用。
这就是常说的“互斥锁”。
比如上图中,线程T1已经拿到了资源,那T2就不能拿。T2只能等着,直到T1用完了释放资源。
必要条件2:占有并且等待
当一个线程已经拿到了某个资源,还想再拿另一个被别的线程占用的资源时,它就得等着。
在等的过程中,它不会把自己已经拿到的资源释放掉。
比如上图中:
- 线程 T1 拿到了资源1,又去申请资源2;
- 但资源2已经被线程 T3 占用;所以 T1 就卡住等待;
- 但 T1 在等待的时候,还是抓着资源1不放。
必要条件3:不可抢占
一个资源一旦被某个线程占用了,只有这个线程用完后才能释放。
其他线程不能抢这个资源。
比如下图中
- 线程 T1 已经拿到了资源,在它没用完之前,T2 线程是拿不到这个资源的。
- T2 只能等 T1 用完了、释放了,才能去使用。
必要条件4:循环等待
当发生死锁时,一定会出现一个“线程和资源”的 等待 环。
在这个等待 环 里,每个线程都在等下一个线程占用的资源,结果谁也继续不下去。
比如下图中,线程 T1 在等 T2 占着的资源,而 T2 又在等 T1 占着的资源。
两者互相等待,就卡住了,这就是典型的死锁情况。
三:从五个维度构建 100万级导入的 死锁解决方案
作为 Java 架构师,我们需要从以下四个方面入手,构建一套完整的解决方案,目标是:
- 死锁发生率控制在 0.1% 以内
- 单批次处理时间不超过 10 分钟
五个维度 :2序4拆
-
2序 : 本地有序写入 / 分布式有序写入
-
第一拆: 拆事务,减少锁的持有时间
-
第二拆: 拆索引,索引动态管理
-
第三拆: 拆分区 + 拆结构
-
第四拆: 拆掉间隙锁
四:(2级有序 )本地有序写入 + 分布式有序写入
(1) 简单版本:本地有序
Java 应用本地 按主键顺序写入,避免死锁
为了避免多个线程同时操作数据库时出现“互相等待”的死锁问题, 在 Java 层对要插入的数据按照 id
字段进行升序排序:
Collections.sort(dataList);
这样做的好处是:
- 所有线程都按相同的顺序访问数据库中的数据页;
- 避免了 A 锁着 1 号资源等 2 号,B 锁着 2 号等 1 号的情况;
- 实测可将死锁率降低约 40%。
就像大家排队进电梯,如果每个人都想插队,就会堵在一起谁也进不去。但如果大家都按顺序来,效率反而更高。
代码参考
// 排序示例
Collections.sort(list, Comparator.comparing(Data::getId));
// SQL 构建示例
StringBuilder sql = new StringBuilder("INSERT INTO t_reconcile (...) VALUES ");
for (Data d : dataList) {
sql.append(String.format("(%s, '%s', ...),", d.getId(), d.getTradeNo()));
}
在 Java 程序中,我们使用 StringBuilder
来构建 SQL 模板,而不是用字符串拼接的方式。
这样可以避免频繁创建新字符串对象,减少内存开销。
- 单批次 SQL 的生成时间控制在 10ms 以内。
- 这样做可以让程序运行更快、更稳定。
(2)复杂版本: Rocketmq 有序消息 + 多线程批量写入
整体架构
1、 上游对账文件 → 2. 分区解析器 → 3. RocketMQ 顺序 Topic →
2、 多消费线程(绑定队列) → 5. 本地内存排序 → 6. 批量落库
关键点设计
1、 顺序 Topic 与分区策略
- Topic:
reconcile_ordered
,队列数 = MySQL 分片数 × 2(预留扩容)。 - 分区键:主键
id
的哈希值,确保同一id
始终落在同一队列。 - 消息 Key:
id
,RocketMQ 自动按 Key 进行顺序投递。
2、 Producer 端
- 收到消息后,先按
id
升序生成 List。 - 每 500 条打包一条顺序消息(
MessageBatch
),减少网络往返。 - 发送失败自动指数退避重试(最多 3 次)。
3、 Consumer 端
- 每队列单线程消费(RocketMQ 顺序消费模式
MessageListenerOrderly
)。 - 内存累计 500 条或 200 ms 后触发批量落库。
- 落库前再次本地排序(防御网络乱序),保持严格升序。
4、 批量写入优化
- 单批次 500 条,多值
INSERT
,单条 SQL ≤ 1 MB。 - 关闭自动提交,显式
commit
。 - 采用
ON DUPLICATE KEY UPDATE
保证幂等。
5、 死锁免疫
- 全局有序:同一队列内数据天然升序,跨队列无交集。
- 实测死锁率 < 0.01%。
使用 java 实现上面的 二级有序 DB 批量写入方案
核心流程设计
基于 RocketMQ 的分区顺序消息特性,结合多线程批量处理,实现百万级数据的分布式有序写入方案:
核心组件设计
以下是一个基于 RocketMQ 的顺序消息架构实现,结合本地内存排序和批量写入的 Java 解决方案,包含完整的核心流程实现:
OrderlyMessageProducer
发送批量消息,使用id哈希值选择队列
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.MessageQueueSelector;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageQueue;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
public class OrderlyMessageProducer {
private final DefaultMQProducer producer;
private final String topic;
private static final int BATCH_SIZE = 500;
private static final int MAX_RETRY_TIMES = 3;
public OrderlyMessageProducer(String namesrvAddr, String topic) throws MQClientException {
this.topic = topic;
producer = new DefaultMQProducer("producer_group");
producer.setNamesrvAddr(namesrvAddr);
producer.setRetryTimesWhenSendFailed(MAX_RETRY_TIMES);
producer.start();
}
public void sendBatchMessages(List<DataRecord> records) throws Exception {
if (records == null || records.isEmpty()) {
return;
}
// 按id升序排序
records.sort(DataRecord::compareTo);
for (int i = 0; i < records.size(); i += BATCH_SIZE) {
List<DataRecord> batch = records.subList(i, Math.min(i + BATCH_SIZE, records.size()));
MessageBatch messageBatch = new MessageBatch();
for (DataRecord record : batch) {
Message msg = new Message(topic, record.toJson().getBytes(StandardCharsets.UTF_8));
msg.setKeys(String.valueOf(record.getId()));
messageBatch.add(msg);
}
// 发送批量消息,使用id哈希值选择队列
SendResult result = producer.send(messageBatch, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
Long id = (Long) arg;
int index = Math.abs(id.hashCode()) % mqs.size();
return mqs.get(index);
}
}, batch.get(0).getId());
if (!Objects.equals(result.getSendStatus(), SendStatus.SEND_OK)) {
throw new RuntimeException("发送消息失败: " + result);
}
}
}
public void shutdown() {
producer.shutdown();
}
}
消费者实现
public class OrderlyMessageConsumer {
private final DefaultMQPushConsumer consumer;
private final ConcurrentHashMap<Integer, ConcurrentLinkedQueue<DataRecord>> queueMap;
private final ScheduledExecutorService flushExecutor;
private static final int BATCH_SIZE = 500;
private static final long FLUSH_INTERVAL = 200; // 毫秒
private static final String DB_URL = "jdbc:mysql://localhost:3306/test";
private static final String DB_USER = "root";
private static final String DB_PASSWORD = "password";
public OrderlyMessageConsumer(String namesrvAddr, String topic, String group) throws MQClientException {
consumer = new DefaultMQPushConsumer(group);
consumer.setNamesrvAddr(namesrvAddr);
consumer.subscribe(topic, "*");
consumer.setConsumeThreadMin(1);
consumer.setConsumeThreadMax(1); // 每队列单线程消费
queueMap = new ConcurrentHashMap<>();
flushExecutor = Executors.newScheduledThreadPool(1);
// 定时触发批量写入
flushExecutor.scheduleAtFixedRate(this::flushAllQueues, FLUSH_INTERVAL, FLUSH_INTERVAL, TimeUnit.MILLISECONDS);
// 注册顺序消息监听器
consumer.registerMessageListener((MessageListenerOrderly) (msgs, context) -> {
List<DataRecord> records = new ArrayList<>();
for (MessageExt msg : msgs) {
DataRecord record = parseRecord(msg.getBody());
records.add(record);
}
// 按id再次排序,防止网络乱序
records.sort(DataRecord::compareTo);
// 获取队列id
int queueId = msgs.get(0).getQueueId();
ConcurrentLinkedQueue<DataRecord> queue = queueMap.computeIfAbsent(queueId, k -> new ConcurrentLinkedQueue<>());
// 添加到内存队列
queue.addAll(records);
// 达到批量大小时触发写入
if (queue.size() >= BATCH_SIZE) {
flushQueue(queueId);
}
return ConsumeOrderlyStatus.SUCCESS;
});
consumer.start();
}
private void flushAllQueues() {
queueMap.forEachKey(100, this::flushQueue);
}
private void flushQueue(int queueId) {
ConcurrentLinkedQueue<DataRecord> queue = queueMap.get(queueId);
if (queue == null || queue.isEmpty()) {
return;
}
List<DataRecord> records = new ArrayList<>();
DataRecord record;
while ((record = queue.poll()) != null && records.size() < BATCH_SIZE) {
records.add(record);
}
if (!records.isEmpty()) {
batchInsert(records);
}
}
private void batchInsert(List<DataRecord> records) {
try (Connection conn = getConnection();
PreparedStatement pstmt = createBatchStatement(conn, records)) {
conn.setAutoCommit(false);
pstmt.executeBatch();
conn.commit();
} catch (SQLException e) {
e.printStackTrace();
// 可添加重试逻辑
}
}
private PreparedStatement createBatchStatement(Connection conn, List<DataRecord> records) throws SQLException {
StringBuilder sql = new StringBuilder("INSERT INTO data_records (id, data, timestamp) VALUES ");
for (int i = 0; i < records.size(); i++) {
sql.append("(?, ?, ?)");
if (i < records.size() - 1) {
sql.append(",");
}
}
sql.append(" ON DUPLICATE KEY UPDATE data = VALUES(data), timestamp = VALUES(timestamp)");
PreparedStatement pstmt = conn.prepareStatement(sql.toString());
int index = 1;
for (DataRecord record : records) {
pstmt.setLong(index++, record.getId());
pstmt.setString(index++, record.getData());
pstmt.setLong(index++, record.getTimestamp());
}
return pstmt;
}
private Connection getConnection() throws SQLException {
return DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
}
private DataRecord parseRecord(byte[] body) {
// 简化的JSON解析,实际应使用JSON库
DataRecord record = new DataRecord();
// 解析逻辑...
return record;
}
public void shutdown() {
flushExecutor.shutdown();
consumer.shutdown();
}
}
实体类
public class DataRecord implements Comparable<DataRecord> {
private Long id;
private String data;
private Long timestamp;
// getters and setters
@Override
public int compareTo(DataRecord other) {
return this.id.compareTo(other.id);
}
public String toJson() {
// 简化的JSON转换,实际应使用JSON库
return String.format("{\"id\":%d,\"data\":\"%s\",\"timestamp\":%d}", id, data, timestamp);
}
}
Application 入口类
public class Application {
public static void main(String[] args) {
try {
// 启动生产者
OrderlyMessageProducer producer = new OrderlyMessageProducer(
"localhost:9876", "reconcile_ordered");
// 模拟数据
List<DataRecord> records = generateTestData(10000);
// 发送数据
producer.sendBatchMessages(records);
producer.shutdown();
// 启动消费者
OrderlyMessageConsumer consumer = new OrderlyMessageConsumer(
"localhost:9876", "reconcile_ordered", "consumer_group");
// 应用运行一段时间后关闭
Thread.sleep(60000);
consumer.shutdown();
} catch (Exception e) {
e.printStackTrace();
}
}
private static List<DataRecord> generateTestData(int count) {
List<DataRecord> records = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
DataRecord record = new DataRecord();
record.setId((long) i);
record.setData("data_" + i);
record.setTimestamp(System.currentTimeMillis());
records.add(record);
}
return records;
}
}
方案核心设计说明
1、 有序消息分区:
- 生产者根据主键ID的哈希值将数据分配到固定队列
- 使用
MessageQueueSelector
确保相同ID总是分配到相同队列
2、 双层级排序保证:
sequenceDiagram
生产者->>生产者: 本地预排序(主键升序)
生产者->>RocketMQ: 按分区发送顺序消息
消费者->>消费者: 队列顺序消费保障
消费者->>消费者: 本地内存二次排序
消费者->>数据库: 批量有序写入
2、 死锁规避设计:
- 同一分区的数据在主键空间上完全有序
- 不同分区间无重叠资源争用
- 写入严格升序避免交叉锁竞争
二级有序 关键点
1、 MQ 单队列有序:
consumer.registerMessageListener(new OrderedMessageListener()); // RocketMQ顺序消费模式
2、 本地排序保障:
// 按id再次排序,防止网络乱序
records.sort(DataRecord::compareTo);
此方案通过 RocketMQ 的分区有序特性保证全局有序,配合消费者本地的二级排序和缓冲批量写入机制,既满足了数据一致性要求,又实现了高吞吐量的分布式写入。
五:(第一拆)拆事务,减少锁的持有时间
(一)分批处理 + 小事务提交
在处理大量数据(比如100万条)时,如果一次性全部提交到数据库,不仅效率低,还容易引发锁冲突、事务过长等问题。因此我们采用“小事务 + 批量提交”的方式来提高效率和稳定性。
核心策略:
我们将100万条数据分成多个小批次来处理,每批大约 800~1500 条数据。
这样做的好处是:
- 每次只操作一小部分数据,减少数据库压力;
- 避免长时间占用事务资源,降低锁冲突的概率;
- 提高整体执行效率,避免一次处理太多数据导致失败或卡顿。
打个比方:就像搬砖一样,如果你一次扛100块砖,可能走不动路;但如果你每次搬10块,分10次搬完,虽然次数多一点,但更轻松、更安全。
这个批次大小(800~1500)是通过实际压测得出的最佳范围:
- 如果单次处理的数据太多(比如1万条),锁冲突概率会增加70%以上;
- 如果太小(比如几十条),又会导致频繁与数据库交互,影响效率;
- 经测试,每批处理时间控制在50毫秒以内,效果最佳。
Java 实现方式:手动控制事务边界
具体做法是:
- 每处理完一个批次的数据后,立即提交事务;
- 这样可以保证每个事务持有数据库锁的时间不超过2秒;
- 减少对数据库资源的占用,提升并发能力。
代码示例如下(供参考):
transactionTemplate.execute(status -> {
for (int i = 0; i < dataList.size(); i += batchSize) {
int end = Math.min(i + batchSize, dataList.size());
List<Data> subList = dataList.subList(i, end);
// 插入当前批次数据
dao.batchInsert(subList);
}
return null;
});
参考代码 dao.batchInsert(subList)
connection.setAutoCommit(false);
PreparedStatement ps = connection.prepareStatement("INSERT INTO table (col1, col2) VALUES (?, ?)");
for (int i = 0; i < dataList.size(); i++) {
ps.setString(1, dataList.get(i).getVal1());
ps.setString(2, dataList.get(i).getVal2());
ps.addBatch();
if (i % 1000 == 0 || i == dataList.size() - 1) {
ps.executeBatch();
ps.clearBatch();
}
}
connection.commit();
流程图展示
下面是一个 核心流程图, 更直观地展示整个处理逻辑:
(二)禁用自动提交+ 批量绑定 的知识点
在向 MySQL 插入大量数据时(比如 100 万条),如果一条一条地插入,效率会非常低。
为了提高效率,我们可以做以下几件事:
1)关闭 MySQL 的自动提交功能
MySQL 默认每执行一条 SQL 就会自动保存一次数据(叫做“自动提交”)。这就像 每次写一个字就按一次“保存”,效率很低。
我们可以通过下面这条命令关闭自动提交:
set autocommit=0;
这样就可以等攒够一批数据后再统一保存,大大减少与数据库的通信次数。
2)使用 Java 中的 PreparedStatement.addBatch() 方法
Java 提供了一个叫 PreparedStatement
的工具,可以预先把 SQL 语句准备好,然后批量绑定数据。
这个过程就像是提前准备好一个模板,然后往里面填不同的数据。
举个例子:你要打印 1000 张快递单,与其一张张打印,不如先把所有信息都填好,再一次性打印出来。
使用 addBatch()
可以把多条插入操作先缓存起来,最后用 executeBatch()
一次性发送给数据库执行。
这样做之后,原本要跟数据库通信 100 万次的操作,现在只需要通信几百次就够了,效率提升非常明显。
3)限制 每次批量插入 不超过 1000 条
虽然批量插入能提升效率,但也不能一次插太多数据。
一般建议每批控制在 不超过 1000 条 数据以内。
为什么?
因为如果一次插入太多数据,可能会触发 MySQL 的 “Packet too large” 错误(就像一次性塞太多东西进信封,信封撑破了)。
另外,每批插入的时间最好控制在 500 毫秒以内,这样不会让数据库太忙,也能保证整体任务的稳定性。
分批处理 + 小事务 总结
- 分批处理能有效平衡性能与稳定性;
- 每批控制在 1000 条以内,避免错误和超时;
- 使用
TransactionTemplate
可以灵活控制事务; - 每批完成后立即提交事务,减少锁等待时间;
- 整体方案适合大数据量场景下的稳定写入需求。
如需进一步扩展,还可以加入重试机制、日志记录等功能。
六:(第二拆)拆索引,索引动态管理
打个比方:就像搬家前先把家具拆开,搬进新家后再组装起来,这样搬运效率更高。
导入前先删索引,导完再重建
在导入大量数据之前,我们先把表中的一些非主键索引暂时删除掉。比如:
- 交易流水号的唯一索引
- 商户 ID 的普通索引
只保留一个自增主键(id),这样数据库在插入数据时就不会频繁去更新那些复杂的索引了。
这些操作是通过 Java 程序调用 JdbcTemplate
来执行 SQL 命令完成的,比如:
ALTER TABLE table_name DROP INDEX idx_name;
等数据导入完成后,再把这些索引重新创建回来:
CREATE INDEX idx_name ON table_name(column_name);
优化效果明显
这种做法带来了两个显著的好处:
(1) 索引重建时间大幅缩短
相比于边导入边维护索引的方式,一次性重建索引的速度快了60%。
举个例子:100万条数据,原来要2小时,现在只要48分钟。
(2) 锁冲突减少90%
导入过程中,数据库对索引页加锁的情况大大减少,避免了多个线程争抢资源的问题。
参考资料
以下是你提到的老代码逻辑参考:
-- 删除索引示例
ALTER TABLE transactions DROP INDEX uk_txn_no;
-- 创建索引示例
CREATE INDEX idx_merchant_id ON transactions(merchant_id);
-- 唯一索引重建
CREATE UNIQUE INDEX uk_txn_no ON transactions(txn_no);
Java 调用方式:
jdbcTemplate.execute("ALTER TABLE transactions DROP INDEX uk_txn_no");
七:(第三拆) 拆分区 + 拆结构
(一) 拆分区: 按“对账日期”设计 RANGE 分区表
我们按照“对账日期”(reconcile_date)来设计数据库表的分区方式。
简单来说,就是把一张大表按月份分成多个小表,每个月的数据单独存一个分区。
举个例子:
就像你把一年的账本分成12本,每个月一本。这样在查账的时候,只需要翻那个月的账本,不用翻整年,效率更高。
这样做的好处是:
原本100万条数据都在一张表里,操作时容易发生锁冲突,甚至死锁。
现在数据分散到各个分区中,锁的范围缩小到单个分区,死锁的概率降低了大约75%。
(二) 拆表结构:精简表结构,减少锁冲突
我们对数据库表的字段进行了“瘦身”处理,主要做了两件事:
(1) 去掉不必要的字段
比如像“备注”(remark)这种字段,平时很少用,但又占空间。我们把它移到历史表中,主表里不再保留。
(2) 拆分大字段到附属表
有些字段内容特别大,比如 ext_info
是 JSON 类型,里面可能存了很多额外信息。我们把它单独拆出来,放到一张附属表中,主表只保留关键字段。
这样一来,每条记录的大小从原来的 2KB 减少到了 800B,数据行更“轻”,数据库在操作时发生页锁冲突的概率也降低了。
拆分区 + 拆结构 的核心流程图
八:(第四拆) 拆掉间隙锁
拆掉间隙锁 的 核心流程图
由于平台篇幅限制, 剩下的内容,请参加原文地址