很多存量项目还保留着 MySQL 5.7 时期的表设计:规则写在服务代码里,字段间关系依赖约定,给索引做调整时只能直接上线观察。迁移到 MySQL 8 并不意味着要重写全部 SQL;更有价值的做法,是先挑那些能减少错误、降低维护成本、又可以逐表落地的能力。

本文只讨论以 InnoDB 为主、目标版本为 MySQL 8.x(8.4 可作为 LTS 落点)的应用库。它不是一份“所有新特性”清单,也不把升级说成没有代价的性能优化。每次 DDL、认证方式和执行计划变化,都应该先在与生产接近的数据和流量条件下验证。

CHECK:把字段关系放回数据库,但先校正版本认知

下面这段订单约束很适合写入表定义:数量必须大于零,剩余数量不能为负,也不能超过原始数量。

CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
qty DECIMAL(18, 8) NOT NULL,
rest_qty DECIMAL(18, 8) NOT NULL,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (id),
CONSTRAINT ck_orders_positive_qty CHECK (qty > 0),
CONSTRAINT ck_orders_rest_qty CHECK (rest_qty >= 0 AND rest_qty <= qty)
) ENGINE = InnoDB;

这不是 MySQL 8.4 才有的能力。MySQL 8.0.16 起才真正实现并执行 CHECK 约束;更早版本曾允许有限的 CHECK 语法,但只解析、不执行。也就是说,如果一套应用仍会连到 5.7,不能因为 DDL 中“写了 CHECK”就以为数据一定安全。官方发布说明明确记录了这一变化。MySQL 8.0.16 release notes

对于开发者,它的收益不是少写一行 if,而是让不同写入入口遵守同一底线:后台任务、管理脚本、批量导入、修复 SQL 和未来新增服务,都无法轻易绕过“剩余数量不能大于原始数量”这种领域事实。

CHECK 的两个容易踩坑的边界

  1. NULL 不是 FALSE。 CHECK 条件结果为 TRUE 或 UNKNOWN 时都能通过;因此 qty、rest_qty 是否允许为空,要另外用 NOT NULL 表达。不能指望 CHECK (qty > 0) 自动拒绝 NULL。
  2. 它不是触发器,也不能查询其他表。 约束表达式不能用子查询、变量、存储函数或非确定性函数;不能拿 NOW() 解决“过期时间必须晚于当前时间”这类动态规则。跨表余额、库存并发扣减仍需事务、行锁和业务逻辑协作。

这些限制和约束在写入时的行为可参考 MySQL 8.4 CHECK 文档。

给已经运行的旧表加约束:先查脏数据,再改变规则

直接执行 DDL 不是第一步。先把历史数据中会违反新规则的记录列出来,并决定是修复、归档还是显式允许例外:

SELECT id, qty, rest_qty, status
FROM orders
WHERE qty IS NULL
OR qty <= 0
OR rest_qty IS NULL
OR rest_qty < 0
OR rest_qty > qty
ORDER BY id
LIMIT 200;

确认没有不应保留的异常后,再增加约束:

ALTER TABLE orders
ADD CONSTRAINT ck_orders_positive_qty CHECK (qty > 0),
ADD CONSTRAINT ck_orders_rest_qty CHECK (rest_qty >= 0 AND rest_qty <= qty);

最后用两条故意失败的写入验证数据库真的在兜底;示例中的数值只用于验证,不要在生产订单表上随意插入:

-- 应报 CHECK constraint 违反错误
INSERT INTO orders (qty, rest_qty, status)
VALUES (10, 11, 'pending');

-- 应报 CHECK constraint 违反错误
INSERT INTO orders (qty, rest_qty, status)
VALUES (0, 0, 'pending');

写入失败时,应用实际会看到什么

对上面的 rest_qty > qty 示例,MySQL 8.0.16+ 的典型返回是:

ERROR 3819 (HY000): Check constraint 'ck_orders_rest_qty' is violated

服务端不应只把它记录成“数据库异常”。这是一个可以预期的业务拒绝:调用方应该把它转换为参数或状态不合法的结果,并保留约束名、订单主键和请求追踪号;不要通过捕获异常后重试同一条错误写入来掩盖问题。

还要特别区分普通写入和 IGNORE 写入:INSERT IGNORE、UPDATE IGNORE、LOAD DATA ... IGNORE 遇到约束失败时,可能以警告形式跳过出错行,而不是让整个语句报错。批量任务必须检查受影响行数和 SHOW WARNINGS,否则“SQL 执行成功”并不代表数据全部入库。

INSERT IGNORE INTO orders (qty, rest_qty, status)
VALUES (10, 11, 'pending');

SHOW WARNINGS;

下面这些并非都是 MySQL 8 的新能力,但它们往往会在启用新约束、收紧类型或提高并发后更早暴露。异常码适合用来做应用层分类;错误文案会随版本、驱动和语言封装略有差异。

场景 常见 MySQL 错误 应用侧第一反应
CHECK 约束失败 3819,Check constraint ... is violated 返回可理解的字段 / 状态错误,不盲目重试
唯一键冲突 1062,Duplicate entry 判断是否为幂等请求;不要把所有重复都当成成功
外键写入失败 1452,Cannot add or update a child row 检查父记录、写入顺序与事务边界
非法 JSON 3140,Invalid JSON text 拒绝或规范化输入;不要把坏 JSON 悄悄转成字符串
死锁 1213,Deadlock found ... 对幂等短事务做有限次数、带退避的重试
锁等待超时 1205,Lock wait timeout exceeded 排查长事务 / 热点行;不要无限重试
连接耗尽 1040,Too many connections 限流、检查连接池泄漏和慢请求,不是只调大上限

1213 和 1205 都不是“数据库坏了”的同义词。对交易类写入,更常见的根因是多个事务以不同顺序锁同一批记录、事务中混入远程调用、或一条范围更新意外扩大了锁定范围。统一锁顺序、缩短事务、让更新条件命中唯一 / 明确索引,通常比把超时时间调得更长有效。

ALTER TABLE 是否真正做到低阻塞,取决于 MySQL 小版本、表结构、约束操作和当前长事务;不要因为它看起来只是“加一个规则”就假设无锁。上线前应在预发实测耗时、观察元数据锁,并为失败或长时间等待准备停止窗口。写操作高峰期尤其应先清理长事务,再安排变更。

从 5.7 开始就值得用的增量能力

JSON 加生成列:保留灵活输入,但别放弃索引和约束

MySQL 5.7 提供原生 JSON 类型和生成列。对扩展字段频繁变化、但又需要按其中少数键查询的业务,这比把 JSON 全部拆散或完全交给应用过滤更平衡。

ALTER TABLE orders
ADD COLUMN ext JSON NULL,
ADD COLUMN channel VARCHAR(32)
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(ext, '$.channel'))) STORED,
ADD INDEX idx_orders_channel_created (channel, created_at);

好处是查询可以走普通索引,JSON 仍能保留非核心扩展字段。代价也很明确:生成列的表达式一旦变成高频更新路径,就会增加写入成本;JSON 里适合索引、参与关联或作为金额 / 状态判断的字段,最终仍应有清晰的类型和约束,不能把 JSON 当成没有治理的“万能字段”。

8.0 带来的开发效率,不只是性能

CTE 和窗口函数:让统计 SQL 少一层临时表和应用循环

以前做“每个用户最近一笔订单”或“按日累计”时,常见写法是嵌套子查询、临时表,甚至把分组后逻辑搬到应用里。8.0 的窗口函数让这类查询更接近业务语言:

WITH ranked_orders AS (
SELECT
user_id,
id,
status,
created_at,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY created_at DESC, id DESC
) AS row_num
FROM orders
WHERE created_at >= CURRENT_DATE - INTERVAL 30 DAY
)
SELECT user_id, id, status, created_at
FROM ranked_orders
WHERE row_num = 1;

优势是可读、可审核,也更容易在同一条 SQL 内完成排名、去重、环比和累计计算。注意它不保证一定更快:窗口函数可能需要排序或临时表,仍要使用真实条件下的 EXPLAIN ANALYZE 验证,而不是只因为 SQL 更漂亮就替换线上关键路径。

EXPLAIN ANALYZE
SELECT user_id, COUNT(*)
FROM orders
WHERE status = 'paid'
AND created_at >= CURRENT_DATE - INTERVAL 7 DAY
GROUP BY user_id;

EXPLAIN ANALYZE 会实际执行语句来给出执行时间与行数,不能把它当成零成本的“查看计划”按钮;生产大查询应限流、限时或在只读副本 / 预发执行。

函数索引:让表达式查询不必全表扫

MySQL 8.0.13 起可为表达式建立函数索引。例如业务邮箱不区分大小写、又不方便立刻改写全部历史字段时:

CREATE INDEX idx_users_lower_email
ON users ((LOWER(email)));

SELECT id, email
FROM users
WHERE LOWER(email) = LOWER('lee@example.com');

它适合已经稳定、重复出现的表达式;不适合把每个偶发查询都做成索引。索引会占空间、拖慢写入,表达式也需要与查询文本匹配才可能被优化器使用。

不可见索引:先验证“删掉会不会慢”,再真正删

存量库经常有不知道谁在用的索引。直接删除后回滚成本高;不可见索引可以先让优化器默认不再选择它:

ALTER TABLE orders
ALTER INDEX idx_orders_status_created INVISIBLE;

-- 在压测或观察期确认无回归后,才考虑真正删除
ALTER TABLE orders
ALTER INDEX idx_orders_status_created VISIBLE;

这非常适合索引瘦身前的验证,但不是自动化删除工具。需要覆盖关键 SQL、慢查询和报表任务,并注意会话级或全局设置可能允许优化器使用不可见索引。详见 Invisible Indexes。

直方图与执行计划:帮助优化器看见非均匀数据

状态列、地区或业务类型等低基数字段,实际分布往往很不均匀。直方图能帮助优化器估算过滤后的行数:

ANALYZE TABLE orders
UPDATE HISTOGRAM ON status WITH 32 BUCKETS;

它不是索引,也不会替代组合索引设计;它改变的是估算信息。上线前后都应比对关键 SQL 的实际耗时和扫描行数,因为更“聪明”的估算也可能让某个边缘查询换到不合适的计划。

InnoDB DDL:更容易上线,不等于可以跳过变更管理

MySQL 8 的原子 DDL、InnoDB 在线 DDL 和部分 Instant DDL,减少了过去“建到一半失败,留下半张表”的风险,也让某些新增列 / 索引操作更适合渐进上线。真正对开发有价值的不是记住所有 ALGORITHM,而是把 DDL 当成一项可观测变更:

-- 明确表达希望减少读写阻塞;如果当前操作不支持,MySQL 会拒绝而非静默降级。
ALTER TABLE orders
ADD COLUMN source VARCHAR(32) NULL,
ALGORITHM=INSTANT,
LOCK=NONE;

不同版本和操作对 INSTANT / INPLACE / COPY 的支持范围不同。对涉及大表、索引重建、列位置调整、生成列或约束的 DDL,不应复制上面的参数后直接执行;先查当前版本官方矩阵、在预发复现,并观察复制延迟、长事务和元数据锁。参考 InnoDB Online DDL。

重启、连接数与并发:不要把“默认值”当成容量结论

以前不少人会把“重启后自增 ID 可能变化”当作 InnoDB 的固定规律。它对较早版本的经验有参考价值:计数器曾主要存在内存里,重启后需要重新初始化。但在现代 MySQL 8 的 InnoDB 中,当前最大 AUTO_INCREMENT 计数会写入 redo log 并在 checkpoint 保存到数据字典,正常重启后会恢复这个值。

这不等于 ID 从此连续、也不等于 ID 能承担业务顺序:回滚、失败写入、批量预分配和异常退出都可能留下间隔;异常退出前尚未落盘的已分配值也不能保证绝不复用。因此订单、成交和账务都不应以“自增 ID 必然连续”作为对账或排序依据。自增列更适合作为内部代理键,业务时间与业务流水号应单独记录。

可以先在每个实例上看真实配置,而不是背一套网上参数:

SHOW VARIABLES
WHERE Variable_name IN (
'max_connections',
'innodb_thread_concurrency',
'transaction_isolation',
'autocommit'
);
项目 MySQL 8.4 默认值 容易被误解的地方
max_connections 151 是同时客户端连接上限,不是 QPS、TPS 或安全并发数;上调会增加每连接内存和排队压力
innodb_thread_concurrency 0 0 表示不人为限制并发进入 InnoDB 的线程,不是“数据库只有 0 个并发”
transaction_isolation REPEATABLE-READ 默认可避免同一事务内一致读的变化,但锁定范围与查询条件、索引密切相关
autocommit ON 单条写入自动提交;多条相关写入必须显式放进短事务

官方同样明确:现代多核机器通常不需要先限制 innodb_thread_concurrency;是否限流应该以压测和监控为依据。InnoDB 并发说明 系统变量说明

高频交易写入:隔离级别不是一个全局性能开关

InnoDB 默认是 REPEATABLE READ。对于余额、订单状态或持仓这类需要同一事务内多次读取保持一致视图,或需要防止范围条件被插入新记录影响的流程,应该先保留这个默认值,并让查询走唯一键或合适的范围索引。

对已经验证不依赖范围防幻读、主要做按唯一键的条件更新的高冲突短事务,可以评估在写入会话中使用 READ COMMITTED。它让每次一致读读取更新的快照,并减少一般更新 / 锁定读的间隙锁;代价是幻读可能出现,不能拿它处理需要“先查范围、再假设范围不变”的逻辑。

-- 只在已经完成并发语义验证的写入会话中使用;不要直接改全局默认值。
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

START TRANSACTION;

UPDATE orders
SET rest_qty = rest_qty - 1,
updated_at = NOW(6)
WHERE id = 90001
AND status = 'open'
AND rest_qty >= 1;

SELECT ROW_COUNT() AS affected_rows;
COMMIT;

这里的关键不是 READ COMMITTED 本身,而是 UPDATE 把状态和余量判断放在同一条、命中主键的原子语句中:affected_rows = 1 才表示扣减成功。若需要同时写订单、余额、冻结记录和账本,应把这些写入放入一个短事务,并对 1213 做有上限的幂等重试。SERIALIZABLE 不应作为“交易更安全”的默认替代,它通常会显著增加锁竞争;READ UNCOMMITTED 也不适合作为账务和订单数据的隔离级别。关于 READ COMMITTED 的间隙锁边界见 InnoDB 隔离级别文档。

字段类型:新增能力的正确用法比“用了新类型”更重要

MySQL 8 并没有一个通用的原生 UUID 字段类型;INVISIBLE 也是列属性,不是隐藏敏感数据的安全机制。更实用的是根据数据语义选型:

数据 建议定义 适合的场景与限制
金额、价格、费率 DECIMAL(24, 8) 等按业务精度确定的定点数 不用 FLOAT / DOUBLE 保存需精确对账的金额;精度和币种最小单位要先约定
高频事件时间 DATETIME(6) 保存微秒级事件时间;通常统一按 UTC 写入。它不是新增类型,但对撮合、成交、审计排序很实用
外部 UUID BINARY(16) 用 UUID_TO_BIN() 写入、BIN_TO_UUID() 读取,比 36 字符文本更紧凑;不要把 UUID 是否递增当作业务时间顺序
扩展属性 JSON + 已索引的生成列 JSON 适合低频变化的附加信息;筛选、关联、金额和状态仍应落为具名列
大文本 / 二进制 TEXT / BLOB 大字段不宜被无差别读出;要配合对象存储、长度限制和访问路径设计
暂不在 SELECT * 展示的兼容列 INVISIBLE 列属性 可辅助渐进迁移,但仍会参与主键 / 唯一键比较;不能用于隐藏密码、密钥或权限数据

例如使用二进制 UUID 与微秒时间记录外部请求:

CREATE TABLE order_requests (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
request_id BINARY(16) NOT NULL,
received_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
payload JSON NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order_requests_request_id (request_id)
) ENGINE = InnoDB;

INSERT INTO order_requests (request_id, payload)
VALUES (
UUID_TO_BIN(UUID()),
JSON_OBJECT('source', 'api', 'symbol', 'BTC-USDT')
);

JSON、BLOB、TEXT 和 GEOMETRY 的默认值有额外语法要求:需要写成表达式,即使值本身是字面量。数据类型默认值说明

经典连接编码问题:库是 utf8mb4,为什么仍会乱码或比较报错

这是存量 MySQL 项目最容易被低估的一类问题:数据库、表和列都可能是 utf8mb4,但客户端连接建立时声明的字符集不同,字符串在传输或比较时仍会被错误转换。更进一步,即使字符集同为 utf8mb4,不同的 collation(排序规则) 也可能改变等值比较、唯一性判断和排序结果。

MySQL 至少有四层需要分别确认:服务端默认、数据库默认、表 / 列定义,以及当前会话连接。不要只看建库语句就下结论。

-- 1. 当前连接实际协商的字符集与排序规则
SHOW VARIABLES
WHERE Variable_name IN (
'character_set_client',
'character_set_connection',
'character_set_results',
'collation_connection',
'character_set_server',
'collation_server'
);

-- 2. 当前库、表和列的定义
SELECT
TABLE_SCHEMA,
TABLE_NAME,
TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'orders';

SELECT
COLUMN_NAME,
CHARACTER_SET_NAME,
COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'orders'
AND CHARACTER_SET_NAME IS NOT NULL;

连接协商不一致时,典型现象不是只有“中文显示成乱码”。也可能出现表情无法写入、字符串比较结果意外、唯一索引判断与产品预期不符,或拼接 / JOIN 时出现:

ERROR 1267 (HY000): Illegal mix of collations ... for operation '='

应用应在每个新建连接由驱动 / 连接池统一声明 utf8mb4,不要依赖开发机或服务器默认值。若需要临时验证,会话中可以执行:

SET NAMES utf8mb4;

这条语句会同时设置 character_set_client、character_set_connection、character_set_results,并按该字符集的默认规则设置 collation_connection。若业务需要明确比较规则,也可以在连接建立时指定:

SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci;

但不要把这当成修复历史乱码的方法:如果旧连接曾用错误编码把字节写入,后续再 SET NAMES utf8mb4 只会让新连接正确,无法自动还原旧数据。修复前应先抽样比对原始字节、应用输入和备份,再选择针对性的转换方案;直接对全表 CONVERT TO CHARACTER SET 可能改变索引长度、排序语义和唯一键冲突结果。

对已经全部升级到 MySQL 8 的新系统,统一使用 utf8mb4 是稳妥基线;utf8 是 utf8mb3 的已弃用别名,不应再作为新库约定。在 5.7 与 8.0 / 8.4 混合复制或灰度期间,不能贸然把列改为只有新版本才识别的 collation;应先确认全部副本、驱动和导入导出工具的兼容性,再安排字符集迁移。 MySQL 字符集总览 SET NAMES 语义

为什么 8.4 仍需要谨慎:新能力也会放大旧问题

8.4 是 MySQL 的 LTS 选择,但“LTS”不等于旧应用天然兼容。升级前至少把下面几项列入检查表:

检查项 为什么需要看 可采取的动作
存量数据质量 新 CHECK 会让过去被放行的坏数据在新写入时显形 先跑预检 SQL,定义修复和例外规则
驱动与认证 老驱动、语言运行时和旧认证插件可能无法适配默认认证方式 在升级前做真实连接回归,不要靠生产报错排查
SQL mode、字符集与保留字 更严格的规则会暴露历史 SQL 的隐式依赖 用应用回归和慢查询回放验证
执行计划 统计信息、直方图和优化器改进可能让同一 SQL 改走另一条路径 对核心 SQL 留基线,升级后用 EXPLAIN ANALYZE 复核
DDL 与复制 大表变更、长事务、不同版本副本会让短操作变成长故障 先备份、演练回滚、分批变更,并维护版本兼容顺序

特别是约束上线,不应该变成“应用把异常吞掉”。对于 INSERT IGNORE、UPDATE IGNORE 或批处理,违反 CHECK 的行可能以警告和跳过形式处理;调用方必须能感知写入数量与异常数量,不能只看 SQL 没有抛错就视为成功。

公开事故复盘:43 秒故障为什么会拖成 24 小时

GitHub 2018 MySQL 集群网络分区是一个很适合作为边界案例的公开事故:一次计划性网络维护造成两个数据中心之间约 43 秒的网络分区,随后引发复制拓扑异常、跨地域写入延迟和恢复决策困难,最终形成约 24 小时 11 分钟的服务降级。GitHub 的事后报告强调,当两个方向都有尚未汇合的写入时,为了保护数据完整性,团队选择向前恢复而不是草率回切。

这个案例不是“用了 MySQL 就会出事故”,也不能拿来类比单机实例。它说明的是:高频写入系统的风险不只在 SQL 本身,还在复制拓扑、故障切换、重试风暴、写入保护和恢复演练。对一般团队而言,可落地的教训是:

  1. 故障时先限制额外变更和自动写入,避免扩大分叉;
  2. 让订单写入具备幂等键,重试不会生成第二笔业务动作;
  3. 监控复制延迟、死锁、连接池等待、长事务和错误码比例,而不是只看 CPU;
  4. 定期演练“主库不可写、部分副本落后、消息重复投递”的恢复路径。

完整过程可查看 GitHub October 21 post-incident analysis。

一个务实的升级顺序

如果目标是改善旧项目维护,而不是做一次高风险的大迁移,我倾向按下面顺序推进:

  1. 先盘点版本与连接依赖。 明确所有实例、复制链路、驱动版本和认证方式,准备可恢复备份。
  2. 挑一张边界清楚的小表试点。 先用 CHECK、生成列或不可见索引治理一个具体问题,保留前后对比和回滚办法。
  3. 把执行计划验证写进变更流程。 新索引、直方图、窗口函数改写都应有真实数据验证,不只看语法可执行。
  4. 最后再扩大 DDL 范围。 任何看起来能在线完成的变更,都要把锁等待、复制延迟和长事务纳入发布窗口。

MySQL 8 的价值,不在于让表定义变得更炫,而在于让数据规则、查询意图和变更风险更接近真实业务。CHECK 很适合从订单数量、余额范围、状态组合这类基础规则开始;它能减少“某个入口忘记校验”的问题,但不能替代事务设计、监控、测试和对异常数据的处置策略。

参考资料