关系型数据库每天服务着大量业务系统,很多开发者已经非常熟悉 CRUD 和事务,但未必认真想过:数据库到底靠什么保证数据不出错?这里的“出错”不只是字段填错,更典型的场景是:一组操作执行到一半,某一步失败;两个请求同时改一条数据,互相覆盖;事务已经提交,机器却突然断电,重启后丢了一条记录;应用层检查通过,但外键还是被绕过,写入了根本不存在的数据。
这些场景背后,其实对应数据库里不同的保护机制。关系型数据库之所以能在工程中被信任,并不是因为它会自动修复业务错误,而是因为它通过事务、日志、并发控制和完整性约束,把数据写坏的可能性压到了很低。
这篇文章围绕“数据不出错”这条主线展开,讲清楚原子性、持久性、隔离性和完整性约束分别解决什么问题,MySQL 的 redo log、undo log、锁和约束是怎么工作的,以及实际开发中应该怎么用、怎么验证、怎么排查。读完后再回看一个转账接口,你会更清楚数据库在背后替你扛住了哪些风险。
1. 先想清楚:数据库里的“正确”靠哪四层机制保证
1.1 从转账场景看业务正确性
假设用户 A 要给用户 B 转账 100 元。最简单的逻辑是两条 SQL:
UPDATE account SET balance = balance - 100 WHERE user_id = 1; UPDATE account SET balance = balance + 100 WHERE user_id = 2;如果第一句执行成功,第二句因为网络、磁盘或代码异常没执行,A 的钱少了,B 的钱没多,账就平不了。这个场景说明,业务上“正确执行一次转账”并不是两个独立的“更新成功”,而是两条 SQL 被当作一个整体,一致性结果必须是资金总额不变。
这种业务规则,数据库本身并不理解。数据库理解的是更通用的保证:它允许你把多个操作包在一个事务里,事务提交之前,所有中间状态对外不可见;事务失败时,把已经做过的影响全部撤回。这样“转账不丢钱”的应用逻辑才有落地的基础设施。
1.2 ACID 并不是一个“按钮”
在学习关系型数据库时,很多人把 ACID 当四个单词记,但它不是一个可以一键开启的功能,而是多种机制叠加后的效果。
| 属性 | 核心含义 | 数据库怎么实现 |
|---|---|---|
| 原子性 Atomicity | 事务内所有操作要么全部生效,要么全部不生效 | 失败时通过 undo log 回滚已做的修改 |
| 一致性 Consistency | 事务前后数据满足约束和业务规则 | 数据库事务保证状态整体迁移,但业务规则要靠约束和应用代码配合 |
| 隔离性 Isolation | 并发事务之间不能互相干扰 | 锁、MVCC、隔离级别控制可见性 |
| 持久性 Durability | 已提交事务的修改不会因故障而丢失 | 先写 redo log,再异步刷脏页,崩溃后按日志恢复 |
一致性看起来像数据库的事,实际是应用与数据库共同的事。数据库只能保证“事务开始前如果满足约束,事务结束后的数据不会破坏主键、外键、CHECK 等约束”。例如“转账后总余额不能变”这类业务一致性,数据库不会自动判断,必须由事务里的 SQL 和约束共同表达。
1.3 数据“写不对”的故障分层
可以把数据出错的风险按来源分成几层:
- 程序执行了多个步骤,中途某一步失败,需要回滚。
- 数据库在内存里改了数据,还没来得及写回磁盘,进程崩溃或断电。
- 多个事务同时操作同一行,读取和写入相互覆盖。
- 应用代码绕过约束,把不符合规则的脏数据写入表里。
- 硬件损坏、备份不完整或恢复流程缺失导致数据不可恢复。
关系型数据库的应对思路,可以概括为:事务层管原子性和隔离性,日志层管持久性,约束层管业务合法性,而备份和恢复流程管外部灾害。四层职责不同,少了任何一层,“数据不出错”都只是口号。
2. 原子性:为什么一组 SQL 必须“要么全成要么全毁”
2.1 用事务包住一组写操作
把转账逻辑放进事务,是开发者最熟悉的写法:
START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE user_id = 1; UPDATE account SET balance = balance + 100 WHERE user_id = 2; COMMIT;如果第二条 UPDATE 失败,你可以在应用代码里调用 ROLLBACK:
START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE user_id = 1; -- 如果这里抛异常,执行: ROLLBACK;先在一个客户端里做一次最小实验,观察回滚效果:
CREATE TABLE account ( id INT PRIMARY KEY, balance DECIMAL(12,2) ); INSERT INTO account VALUES (1, 100.00); START TRANSACTION; UPDATE account SET balance = balance - 20 WHERE id = 1; SELECT balance FROM account WHERE id = 1; -- 当前事务能看到 80 ROLLBACK; SELECT balance FROM account WHERE id = 1; -- 回滚后仍然 100这里要理解:ROLLBACK 不是简单地把“上一秒的数据”找回来,而是逐个恢复事务内修改过的行。数据库为每个事务的写操作记录了“旧值”,回滚时用旧值覆盖新值。这个机制就是 undo log。
2.2 没有事务时会出现什么错误
如果忘记使用事务,或者把 autocommit 设置为 1 后逐条执行多条修改 SQL,每条 SQL 都可能被当作独立事务提交。在转账场景里,第一句 UPDATE 成功后立即提交,第二句失败,数据库不会因为第二句失败而撤销第一句,因为第一句已经不是同一个事务了。
很多初学者在应用层这么写:
conn.execute("UPDATE account SET balance = balance - 100 WHERE user_id = 1") conn.execute("UPDATE account SET balance = balance + 100 WHERE user_id = 2")两个 execute 之间没有显式事务,一旦第二个 execute 抛异常,第一个操作已经生效。正确做法是在一个事务内执行,并把事务边界控制在数据修改的入口层,而不是散落在业务代码的每一步里。
2.3 undo log 如何把写了一半的数据拉回来
undo log 是存储引擎层的一种日志,记录的是事务修改前的数据版本。一个事务在执行更新时,会先写 undo,再修改数据页。如果事务回滚,系统会顺着 undo 记录把数据回滚到事务开始前。
要注意:undo log 并非只服务回滚。在 MySQL InnoDB 的 MVCC 机制里,它还要为“正在进行中的事务之前的读操作”提供旧版本数据。所以,一个事务执行时间越长、修改行越多,undo log 积累可能越多,这会影响性能和磁盘占用。这也解释了为什么“别开长事务”是数据库开发的常见红线。
3. 持久性与 WAL:数据为什么敢先留在内存再落盘
3.1 直接刷盘太慢,于是先写日志
数据库为了性能,不会每次 UPDATE 都立刻把磁盘上的数据文件改掉。InnoDB 在内存里有缓冲池,数据页被修改后先在缓冲池变脏,之后再由后台线程找时机刷盘。
问题来了:如果脏页还没刷盘,数据库突然崩溃,已提交的修改去哪找?
数据库选择的方案不是“每次提交立刻把脏页刷盘”,而是先写日志。这个策略叫 WAL(Write-Ahead Logging),翻译过来是先写日志,再写数据。它背后的逻辑基于两个事实:
- 日志写入是顺序追加,磁盘顺序写比随机写快得多。
- 即使数据页没落盘,只要日志记录在,崩溃后就能按日志重放修改。
所以 InnoDB 在事务提交时,会先把本次修改对应的 redo log 刷到磁盘,保证“事务提交成功 = 日志已经安全落盘”。至于数据页什么时候刷新到磁盘,可以由后台慢慢完成。
MySQL 里可以用 SQL 查看 redo log 相关配置:
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit'; SHOW VARIABLES LIKE 'innodb_log_file_size'; SHOW VARIABLES LIKE 'innodb_log_files_in_group';3.2 从 redo log 看提交可靠性
redo log 记录的是物理页面的变化,主要解决“持久性”,保证已经提交的事务数据不丢。
可以把一个事务提交流程简化成:
- 事务修改缓冲池里的数据页,同时生成 redo 记录。
- 提交时把 redo 记录刷到磁盘上的 redo log 文件。
- 返回客户端“COMMIT 成功”。
- 后台线程之后把脏页刷到系统表空间的数据文件。
如果数据页还没刷盘时数据库崩溃,重启后,数据库会扫描 redo log,对已经提交但还未刷盘的数据页进行重放,数据就找回来了。
生产中最重要的参数是innodb_flush_log_at_trx_commit。它的取值直接影响持久性强度:
| 参数值 | 提交时行为 | 风险级别 | 适用场景 |
|---|---|---|---|
| 1 | 每次事务提交都把 redo log 刷到磁盘 | 最安全,理论上不丢已提交事务 | 金融、订单、账户等核心库 |
| 2 | 每次事务提交把 redo log 写入操作系统缓存,每秒刷一次磁盘 | 数据库进程崩溃不丢,操作系统崩溃可能丢最近 1 秒 | 可以容忍极短数据丢失的业务 |
| 0 | 每秒把日志写入并刷到磁盘,事务提交不主动刷 | 数据库崩溃可能丢最近 1 秒甚至更多 | 日志型、性能敏感且允许丢失的数据 |
生产环境如果没有特殊理由,innodb_flush_log_at_trx_commit应保持为 1。
3.3 组提交与刷盘频率
如果每次提交都做一次日志刷盘,高并发下性能压力会很大。InnoDB 使用组提交:多个事务在接近同一时间提交时,可以合并到一次刷盘操作中,一起把日志落盘。这样同样可以保证持久性,但磁盘 fsync 次数大幅减少。
从开发视角看,这条机制的启示是:单个事务里不要做太多无意义操作,也不要在同一个事务里混入很多互不相关的更新。事务写得越轻,提交越快,日志压力越小。如果为了“省事务”把所有写操作都塞进一个超大事务,反而会造成锁停留久、undo 膨胀和日志量暴涨。
常见误区是认为“COMMIT 成功之后,数据就一定在数据文件里了”。准确理解应该是“COMMIT 成功之后,事务的修改已经记录在持久化的日志里,数据文件可能还没刷,但可以通过日志恢复”。只有配置了innodb_flush_log_at_trx_commit=1,才能把在数据库层面的丢失风险降到最低。
4. 并发控制:脏读、不可重复读和幻读是怎么被拦下的
4.1 没有隔离时,并发事务会读到什么
原子性解决“一个事务自身失败”的问题,但没有解决“多个事务同时执行”的问题。两个事务同时读同一行、同时写同一行,如果没有隔离,可能会看到不该看到的数据。
经典的三个异常现象:
- 脏读:事务 A 修改了数据但未提交,事务 B 读到了这个修改。如果 A 最后回滚,B 就读到了从未真正存在的数据。
- 不可重复读:事务 B 在同一事务里两次读取同一行,第一次读到 100,事务 A 提交把行改成 200,B 第二次读变成 200。
- 幻读:事务 B 按条件查一批数据,事务 A 插入了满足条件的新行并提交,B 再查时多出几行。
关系型数据库通过隔离级别让开发者决定“容忍哪种现象”。SQL 标准定义了四个级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现依赖 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 基本不加隔离读锁,风险极高,极少使用 |
| READ COMMITTED | 不会 | 可能 | 可能 | 当前读加锁,快照读每次生成新快照 |
| REPEATABLE READ | 不会 | 不会 | MySQL 中基本不会 | 普通读走 MVCC 快照,当前读加锁与间隙锁 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 所有读写都加锁或转成串行,并发能力低 |
需要知道,MySQL 默认隔离级别是 REPEATABLE READ。它通过 MVCC 和间隙锁在多数场景下解决了幻读,但和标准定义里的实现并不完全一样,不同数据库行为会有差异。
4.2 快照读与当前读的差别
MySQL InnoDB 里,普通 SELECT 在多数隔离级别下是“快照读”:它读取一个一致性快照,不加锁,不阻塞其他事务,也不会被其他事务未提交的修改影响。
REPEATABLE READ 的核心特点是:事务第一次做快照读时生成快照,之后在这个事务内继续使用同一份快照,所以两次 SELECT 看到的结果一致。
下面用一个实验理解快照读:
事务 A 启动并先读取余额:
START TRANSACTION; SELECT balance FROM account WHERE id = 1; -- 此时读到 100事务 B 接着修改并提交:
UPDATE account SET balance = 200 WHERE id = 1; COMMIT;事务 A 再次读取同一行:
SELECT balance FROM account WHERE id = 1; -- REPEATABLE READ 下仍然读到 100,因为快照没有变化 COMMIT;如果隔离级别改成 READ COMMITTED,事务 A 第二次 SELECT 会生成新的快照,看到的会是 200。这就是两种隔离级别表现出的可观察差异。
4.3 锁和隔离级别怎么配合
快照读解决“读”的可见性问题,但如果多个事务要修改同一行,数据库仍然需要“当前读”加锁。当前读包括SELECT ... FOR UPDATE、UPDATE、DELETE等,它们读取的是已提交的最新版本,并对记录加锁,防止并发写冲突。
InnoDB 在 REPEATABLE READ 下默认使用临键锁,封锁的范围不仅包括匹配到的记录,还包括记录之前的间隙。这样能防止其他事务在这个范围插入新行,从而解决幻读。
锁机制带来正确性,也带来风险。一个常见问题是:两个事务各自锁住不同资源,然后互相等待对方释放资源,形成死锁。MySQL 检测到死锁后,会回滚其中一个事务,应用侧就会收到类似错误:
Deadlock found when trying to get lock; try restarting transaction这个错误不是数据库坏了,而是数据库主动打破死锁,保护系统不进入无人可推进的状态。应用层要做的不是避免数据库出现死锁提示,而是捕获提示并带幂等逻辑重试。
注意:REPEATABLE READ 并不是在所有场景下天然最好。事务并发高、不同事务更新不同行却很在意锁等待时,可以评估业务对一致性的要求,再决定是否调整隔离级别。不要为了提升并发,在一笔资金操作里使用 READ UNCOMMITTED。
5. 完整性约束:数据库在源头拒绝错误数据
5.1 约束不是应用校验的替代品
事务和并发控制能保证“数据状态不会因为执行失败而错乱”,但挡不住应用主动写入不符合业务规则的数据。比如账户余额是负数、用户编号不存在、两个用户同名同手机号等。
数据库提供了另一层防线:完整性约束。在数据进入表之前,数据库会先检查约束,不满足就直接返回错误,让错误在源头暴露出来。
应用层也会做校验,但它往往不是可靠的防线。原因是:
- 应用有多个入口,换一个接口可能漏掉校验。
- 应用校验先读到旧值,等真正写入时,另一个事务可能已经改变数据。
- 数据可以通过 SQL 直连修改,绕过应用逻辑。
数据库约束是在存储层面执行,无论谁来写入,都必须先过这一关。
5.2 用 DDL 把约束建起来
实际建表时,可以在列级和表级声明约束:
CREATE TABLE account ( id BIGINT PRIMARY KEY, user_name VARCHAR(64) NOT NULL, phone VARCHAR(20) NOT NULL, balance DECIMAL(12,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, CONSTRAINT uk_account_user_phone UNIQUE (user_name, phone), CONSTRAINT chk_account_balance CHECK (balance >= 0) ); CREATE TABLE account_log ( id BIGINT PRIMARY KEY, account_id BIGINT NOT NULL, amount DECIMAL(12,2) NOT NULL, created_at DATETIME NOT NULL, CONSTRAINT fk_account_log_account FOREIGN KEY (account_id) REFERENCES account(id) );这段 DDL 包含了几类约束:
- PRIMARY KEY 保证主键不重复,且创建唯一索引。
- NOT NULL 防止关键字段为空。
- UNIQUE 保证业务唯一键不重复。
- CHECK 限制取值必须满足表达式。
- FOREIGN KEY 维护父子表之间的引用完整性,防止插入不存在的账号日志。
外键约束从数据库层面保证“关联数据不会悬挂”。如果日志表引用的 account_id 在账户表不存在,INSERT 会直接失败,应用层能很快发现数据问题,而不是在统计和汇总时才发现关联不上。
5.3 CHECK 约束在各版本的差异与实战注意
同样一张表,在 MySQL 5.7 和 8.0 里对 CHECK 的处理是不一样的。MySQL 5.7 及其早期版本只是解析 CHECK 但不强制,很多开发者在旧库上建过 CHECK,发现负数余额照样能插进去,原因就在版本兼容上。
所以落地时先确认数据库版本:
SELECT VERSION();如果是 MySQL 8.0,CHECK 默认强制执行。如果生产还在 5.7,与其依赖版本不确定的 CHECK,不如在应用层、存储过程和写 SQL 时都做校验,并考虑升级数据库版本。
业务上常见的“余额不为负数”校验,除了 CHECK,还有一种常见做法是使用条件更新:
UPDATE account SET balance = balance - 100 WHERE id = 1 AND balance >= 100;如果这条 UPDATE 的受影响行数是 0,说明余额不足,程序可以终止后续流程。这种写法把“余额够不够”变成原子判断,能避免并发情况下先读取、再判断、再更新引入的竞态。
注意:约束虽然可靠,但仍需要提前设计。主键、唯一键的选择会影响写入效率;外键约束在插入、删除时会增加额外锁和检查开销。高并发写入场景下,外键是否可用需要结合性能测试决定,不要把一个会频繁更新的父表主键无脑关联到日志表的外键上。
6. 崩溃恢复:数据库怎么从断电或进程被杀中活过来
6.1 数据页可能损坏,但日志能帮忙重放
很多工程师都不敢想象“数据库服务器突然断电”到底会发生什么。实际上,只要日志机制完整,InnoDB 并不需要依赖数据页里每一个字节都最新,因为崩溃恢复阶段会重新执行日志。
简化后的恢复流程如下:
- 数据库启动进入恢复流程。
- 找到最近一次检查点记录的日志位置。
- 从检查点开始向后扫描 redo log。
- 对已经提交但没有完全刷入数据页的事务,重新应用其修改。
- 对包含未提交事务的数据页,使用 undo log 回滚到事务前的状态。
这个流程不需要人工输入,启动过程中数据库会打印相关日志。MySQL 里常见的恢复期日志类似:
[Note] InnoDB: Starting crash recovery. [Note] InnoDB: The log sequence number 123456789 is less than 987654321 [Note] InnoDB: Doing recovery: scanned up to log sequence number 987654321 [Note] InnoDB: Crash recovery finished.看到这类日志不必紧张,说明数据库正在做启动恢复。
6.2 检查点为什么重要
如果每次恢复都从最开始的事务重放,日志量会越来越大,启动时间无限拉长。检查点机制用来解决这个问题。
数据库会在某个安全时刻把缓冲池里的脏页刷到磁盘,并把当前日志位置记录为检查点。恢复时只需要从检查点后面的日志开始重放,之前的修改已经落盘,不需要再处理。
InnoDB 自动维护检查点。对于 DBA 或开发者来说,比较实际的现象是:异常重启后,如果崩溃点离检查点很远,恢复时间会更长。因此,日志文件大小、刷盘频率和脏页比例都影响崩溃恢复时间。
可以通过命令观察 InnoDB 状态里的日志和恢复信息:
SHOW ENGINE INNODB STATUS;输出中LOG段落会展示日志序列号、写入位置、刷盘位置等信息。它是排查数据库异常状态的重要工具。
6.3 恢复不是数据备份的替代品
崩溃恢复解决的是“数据库自身异常中断后的内部一致性”,它不能解决以下几种情况:
- 数据文件所在磁盘物理损坏,连日志文件一起丢失。
- 运维误操作,执行了不带 WHERE 条件的 DELETE。
- 逻辑判断错误,把正确的线上数据批量覆盖为错误值。
- 机房断电、存储设备异常导致日志无法写入或读取。
这些情况下,数据库没有可靠日志可以重放,只能依靠备份和 binlog 进行恢复。所以“持久性”和“备份”是两个不同层级的问题。前者保障普通故障下数据不丢,后者保障极端事故下还能找回。
如果一个系统只有数据库自动恢复,没有做过备份恢复演练,那“数据不出错”依然没有闭环。备份策略至少要覆盖全量备份、增量备份和 binlog 归档,并且要定期在临时环境验证备份可以恢复。
7. 本地 MySQL 实操:自己验证一次回滚、隔离和锁等待
7.1 准备实验环境
为了让原理落到实践,可以采用一套最简单的实验环境:本地安装 MySQL 8.0,使用命令行客户端。实验前先确认隔离级别:
SHOW VARIABLES LIKE 'transaction_isolation';MySQL 8.0 里默认通常是REPEATABLE-READ。下面所有实验都基于这个默认隔离级别。先建一张简单表:
CREATE DATABASE IF NOT EXISTS db_demo; USE db_demo; CREATE TABLE IF NOT EXISTS t_account ( id INT PRIMARY KEY, balance DECIMAL(12,2) NOT NULL ); INSERT INTO t_account VALUES (1, 100.00);打开两个命令行终端,分别连接同一个数据库,方便模拟两个并发事务。
7.2 实验 1:回滚让数据还原
在终端 A 执行:
USE db_demo; START TRANSACTION; UPDATE t_account SET balance = balance - 20 WHERE id = 1; SELECT balance FROM t_account WHERE id = 1;这时候当前终端 A 能查到80.00。在终端 A 执行回滚:
ROLLBACK;然后查询:
SELECT balance FROM t_account WHERE id = 1;输出应该恢复为100.00。这个实验能直观看到原子性里的回滚效果。如果程序里 START TRANSACTION 后没有 ROLLBACK,也没有 COMMIT,事务一直挂着,数据会被锁住,这就是很多“接口卡死”的根源。
7.3 实验 2:不同隔离级别看到不一样的已提交数据
先把数据恢复成初始值:
UPDATE t_account SET balance = 100.00 WHERE id = 1;终端 A:
USE db_demo; SET SESSION transaction_isolation = 'REPEATABLE-READ'; START TRANSACTION; SELECT balance FROM t_account WHERE id = 1; -- 第一次读到 100终端 B:
USE db_demo; UPDATE t_account SET balance = 88.00 WHERE id = 1; COMMIT;回到终端 A,再次查询:
SELECT balance FROM t_account WHERE id = 1; -- REPEATABLE READ 下仍然读到 100接着把终端 A 的事务提交,然后再开启一个新事务:
COMMIT; START TRANSACTION; SELECT balance FROM t_account WHERE id = 1; -- 这次会读到 88 COMMIT;这个实验演示了同一种隔离级别下,快照什么时候创建很关键。也是很多线上问题造成“我明明改了数据,为什么别人查不到”的原因之一:可能是读的一方正在长事务里沿用旧快照。
7.4 实验 3:未提交事务会阻塞另一个写
把数据复位:
UPDATE t_account SET balance = 100.00 WHERE id = 1;终端 A:
USE db_demo; START TRANSACTION; UPDATE t_account SET balance = 50.00 WHERE id = 1;注意此时终端 A 不要执行 COMMIT 或 ROLLBACK。终端 B 尝试更新同一行:
USE db_demo; UPDATE t_account SET balance = 80.00 WHERE id = 1;终端 B 会一直等待,直到终端 A 提交或回滚。等几秒后,可以通过 MySQL 配置的锁等待超时结束:
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';默认通常是 50 秒,也可以主动在终端 B 用SHOW PROCESSLIST;看到状态为Waiting for table metadata lock或updating时的锁等待状态。最后用终端 A 执行 ROLLBACK,终端 B 的 UPDATE 才会继续执行成功。
7.5 查看锁等待与事务状态命令
除了上面几个实验,还需要掌握两个常用排查命令。第一个是查看正在执行的所有线程:
SHOW PROCESSLIST;输出里可以观察Info列是否长时间卡在 UPDATE,Time列大小是否异常偏大。第二个是查看 InnoDB 运行状态:
SHOW ENGINE INNODB STATUS\G截取TRANSACTIONS段落,可以看到当前活跃事务、锁等待、undo 日志记录,以及检测到的死锁信息。遇到“数据库变慢且 CPU 不高”的问题,优先看这里,而不是直接重启数据库。
注意:这些实验会让数据行锁住,实验结束后要适当 ROLLBACK 或 COMMIT。不要在测试环境之外乱做锁实验,避免影响线上业务。
8. 生产环境最该注意的“数据不出错”实践
8.1 事务边界和代码写法的常见问题
理解了原理后,进入真实项目里,最常见的错误不是“没用事务”,而是事务边界不合理。
错误习惯一:事务里调用外部服务或 RPC。事务本应该在数据库确认成功后很快提交,大量时间消耗在网络等待上,锁会一直持有着,其他事务只能排队。
错误习惯二:一个事务里执行几十条无关业务更新。比如在同一个事务里扣订单、写流水、更新用户积分、清理缓存。任何一个环节失败都会导致整批回滚,排查难度很高,日志量也大。
错误习惯三:捕获异常后不回滚,继续执行后续流程。事务没有以 ROLLBACK 结束,连接归还连接池后可能保留打开状态,也会导致连接池被占满。
比较适合的做法是:
- 事务紧贴数据库写操作,不包含外部 I/O。
- 每个接口保持“开启事务、执行核心写、立即提交或回滚”的结构。
- 通过 Spring @Transactional 等框架声明时,要理解它的默认回滚规则,不要误以为捕获异常后框架还能自动回滚。
- 单元测试覆盖提交路径和回滚路径,不只验证成功数据。
8.2 幂等与重试怎么避免二次扣款
持久性越好,程序重试越安全,但重试本身也可能带来新的问题。比如支付回调里扣款接口被网络超时触发重试,如果上游没有幂等键,数据库事务执行了两次,用户被扣两次款。
解决思路是给每次业务操作一个唯一键,在处理前先查幂等记录或直接利用唯一约束。
例如:
CREATE TABLE t_payment_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_no VARCHAR(64) NOT NULL, account_id INT NOT NULL, amount DECIMAL(12,2) NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_biz_no (biz_no) );事务开始时先插入一条 biz_no 对应的记录。如果 biz_no 重复,唯一索引会报错,程序就知道这是重复请求,不要再扣款:
START TRANSACTION; INSERT INTO t_payment_record(biz_no, account_id, amount, status) VALUES ('ORDER-20250101-0001', 1, 100.00, 0); UPDATE tb_account SET balance = balance - 100 WHERE id = 1 AND balance >= 100; UPDATE tb_account SET balance = balance + 100 WHERE id = 2; COMMIT;当重复请求发生时,INSERT 会因为唯一键冲突直接失败,后续更新不会执行。这样的设计比“先查再判断”更可靠,因为唯一索引本身是数据库层的原子保护。
8.3 生产参数、备份与可观测性
在数据库层,有几项配置值得重点检查。它们不全是数据库默认就能保证的,需要 DBA 或业务负责人根据场景确定。
| 检查项 | 推荐策略 | 作用 |
|---|---|---|
| innodb_flush_log_at_trx_commit | 1 | 保证事务提交日志持久化 |
| sync_binlog | 1 | 配合 binlog 时减少主从和恢复的不一致窗口 |
| 隔离级别 | 按业务需要确认 | 资金类业务通常不建议低于 READ COMMITTED |
| 备份策略 | 全量 + binlog 归档 | 支持误删、损坏等极端恢复 |
| 慢 SQL 与锁监控 | 定期采集慢日志 | 发现长事务、大事务、锁等待 |
| 大事务治理 | 限制单事务影响行数和执行时间 | 减少 undo 积累与锁滞留 |
在恢复能力上,建议至少每季度做一次恢复演练。创建一台新实例,从最近一次全量备份加上 binlog 增量,恢复到某个时间点,然后做数据完整性对比。只有演练过恢复流程,备份才是可信的。
可观测性方面,不能只盯着 CPU 和内存。数据库中与数据正确性直接相关的监控项包括:慢查询数量、锁等待时间、死锁次数、主从复制延迟、binlog 写入位置、redo log 空间使用率。结合错误日志里的异常提示,可以更快定位到事务、锁、约束或日志层面的问题。
下面是一张排错速查表,适合在遇到数据异常时按顺序对照:
| 现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 事务中某操作失败,但部分数据已改 | 没有真正开启事务,或异常后被吞掉 | 查看代码事务边界,回看数据库二进制日志和时间点 | 显式开启事务,异常时 ROLLBACK |
| 修改了数据,其他连接查不到 | 读连接在旧快照里,或隔离级别导致快照复用 | 查看连接事务开始时间,检查隔离级别 | 确认读事务是否有必要长期开启 |
| UPDATE 一直卡住 | 同一行被其他未提交事务锁住 | SHOW PROCESSLIST; SHOW ENGINE INNODB STATUS | 找到持有锁的连接并提交或回滚 |
| 业务检查通过,写入负数余额 | 缺少 CHECK 或业务 SQL 未原子判断 | 查看表结构和数据库版本 | 增加约束,或使用带条件的 UPDATE |
| 死锁报错 | 两个事务按不同顺序锁多个资源 | 查看 InnoDB 死锁日志 | 统一加锁顺序,捕获后按幂等键重试 |
| 提交成功但重启丢数据 | 日志刷盘参数不安全 | 查看 innodb_flush_log_at_trx_commit | 核心库改为 1,检查存储设备断电行为 |
| 表数据异常但本机启动正常,无法回退 | 误删、大批量覆盖,无备份 | 查看 binlog,定位误操作时间点 | 使用备份 + binlog 恢复到时间点 |
这七类现象覆盖了“事务、日志、隔离、锁、约束、恢复”几个主要环节。理解背后的机制后,排查数据问题就会更有方向,而不是重启数据库碰运气。
真正要对“数据不出错”负责,不只是数据库自身的 ACID,还有开发者在事务边界、幂等设计、约束建模、备份演练和监控上的投入。关系型数据库已经提供了强大且成熟的底层保障,但要把它用好,仍然需要知道每个机制什么时候生效、为什么生效、失效时该看哪里。建议先在一个本地 MySQL 实例上把回滚、隔离和锁等待实验跑完,再回到真实项目代码里检查事务边界,这比背参数更有价值。