一、先搞懂两个核心:事务的原子性和持久化到底是啥

很多做开发的朋友都听过“事务”这个词,但真要讲清楚它的两个核心属性——原子性和持久化,可能很多人会说“要么全成要么全败”“改完不会丢”,但具体怎么实现的,可能就懵了。咱们先把这俩概念掰碎了说,再讲背后的保障机制。

1.1 原子性:事务里的操作不能“拆零件”

原子性的意思是,一个事务里不管有多少步操作,要么全部成功,要么全部失败,绝对不能出现“只做了一半”的情况。举个生活里的例子:你去银行转账,从A账户扣100块,给B账户加100块,这两步必须一起成,不能出现A扣了B没加,或者A没扣B加了的情况。

放到数据库操作里,比如你要给两个用户更新积分,用事务包起来的话,原子性就保证这俩操作要么都改,要么都不改。

1.2 持久化:改完的数据不能“凭空消失”

持久化的意思是,事务提交成功后,数据就永久存在数据库里了,哪怕这时候服务器突然断电、重启,之前改的数据也不能丢。比如你转账成功后,哪怕银行的机房突然停电,来电后你查A账户少了100、B账户多了100的结果必须还在。

二、MariaDB怎么保证原子性和持久化?靠两个“日志”

MariaDB为了实现这俩属性,搞了两个关键的日志:redo log和bin log。这俩日志分工不一样,咱们一个个说。

2.1 redo log:给“没写完的操作”打草稿

redo log(重做日志)是InnoDB存储引擎自己的日志(MariaDB默认用InnoDB),专门用来保证原子性和持久化。它的工作原理有点像你写作业的草稿纸:

  • 你想改某个数据的时候,不会直接去写最终的作业本(磁盘上的数据库文件),而是先在草稿纸(redo log)上记下来“我要把这个数据改成X”;
  • 等草稿纸记完了,再慢慢把草稿上的内容抄到作业本上;
  • 如果抄到一半停电了,来电后你先看草稿纸,没抄完的内容再重新抄一遍,保证不会丢。

举个具体的操作例子,假设你要把用户ID为1的积分从10改成20:

  1. 先把用户1的积分数据从磁盘读到内存的缓存页里;
  2. 在内存里把积分改成20,这时候缓存页是“脏”的(和磁盘里的不一样);
  3. 先往redo log里写一条记录:“修改用户1的积分,旧值10,新值20”;
  4. 等redo log写成功了,才给应用返回“事务提交成功”;
  5. 之后再找时间,把脏的缓存页写到磁盘的数据库文件里。

这个过程的关键是:redo log写成功了才返回提交,这样哪怕之后磁盘的数据库文件还没改,停电后重启,InnoDB会先扫一遍redo log,把没写到磁盘的操作再重做一遍,保证数据不会丢——这就实现了持久化。同时,redo log是按事务分组的,比如一个事务有两步操作,redo log会把这两步打包记下来,要是事务失败,就会跳过这个分组的redo log,不会只做一半——这就实现了原子性。

2.2 bin log:给“所有操作”做总账

bin log(二进制日志)是MariaDB服务层的日志,和存储引擎没关系,它的作用是记录所有对数据库的修改操作,相当于一个总账本。比如你做了转账、改积分、删用户,bin log都会把这些操作记下来,主要用在两个地方:

  • 主从同步:主库的修改操作写到bin log,从库拉过去执行,保证主从数据一致;
  • 数据恢复:数据库出问题了,用备份加bin log可以把数据恢复到出问题前的状态。

bin log的记录格式有三种,最常用的是row格式(行格式),比如修改用户1的积分,row格式的bin log会记下来“把用户1的积分从10改成20”,很具体。

三、最容易出问题的场景:崩溃时两个日志不一致

既然两个日志都是用来保证数据安全的,那如果它们不一样了,会怎么样?咱们先看一个具体的崩溃场景,再分析问题。

3.1 崩溃场景还原:事务提交到一半断电

咱们还是用修改用户积分的例子,假设事务提交的过程是这样的:

  1. InnoDB先写redo log,写完了;
  2. 然后要写bin log,写到一半的时候,服务器突然断电了。

这时候会出现什么情况?

  • 下次重启后,InnoDB会扫redo log,发现这条修改操作已经在redo log里记了,就会把它重做一遍,最终数据库里用户1的积分变成20;
  • 但bin log里只写了一半,这条修改操作根本没记完整,相当于bin log里没有这次修改的记录。

这就出问题了:如果之后要做数据恢复,用备份加bin log恢复的话,根本找不到这次修改的记录,恢复出来的积分还是10,和实际的数据库数据(20)不一样;如果是主从同步,从库也不会拿到这次修改,主从数据就不一致了。

反过来也有问题:如果先写bin log,再写redo log,写到redo log一半断电,那bin log里有这次修改,redo log里没有,重启后InnoDB不会重做,数据库里的积分还是10,bin log里却有修改记录,同样会导致主从同步或数据恢复出错。

3.2 怎么解决?MariaDB的“两阶段提交”

为了避免两个日志不一致,MariaDB用了“两阶段提交”的机制,把两个日志的提交过程拆成两步,保证要么都成功,要么都失败。具体流程是这样的:

  1. 准备阶段(Prepare):InnoDB先把redo log写好,然后把redo log设为“准备提交”的状态,这时候还没给应用返回成功;
  2. 提交阶段(Commit):先写bin log,等bin log写成功了,再把redo log设为“正式提交”的状态,然后给应用返回“事务提交成功”。

现在再看断电的情况:

  • 如果在准备阶段断电:redo log是“准备提交”状态,bin log还没写,重启后InnoDB发现redo log没正式提交,就会把这次修改回滚,两个日志都没有这次操作,一致;
  • 如果在写bin log的时候断电:redo log是“准备提交”状态,bin log没写完,重启后InnoDB同样会回滚这次修改,两个日志都没有,一致;
  • 如果bin log写完了,在把redo log设为正式提交的时候断电:重启后InnoDB会扫redo log,发现是“准备提交”状态,再去查bin log,发现bin log里有这次操作,就会把redo log改成“正式提交”,然后重做这次修改,两个日志都有,一致;
  • 如果都写完了才断电:重启后两个日志都有,一致。

完美!这样就彻底解决了两个日志不一致的问题。

3.3 实战测试:模拟崩溃场景看效果

咱们用MariaDB来实际测试一下这个机制,测试前先做几个准备:

  • 开启redo log(默认是开的);
  • 开启bin log,设置格式为row;
  • 开启两阶段提交(默认是开的)。

首先,先创建一个测试表:

-- 创建测试表,存储用户积分
CREATE TABLE test_user (
    user_id INT PRIMARY KEY,
    score INT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 插入测试数据
INSERT INTO test_user (user_id, score) VALUES (1, 10);

然后,模拟事务提交过程中崩溃的场景。咱们可以用一个简单的脚本,在事务提交的时候强制杀掉MariaDB进程,模拟断电:

# 测试用的MariaDB命令行脚本,开启事务,修改数据后强制杀进程
mysql -u root -p -e "
START TRANSACTION; -- 开启事务
UPDATE test_user SET score = 20 WHERE user_id = 1; -- 修改积分
-- 这里模拟事务提交到一半,强制杀进程(实际场景是断电)
kill -9 \$(pgrep mariadbd)
"

这时候进程被杀掉,相当于崩溃了。重启MariaDB后,咱们先查数据库里的数据:

SELECT * FROM test_user WHERE user_id = 1;

结果会是score=20,说明redo log的机制生效了。

然后查bin log,看有没有这次修改的记录:

# 查看bin log的内容,找修改用户1的记录
mysqlbinlog /var/lib/mysql/binlog.000001 | grep 'test_user'

你会发现bin log里没有这次修改的记录——因为刚才是在写bin log之前杀的进程,两阶段提交的机制生效了,两个日志都没有这次操作,一致。

如果咱们把脚本改一下,先提交事务再杀进程:

mysql -u root -p -e "
START TRANSACTION;
UPDATE test_user SET score = 20 WHERE user_id = 1;
COMMIT; -- 先提交事务
kill -9 \$(pgrep mariadbd)
"

重启后查数据库,score还是20,查bin log也会找到这次修改的记录,两个日志一致。

四、实际开发中的注意事项和优化

虽然MariaDB默认的机制已经很完善了,但实际开发中还是有很多细节要注意,不然还是可能出问题。

4.1 别乱改两阶段提交的配置

MariaDB有个参数叫sync_binlog,用来控制bin log什么时候刷到磁盘:

  • 设为1:每次事务提交都把bin log刷到磁盘,最安全,但性能会稍微差一点;
  • 设为0:由操作系统决定什么时候刷bin log,性能好,但如果操作系统崩溃,可能会丢bin log;
  • 设为N:每N个事务刷一次bin log,性能和安全的折中。

还有个参数叫innodb_flush_log_at_trx_commit,控制redo log什么时候刷到磁盘:

  • 设为1:每次事务提交都把redo log刷到磁盘,最安全;
  • 设为2:事务提交后先写到操作系统缓存,再刷到磁盘,性能好,但操作系统崩溃会丢redo log;
  • 设为0:每秒刷一次redo log,性能最好,但最不安全。

很多人觉得性能差,就把这两个参数改成0或者2,这时候如果操作系统崩溃,就可能出现两个日志不一致的问题。比如sync_binlog=0,事务提交后bin log在操作系统缓存里,还没刷到磁盘,这时候操作系统崩溃,bin log丢了,redo log里却有,就会导致不一致。所以如果是对数据安全要求高的场景,一定要把这两个参数都设为1,哪怕性能差一点。

4.2 主从同步要注意什么

主从同步的时候,从库是拉主库的bin log来执行的,如果主库的两个日志不一致,从库的同步就会出问题。所以主库的两个参数必须设为1,保证主库的bin log是完整的。另外,从库最好开启半同步复制,保证主库的事务提交后,至少有一个从库收到了bin log再返回成功,这样就算主库崩溃,从库也有完整的数据。

4.3 数据恢复的正确姿势

用备份加bin log恢复数据的时候,一定要保证备份的时间点是在两个日志一致的状态下做的。比如你在凌晨2点做全量备份,备份的时候要先停掉业务,保证所有事务都提交完,两个日志都一致,再做备份。不然备份出来的数据本身就有问题,用bin log恢复也没用。

五、总结

MariaDB的原子性和持久化是靠redo log和两阶段提交来保证的,而bin log是用来做同步和恢复的。两个日志不一致的问题是MariaDB最核心的风险之一,但只要正确配置参数、理解两阶段提交的机制,就能完全避免。实际开发中,数据安全永远是第一位的,不要为了一点性能牺牲数据的一致性,毕竟数据丢了再找回来的成本,比那点性能差高太多了。