事务边界与批量写入:避免长事务、锁等待和日志压力

事务边界与批量写入:避免长事务、锁等待和日志压力

事务和批量写入是应用接入数据库后最容易被低估的两个问题。单次插入几条数据时看不出风险,一旦变成批量导入、订单处理、状态流转、定时同步,长事务、锁等待、日志暴涨、连接长期占用就都来了。

本文从应用开发视角出发,结合金仓数据库在 CentOS 7.6 上运行、Windows 11 本地开发调试的环境,讲清楚事务边界怎么控制,批量写入怎么做得更稳。

@toc


一、事务边界为什么重要

其实事务这个东西。它就是保证你一组数据库操作。要么大家一起成功。要么就一起失败。问题在哪呢?问题在于啊。很多应用把事务的范围开得太大了。比如下面这些情况:

  • 在事务里面去调第三方接口。
  • 在事务里面去处理大文件。
  • 在事务里面写个循环。处理个几万条数据。
  • 在事务里面等着人工确认。或者等远程服务的响应。
  • 一个方法加上了事务。里面又去调一堆复杂的查询和更新。

那么事务持续的时间越长。你的连接就被占得越久。锁也就持有了越久。日志压力也就跟着变大了。最后表现出来是啥样呢?可能就是连接池被耗光了。接口卡得动不了。表被锁死了。磁盘空间嗖嗖地往上涨。

二、先准备示例表

咱们接着用第一篇弄的那个 kb_app。还有 shopapp_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 的。然后再进一步去扒一下它的执行计划。

相关推荐
努力努力再努力wz1 小时前
【Redis入门系列】从 KEYS 到 SCAN:渐进式遍历、Cursor 与位反转原理
数据库·redis·缓存
坐吃山猪2 小时前
【多线程】Lock与Condition
大数据·数据库
Lightpwd2 小时前
Spring Boot 多数据源落地:AbstractRoutingDataSource + 注解切面(附源码)
数据库·后端
LabVIEW开发3 小时前
LabVIEW 64位安装的位深陷阱:工具包、内存与工程兼容
数据库·labview·labview知识·labview功能·labview程序
寺中人3 小时前
MySQL 8.0 Windows 完整安装教程:环境配置、密码重置与常见报错排查
数据库·windows·mysql·环境搭建·mysql 安装
这个DBA有点耶3 小时前
同样48核配置TPS差1倍?高性价比数据库一体机的“软硬协同”才是分水岭
服务器·数据库·架构
这个DBA有点耶3 小时前
MySQL 8.0执行计划分析利器:EXPLAIN ANALYZE到底比EXPLAIN强在哪?
数据库·mysql·代码规范
YHHLAI4 小时前
SQL 完全指南:从入门到精通
数据库·sql