一次本以为安全的 MySQL 升级,却并不安全
A safe MySQL upgrade that wasn't so safe

原始链接: https://blog.elis.cc/articles/a-safe-mysql-upgrade-that-wasnt-so-safe/

一次常规的数据库升级演变成了一场生产灾难,原因是源数据库与副本之间的 ID 分配不一致。该问题源于早先的一次迁移,当时为某张表添加了 `AUTO_INCREMENT` 主键。由于 `ALTER TABLE` 操作可能导致不同副本间的行顺序不一致,因此两个实例之间的新 ID 并不匹配。 数据的损坏因数据库采用 `MIXED` 二进制日志格式而进一步加剧。对于大多数表,MySQL 使用 `STATEMENT` 复制,在本地重新执行更新逻辑,从而映射出正确(尽管不同)的 ID。然而,对于其中一张包含 `AUTO_INCREMENT` 列的表,MySQL 触发了 `ROW` 模式复制,直接将源库的 ID 盲目复制到副本中。由于该表在副本中的 ID 不同,这些外键引用指向了错误的行,导致了数据损坏。 此次事故凸显了 MySQL 复制中一个危险的陷阱:在 `MIXED` 日志模式下,涉及 `AUTO_INCREMENT` 列的操作可能是不确定的,从而导致静默且灾难性的数据不一致。这也提醒我们在复制环境中执行模式迁移时必须格外谨慎,因为源库与副本之间的行为可能存在巨大差异。

Hacker News | 最新 | 往期 | 评论 | 提问 | 展示 | 招聘 | 投稿 | 登录 一次并不安全的 MySQL 安全升级 (elis.cc) 12 分 | el1s7 发布于 2小时前 | 隐藏 | 往期 | 收藏 | 讨论 | 帮助 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 加入 YC | 联系 搜索:
相关文章

原文

I get a notification that my database version has reached end of life, and it has to be upgraded. And the AWS extended support fees are a good motivator to upgrade as soon as possible.

I had a green replica up, so I upgrade that, check that everything works correctly, and then switch over.

Quick and easy, right?

I thought so. However, not everything was correct. An hour later, I get reports of a weird bug, so I inspect the database and find out that one specific table, let’s call it table X, has its IDs assigned in a different order. The row that had ID 1 in the previous database now has ID 26.

This table is also referenced in 6 other tables, 5 of which correctly reference these new IDs, but one table is somehow using the IDs of the previous database, which now refer to completely different rows.

That is mind-boggling. How could things get so messed up?

The migration

Some time ago, before the upgrade, a migration was run to add a new auto-incrementing primary key to table X.

ALTER TABLE X ADD COLUMN id INT NOT NULL AUTO_INCREMENT PRIMARY KEY;

The migration also updated 6 related tables so that they referenced the new ID instead of the old one. For each table, the update looked roughly like this:

UPDATE some_table
JOIN x
  ON x.old_id = some_table.x_old_id
SET some_table.x_id = x.id;

The AUTO_INCREMENT column

As it turns out, adding an AUTO_INCREMENT column to a replicated table can result in the rows getting different IDs on the source and replica.

According to the MySQL documentation on replication and AUTO_INCREMENT, adding an AUTO_INCREMENT column with ALTER TABLE might not produce the same row ordering on the source and replica. The order in which the IDs are assigned depends on the storage engine and the order in which the rows are processed.

OK, I wasn’t aware of that.

But why would this new field be correctly referenced in 5 out of 6 tables and be completely messed up in one table?

The binary log format

This is where it becomes more confusing.

MySQL replication uses the “binary log” to record changes made on the source. Those changes are then sent to the replicas, which use them to reproduce the same transactions.

What gets recorded, and how it gets applied on the replica, depends on the binary log “format”. MySQL supports 3 “formats”:

  • STATEMENT: The SQL statement itself is written to the binary log, and the replica executes that statement.
  • ROW: The changes made to individual rows are written to the binary log, and those row changes are applied directly to the replica.
  • MIXED: With mixed logging, statement-based logging is used by default, but the logging mode switches automatically to row-based in certain cases.

Apparently, my source database had binlog_format configured as MIXED.

So the reason the 5 tables referenced the correct new IDs is because their update statements were replicated using STATEMENT mode. The exact UPDATE statement ran again on the replica. It looked up the replica’s version of x.id and wrote the correct local ID into each related table.

But for the remaining table, MySQL decided to use ROW mode instead… In that mode, the replica does not run the original UPDATE. It receives the resulting row changes from the source and applies them directly.

So the x_id values generated on the source were copied to the replica. But because table X had different IDs on the replica, those values now pointed to completely different rows.

You might ask, why did MySQL decide to use ROW mode just for one table?

The only visible difference I could find between this table and the other 5 was that this one had an AUTO_INCREMENT column.

MySQL does have specific cases involving AUTO_INCREMENT that it considers unsafe for statement-based replication and therefore logs using ROW under MIXED.

That’s ironic, isn’t it? It all seems to come down to the AUTO_INCREMENT feature in the end.

Conclusion

Be careful with MySQL replicas. The scary thing about this kind of issue is that it’s unexpected and easy to miss, but it can quickly turn into a disaster in production, and you’re left wondering how did things end up like that.

联系我们 contact @ memedata.com