【仓颉语言入门 · 第二篇 第 13 课】

【仓颉语言入门 · 第二篇 第 13 课】事务:批量操作的成败一致

上一课结尾埋了个问题:一条业务操作往往要动好几张表——转账要一扣一加,缺一半都不算数。可我们写出来的每条 SQL 都是独立执行的,第二条失败时,第一条已经落库,数据就烂在一半。这一课就把这个坑填上:先在客户端和代码里各制造一次"钱没了"的事故,再请出本课主角事务,把一组 SQL 打包成"要么全成、要么全不算"的整体。


目录(第二篇导航)

第一篇《仓颉语言入门》共 30 课已完结(环境搭建 → 语法 → 函数 → 类型系统 → 工程化 → 并发 → 两场实战)。第二篇在更高的起点上继续,共 26 课、4 个模块:

模块 课次 内容
一、补遗篇 01~06 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理
二、数据库篇 07~15 入门与连接、建表、查询、插入、更新删除、预编译防注入、事务、手写 DAO、综合小实战
三、进阶硬核篇 16~22 反射、宏基础、deriving 实战、FFI 基础、FFI 进阶、并发容器、对象池
四、实战项目篇 23~26 HTTP 基础、HTTP 进阶、多线程日志分析器、简易爬虫
  1. 正则表达式(std.regex)
  2. 时间日期处理(std.time)
  3. 命令行参数解析(std.argopt)
  4. 随机数与概率(std.random)
  5. 加密与哈希(std.crypto)
  6. 二进制数据处理(std.binary)
  7. 数据库入门:认识 std.database.sql,连上 MySQL
  8. 建表:用代码定义表结构
  9. 查询:SELECT 与结果集遍历
  10. 插入:INSERT、占位符与自增主键
  11. 更新与删除:UPDATE / DELETE
  12. 预编译与防 SQL 注入
  13. 事务:批量操作的成败一致(本文)
  14. 手写 DAO 数据访问层
  15. 数据库综合小实战
  16. 反射:在运行时打量类型
  17. 宏基础:std.ast 与 quote
  18. deriving 宏实战
  19. 与 C 互操作基础:FFI 调用 C 函数
  20. FFI 进阶:结构体、指针与编译配置
  21. 并发容器:并发安全的集合
  22. 对象池与资源复用
  23. 实战三:HTTP 服务端基础
  24. 实战四:HTTP 进阶与 JSON 接口
  25. 实战五:多线程日志分析器
  26. 实战六:简易爬虫

一、事故现场:钱扣了,人没收到

本课全程用一张新表 account(账户表)讲故事。老规矩,先在客户端里把它建好,灌入两个账户各 1000 元:

USE tumo_governance;
DROP TABLE IF EXISTS account;
CREATE TABLE account (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    balance BIGINT NOT NULL
);
INSERT INTO account (name, balance) VALUES ('zhangsan', 1000), ('lisi', 1000);
SELECT * FROM account;
+----+----------+---------+
| id | name     | balance |
+----+----------+---------+
|  1 | zhangsan |    1000 |
|  2 | lisi     |    1000 |
+----+----------+---------+

现在来一笔转账:zhangsan 给 lisi 转 100 元。拆成 SQL 就是两条 UPDATE,一条扣、一条加:

UPDATE account SET balance = balance - 100 WHERE name = 'zhangsan';
UPDATE account SET balance = balance + 100 WHERE name = 'lisi';

两条都成功,皆大欢喜。但假如第二条语句出了岔子呢?我们故意把第二条的列名写错(balance2),模拟"第二条 SQL 执行失败":

UPDATE account SET balance = balance - 100 WHERE name = 'zhangsan';
Query OK, 1 row affected
Rows matched: 1  Changed: 1  Warnings: 0
UPDATE account SET balance2 = balance + 100 WHERE name = 'lisi';
ERROR 1054 (42S22): Unknown column 'balance2' in 'field list'

第一条成功,第二条报错。查一下余额:

SELECT * FROM account;
+----+----------+---------+
| id | name     | balance |
+----+----------+---------+
|  1 | zhangsan |     900 |
|  2 | lisi     |    1000 |
+----+----------+---------+

zhangsan 的 100 块扣掉了,lisi 却没收到——100 元凭空蒸发。

为什么会这样?因为 MySQL 默认处于自动提交模式:每条 SQL 一执行完就立刻落库、立刻生效,各管各的。第一条 UPDATE 已经"签字画押",第二条的失败根本牵连不到它。

而转账这件事的本质要求是:扣款和加款要么都成功,要么都不算数。这正是事务要解决的问题。


二、客户端里把事务三件套试明白

事务(Transaction)就是把多条 SQL 打包成一个整体:打包期间执行的改动都只是"草稿",最后你喊一声"定稿"(提交)才真正落库;中间出了任何问题,喊一声"作废"(回滚),草稿全部撕掉,数据回到打包前的样子。

MySQL 客户端里的三件套:

语句 作用
START TRANSACTION; 开始一个事务(之后的改动都是草稿)
COMMIT; 提交:草稿定稿,全部落库
ROLLBACK; 回滚:草稿作废,全部撤销

三、先在代码里复现一次事故

客户端明白了,搬进仓颉代码。先用我们熟悉的老写法(不开事务)复现一次事故——两条 UPDATE 各自独立执行,第二条故意写错列名:

package cj_mysql_demo

import std.database.sql.*
import mariadb.cdbc;

main(): Int64 {
    let driver = DriverManager.getDriver("mariadb").getOrThrow()
    let ds = driver.open("mariadb://127.0.0.1:3306", [
        ("username", "root"),
        ("password", "wangpeng"),
        ("database", "tumo_governance")
    ])
    let conn = ds.connect()

    // 演示前先把两人的余额重置为 1000
    let reset = conn.prepareStatement("UPDATE account SET balance = 1000")
    reset.update()
    reset.close()

    let stmt1 = conn.prepareStatement("UPDATE account SET balance = balance - 100 WHERE name = 'zhangsan'")
    stmt1.update()
    println("zhangsan 扣款成功")
    stmt1.close()

    let stmt2 = conn.prepareStatement("UPDATE account SET balance2 = balance + 100 WHERE name = 'lisi'")
    stmt2.update()
    println("lisi 加款成功")
    stmt2.close()

    conn.close()
    0
}

运行输出:

zhangsan 扣款成功
An exception has occurred:
SqlException: Unknown column 'balance2' in 'field list', errorCode: 0

"zhangsan 扣款成功"打印出来了,随后程序在第二条语句处抛异常退出。用客户端查余额,和第一节一模一样的惨状:zhangsan 900、lisi 1000。代码里的每条 update() 和客户端一样,执行完就独立提交了。

这里顺带说一个本课实测的细节:异常是在 prepareStatement 那一行就抛出的,不是 update() 才抛——mariadb 驱动在 prepare 阶段就会把 SQL 发给服务器校验,列名不对当场报错。所以上面对"事故现场"的代码顺序做了对应安排:先执行完第一条,再去 prepare 那条错误的语句。


四、事务版:createTransaction 三件套

仓颉侧的事务 API 和客户端三件套一一对应,只是事务要先从连接上"领"出来:

客户端 SQL 仓颉代码 作用
(连接自动提交中) let tx = conn.createTransaction() 从连接创建一个事务对象
START TRANSACTION tx.begin() 开始事务
COMMIT tx.commit() 提交
ROLLBACK tx.rollback() 回滚

SQL 语句本身一个字都不用改,还是走 conn.prepareStatement(...)——事务是挂在连接上的一层状态,从 begin() 到 commit() / rollback() 之间,这个连接上执行的所有改动都在草稿区里。

标准的写法套路是:try 里干活、最后 commit;catch 里 rollback:

package cj_mysql_demo

import std.database.sql.*
import mariadb.cdbc;

main(): Int64 {
    let driver = DriverManager.getDriver("mariadb").getOrThrow()
    let ds = driver.open("mariadb://127.0.0.1:3306", [
        ("username", "root"),
        ("password", "wangpeng"),
        ("database", "tumo_governance")
    ])
    let conn = ds.connect()

    // 演示前先把两人的余额重置为 1000
    let reset = conn.prepareStatement("UPDATE account SET balance = 1000")
    reset.update()
    reset.close()

    let tx = conn.createTransaction()
    tx.begin()
    try {
        let stmt1 = conn.prepareStatement("UPDATE account SET balance = balance - 100 WHERE name = 'zhangsan'")
        stmt1.update()
        println("zhangsan 扣款成功")
        stmt1.close()

        let stmt2 = conn.prepareStatement("UPDATE account SET balance2 = balance + 100 WHERE name = 'lisi'")
        stmt2.update()
        println("lisi 加款成功")
        stmt2.close()

        tx.commit()
        println("事务已提交")
    } catch (e: SqlException) {
        tx.rollback()
        println("出错了,事务已回滚:${e.message}")
    }

    conn.close()
    0
}

第一次运行(第二条还是错列名 balance2):

zhangsan 扣款成功
出错了,事务已回滚:Unknown column 'balance2' in 'field list', errorCode: 0

程序不再崩溃——异常被 catch 接住,rollback() 把草稿撕掉,还打印了一句人话提示。客户端查余额,两人都是 1000,一分钱没丢。

然后把 balance2 改回 balance,再跑一次:

zhangsan 扣款成功
lisi 加款成功
事务已提交

余额变成了 900 / 1100。和第三节的事故版对比,业务代码几乎没动,只是多了"领事务 → begin → try 里 commit → catch 里 rollback"这层外壳,批量操作的成败一致性就有了保障。

最后再叮嘱三个实测出来的小细节:

  1. 事务对象不用关闭。它身上没有 close() 方法——本课实测给 tx 调 close() 会直接编译报错 'close' is not a member of interface 'Transaction'。commit() 或 rollback() 一执行,这个事务就结束了。
  2. catch 的类型写 SqlException。它在 std.database.sql 里,我们已经 import std.database.sql.*,直接用;e.message 里就是数据库返回的错误原文。
  3. 回滚之后连接还活着。rollback() 只是结束当前事务,连接可以继续领下一个事务、继续干活,不用重连。

五、常见报错速览

列名写错(本课贯穿始终的那位):

SqlException: Unknown column 'balance2' in 'field list', errorCode: 0

mariadb 驱动在 prepareStatement 阶段就抛出。真实项目里看到 Unknown column,先核对列名拼写,再确认连的是不是你以为的那个库、那张表。

给事务对象调 close:

error: 'close' is not a member of interface 'Transaction'

事务不是 Statement、ResultSet 那种资源对象,没有 close()。结束事务的唯一方式是 commit() 或 rollback()。


六、常用 API 速查(事务篇)

代码 作用
let tx = conn.createTransaction() 从连接创建事务对象
tx.begin() 开始事务(之后的改动进入草稿区)
tx.commit() 提交:草稿全部落库,事务结束
tx.rollback() 回滚:草稿全部撤销,事务结束
固定套路 begin() → try { 干活; commit() } catch (e: SqlException) { rollback() }

七、常见问题 FAQ

Q1:如果忘了 commit 也忘了 rollback,程序直接关掉连接,改动会落库吗?
不会。本课实测过:begin() 之后执行扣款,不 commit 直接 conn.close(),再查余额发现改动被服务器自动回滚了——没定稿的草稿,连接一断就被丢弃。但千万别把这个当成偷懒的理由:显式写 commit() / rollback() 是清清楚楚的意图,靠"断连自动回滚"是碰运气的习惯。

Q2:事务里还能用占位符吗?上一课的知识不会作废吧?
完全兼容,而且是绝配。占位符解决的是"值怎么安全地进 SQL",事务解决的是"几条 SQL 怎么捆绑成败",两个维度互不干扰。事务里的占位符写法和以前一模一样:

let tx = conn.createTransaction()
tx.begin()
try {
    let stmt = conn.prepareStatement("UPDATE account SET balance = balance - ? WHERE name = ?")
    stmt.set(1, 100)
    stmt.set(2, "zhangsan")
    stmt.update()
    stmt.close()
    tx.commit()
    println("扣款已提交")
} catch (e: SqlException) {
    tx.rollback()
    println("扣款已回滚:${e.message}")
}
扣款已提交

Q3:事务是不是什么 SQL 都能回滚?
不是,DDL(建表、删表这类结构操作)管不住。MySQL 里执行 CREATE TABLE、DROP TABLE 时会触发"隐式提交"——它先把当前事务悄悄提交了再干活。本课在客户端实测过:

START TRANSACTION;
CREATE TABLE tx_test (id INT);
ROLLBACK;
SHOW TABLES LIKE 'tx_test';
+-------------------------------------+
| Tables_in_tumo_governance (tx_test) |
+-------------------------------------+
| tx_test                             |
+-------------------------------------+

ROLLBACK 喊完了,tx_test 表照样立在那里。所以记住边界:事务保护的是 INSERT / UPDATE / DELETE 这类数据操作,保护不了表结构的增删。

Q4:一组 SELECT 查询需要包事务吗?
本课的场景都是"多条写操作打包",这也是初学者 99% 的事务用法。至于"多条查询要读到一致的数据快照"这种读场景的事务,涉及隔离级别等进阶概念,日常练习用不到,本课不展开。先把"打包写操作"这一件事练熟。


八、课后练习

前 3 题必做,第 4 题选做。全部在 account 表上操作。

第 1 题(必做)复现事故现场

把第三节的无事务版程序敲进 cj_mysql_demo 并运行,确认输出"zhangsan 扣款成功"后抛出 SqlException,然后用客户端查余额确认 900 / 1000 的事故现场。

提示:重点是亲手感受"每条 update() 独立提交"——第一条成功了,异常拦不住它。

期望输出:

zhangsan 扣款成功
An exception has occurred:
SqlException: Unknown column 'balance2' in 'field list', errorCode: 0

第 2 题(必做)事务版转账

把第四节的事务版程序敲进工程,先保持错列名 balance2 运行一次,确认回滚且余额不变;再把列名改对运行一次,确认提交成功、余额 900 / 1100。

提示:套路是 begin() → try { 干活; commit() } catch (e: SqlException) { rollback() };每次运行前程序里都有重置余额的语句,放心反复跑。

期望输出(第一次):

zhangsan 扣款成功
出错了,事务已回滚:Unknown column 'balance2' in 'field list', errorCode: 0

期望输出(第二次):

zhangsan 扣款成功
lisi 加款成功
事务已提交

第 3 题(必做)事务批量开户

用事务打包两条 INSERT:给 wangwu 开户存 500、给 zhaoliu 开户存 800,两人要么一起开户成功,要么都不开。

提示:SQL 为 INSERT INTO account (name, balance) VALUES (?, ?);同一个 stmt 每次执行前重新 set;两条都 update() 完再 commit()。

期望输出:

插入 wangwu 成功
插入 zhaoliu 成功
事务已提交
+----+----------+---------+
| id | name     | balance |
+----+----------+---------+
|  1 | zhangsan |    1000 |
|  2 | lisi     |    1000 |
|  3 | wangwu   |     500 |
|  4 | zhaoliu  |     800 |
+----+----------+---------+

(做完这题记得在客户端 DELETE FROM account WHERE id > 2; 清理,保持表演数据干净。)

第 4 题(选做)验证 DDL 隐式提交

在客户端依次执行 START TRANSACTION;、CREATE TABLE tx_test (id INT);、ROLLBACK;、SHOW TABLES LIKE 'tx_test';,解释为什么 ROLLBACK 之后表还在。

提示:对照 Q3——DDL 会触发隐式提交,事务管不住表结构的增删;验证完记得 DROP TABLE tx_test;。

期望输出:

+-------------------------------------+
| Tables_in_tumo_governance (tx_test) |
+-------------------------------------+
| tx_test                             |
+-------------------------------------+

下节预告

现在我们的 SQL 都散落在 main 里:连接、拼语句、set 参数、事务……业务一多就成一锅粥。下一课 手写 DAO 数据访问层——把"账户表的增删改查"收进一个专门的类,业务代码只管说"我要转账",不管 SQL 怎么写。这正是第一篇第 29 课"仓库层"思想在数据库上的回响。


系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写。本文全部代码、运行输出与报错文案均已在 SDK 1.2.0 + MySQL 5.7 环境下逐块编译运行、逐字实测;如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。


💬 遇到问题?扫码联系作者

跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。


📥 工具下载

本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):

  • GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
  • 打开页面后进入 发行版(Releases),两种包任选其一:
    • 安装版:下载 CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用;
    • 免安装版(Portable):下载 CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
  • 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn
posted @ 2026-10-23 00:29  upala  阅读(5)  评论(0)    收藏  举报