事务边界与批量写入:避免长事务、锁等待和日志压力
事务和批量写入是应用接入数据库后最容易被低估的两个问题。单次插入几条数据时看不出风险,一旦变成批量导入、订单处理、状态流转、定时同步,长事务、锁等待、日志暴涨、连接长期占用就都来了。
本文从应用开发视角出发,结合金仓数据库在 CentOS 7.6 上运行、Windows 11 本地开发调试的环境,讲清楚事务边界怎么控制,批量写入怎么做得更稳。 
@toc
一、事务边界为什么重要
其实事务这个东西。它就是保证你一组数据库操作。要么大家一起成功。要么就一起失败。问题在哪呢?问题在于啊。很多应用把事务的范围开得太大了。比如下面这些情况:
- 在事务里面去调第三方接口。
- 在事务里面去处理大文件。
- 在事务里面写个循环。处理个几万条数据。
- 在事务里面等着人工确认。或者等远程服务的响应。
- 一个方法加上了事务。里面又去调一堆复杂的查询和更新。
那么事务持续的时间越长。你的连接就被占得越久。锁也就持有了越久。日志压力也就跟着变大了。最后表现出来是啥样呢?可能就是连接池被耗光了。接口卡得动不了。表被锁死了。磁盘空间嗖嗖地往上涨。
二、先准备示例表
咱们接着用第一篇弄的那个 kb_app。还有 shop 和 app_user。先来建一张订单表:
sql
CREATE TABLE shop.t_order_demo (
order_id INT PRIMARY KEY,
user_id INT NOT NULL,
order_status VARCHAR(20) NOT NULL,
amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
然后插几条测试数据进去:
sql
INSERT INTO shop.t_order_demo(order_id, user_id, order_status, amount)
VALUES
(1, 1001, 'NEW', 99.00),
(2, 1002, 'NEW', 199.00),
(3, 1003, 'NEW', 299.00);
再给应用账号上个权限:
sql
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.t_order_demo TO app_user;
三、坏味道一:事务里做远程调用
很多人的代码写出来是这样式的:
java
@Transactional
public void payOrder(Integer orderId) {
orderRepository.updateStatus(orderId, "PAYING");
paymentClient.requestPay(orderId);
orderRepository.updateStatus(orderId, "PAID");
}
这段代码有个大问题。啥问题呢?就是远程支付调用。被你塞到数据库事务里面去了。那要是支付接口响应慢的话。你的数据库连接。还有相关的锁。就全都被拖在那里了。动都动不了。
那更稳妥一点的思路是啥呢。其实就是把事务范围弄小一点:
java
public void payOrder(Integer orderId) {
markPaying(orderId);
paymentClient.requestPay(orderId);
markPaid(orderId);
}
@Transactional
public void markPaying(Integer orderId) {
orderRepository.updateStatus(orderId, "PAYING");
}
@Transactional
public void markPaid(Integer orderId) {
orderRepository.updateStatus(orderId, "PAID");
}
当然啦。实际业务里你还得考虑幂等。还有失败补偿和状态机这些。但是核心原则它是不会变的。也就是说。千万别把外部那些你控制不了的耗时操作。放到数据库事务里去。
四、坏味道二:大循环包在一个事务里
比如说你要一次导入 10 万条数据:
java
@Transactional
public void importOrders(List<Order> orders) {
for (Order order : orders) {
orderRepository.insert(order);
}
}
这个事务跑的时间会很长。这期间你的连接还不了。WAL 日志也一直往大了涨。那要是中途失败了。回滚的成本也是相当高的。
那更稳妥的做法呢。就是分批去提交:
java
public void importOrders(List<Order> orders) {
int batchSize = 500;
for (int i = 0; i < orders.size(); i += batchSize) {
int end = Math.min(i + batchSize, orders.size());
importOneBatch(orders.subList(i, end));
}
}
@Transactional
public void importOneBatch(List<Order> batch) {
orderRepository.batchInsert(batch);
}
这个批大小设多少。其实没有固定答案的情况。你可以先从 300、500、1000 这样开始去压测。看看单批耗时多少。日志涨成啥样。锁等待严重不。还有接口响应的情况。
五、使用 JDBC batch 减少往返
如果你用的是 JdbcTemplate 的话。那可以用批量写入的方式:
java
String sql = "insert into shop.t_order_demo(order_id, user_id, order_status, amount) values (?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, orders, 500, (ps, order) -> {
ps.setInt(1, order.getOrderId());
ps.setInt(2, order.getUserId());
ps.setString(3, order.getStatus());
ps.setBigDecimal(4, order.getAmount());
});
这么搞的话。就能减少应用和数据库之间网络来回跑的次数。但是注意啊。批量写入也不是越大越好。批太大了。往往仅仅只是让单次事务变得太重了。
六、避免无条件全表更新
做批处理的时候。最吓人的 SQL 之一。就是那种啥条件都不带的更新:
sql
UPDATE shop.t_order_demo
SET order_status = 'CLOSED';
要是业务只是想关掉超时的订单。那你条件就得写清楚:
sql
UPDATE shop.t_order_demo
SET order_status = 'CLOSED',
updated_at = CURRENT_TIMESTAMP
WHERE order_status = 'NEW'
AND created_at < CURRENT_TIMESTAMP - INTERVAL '30 minutes';
上线之前你必须得确认几个事:
- 你的
WHERE条件写全了没。 - 预估一下影响行数。看看合不合理。
- 是不是得先跑个
SELECT COUNT(*)验一下影响范围。
比如像这样:
sql
SELECT COUNT(*)
FROM shop.t_order_demo
WHERE order_status = 'NEW'
AND created_at < CURRENT_TIMESTAMP - INTERVAL '30 minutes';

七、观察长事务
批处理跑的时候。你可以在数据库那边。盯着点长事务:
这里说明一下哈。下面这个 SQL 跑的状态视图。你得看你实际用的 KingbaseES 版本为准。要是字段名不一样。你只要把"会话、用户、客户端、事务开始时间、当前 SQL"这几个观察维度留住就行了。
sql
SELECT pid,
usename,
client_addr,
state,
xact_start,
now() - xact_start AS xact_age,
query
FROM sys_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_age DESC;
要是发现某个事务跑的时间。明显超过你预想的了。那你就得回到应用日志里去看看。它到底在处理哪一批数据。
我建议啊。应用批处理的日志。至少得把下面这些打出来:
- 批次的编号。
- 当前这批数据的起止范围。
- 当前这批数据有多少条。
- 当前这批开始跑和跑完的时间。
- 成功了多少条。失败了多少条。
八、观察锁等待
要是用户跟你说"更新订单卡住了"。那你就可以去看看等待的情况。不同版本的系统视图。字段可能稍微有点不一样。但核心思路就是。把等着的会话。和堵别人的会话给找出来。
咱们先看看当前活跃的 SQL:
sql
SELECT pid, usename, state, query_start, now() - query_start AS running_time, query
FROM sys_stat_activity
WHERE state = 'active'
ORDER BY running_time DESC;
接着再结合锁视图去查阻塞关系。生产环境里啊。我建议你提前把 DBA 常用的锁等待排查 SQL 准备好。别等出了故障。在现场临时去拼。那肯定来不及。
九、批量任务的应用侧保护
批量任务你得加点保护措施。别让它无限制地把数据库给拖垮了。
我建议这么搞:
- 并发任务数得控制好。
- 每批的大小设得合理一点。
- 每批都得有个明确的超时时间。
- 失败了得能从断点接着跑。
- 对于那种危险的更新。先查一下影响行数。
- 跟在线接口错开时间跑。
- 给批处理弄个独立账号。或者独立连接池。
要是在线接口和批处理用同一个连接池。批处理一高峰。可能就把接口的连接全占了。那你可以按业务的重要程度。把连接池拆开。但是你得记住。数据库连接的总预算。还是要合在一起算的。
十、事务使用清单
这里我弄了个事务使用的清单。大家可以对照着看看:
| 检查项 | 建议 |
|---|---|
| 事务范围 | 只包数据库一致性所需的最小代码 |
| 远程调用 | 不放进事务 |
| 文件处理 | 不放进事务 |
| 批量导入 | 分批提交 |
| 单批大小 | 通过压测确认 |
| 更新 SQL | 必须有完整条件 |
| 长事务监控 | 定期查看 xact_start |
| 批处理日志 | 打印批次、耗时、数量 |
| 失败恢复 | 支持断点或幂等重试 |
十一、小结
事务边界和批量写入。这俩东西决定了你的应用能不能长期稳稳地跑。事务越短的话。连接就放得越快。锁也持有得越少。那批量写入越可控呢。日志和回滚的压力也就越小。也就是说啊。千万别把所有操作都塞进一个大事务里。也别让批处理没完没了地去冲击数据库。
那下一篇呢。咱们就换个角度。从用户接口这边看起。看看一个慢接口。是怎么从应用日志一路定位到 SQL 的。然后再进一步去扒一下它的执行计划。