告别8.0:升级MySQL 8.4 LTS的兼容性与新特性实测

前年我们团队队接手了一个运行了三年的SaaS后台系统,数据库跑在 MySQL 8.0.33 上。随着业务量上涨,日常QPS从 2000 涨到了 8000,主库CPU经常跑到 70% 以上。正好看到 Oracle 在 2024 年 4 月发布了 MySQL 8.4 LTS,官方明确这是接替 8.0 的长期支持版本,提供 5 年优质支持加 3 年扩展支持,我决定在测试环境先做一次完整升级验证。

我没有直接上生产,而是先拉了一套与生产 1:1 配置的测试环境,数据量约 1200 万行。升级前我重点看了 8.4 的 Release Notes,有几个点直接影响了我们的升级决策:废弃了旧的复制方式默认开启更多安全策略优化器对窗口函数的执行计划更智能。这意味着如果代码里用了 sql_mode 里的旧行为,或者依赖了 8.0 的某些即将移除的特性,升级后可能直接报错。

升级过程我采用逻辑备份加恢复的方式,先用 mysqldump 导出 8.0 的 Schema,然后在 8.4 实例上导入。导入后我执行了 mysql_upgrade,这一步在 8.4 里已经集成到服务器启动流程中,不需要单独跑工具,但我会手动检查 performance_schema 里的版本视图确认升级完成。

-- 升级完成后我用来验证版本和兼容性的查询 SELECT VERSION() AS db_version, @@sql_mode AS current_sql_mode, @@default_authentication_plugin AS auth_plugin; -- 结果示例(脱敏后) -- db_version: 8.4.0 -- sql_mode: ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION -- auth_plugin: caching_sha2_password

升级后第一个实际变化是 角色(Roles)管理更严格了。我们之前在 8.0 里用了一个“只读账号”,权限是直接在用户上 GRANT SELECT,没有用角色。升级后我发现 8.4 对角色继承的检查更严谨,导致一个内部报表服务连接时因为权限路径变化而失败。排查下来是因为 8.4 默认对角色激活逻辑做了调整,我不得不显式给用户激活角色:

-- 在 8.4 中需要显式激活角色,否则会话中权限不生效 CREATE ROLE read_only_role; GRANT SELECT ON biz_db.* TO read_only_role; GRANT read_only_role TO 'report_user'@'%'; SET DEFAULT ROLE read_only_role TO 'report_user'@'%';

性能方面,我重点测了订单报表里的一个复杂查询,用到了 RANK() 窗口函数,在 8.0 上平均耗时 820ms,8.4 上降到了 610ms。EXPLAIN 对比后发现 8.4 的优化器对窗口函数的分区裁剪更精准,减少了一次全表扫描。这个提升对我们每天跑几万次的分析任务来说,意味着每天能省出将近 2 小时的 CPU 时间。

我也测试了 JSON 功能。我们系统里有一张 user_profiles 表,存了用户偏好 JSON。8.4 对 JSON 函数的执行效率有小幅提升,同时 GIS 空间函数也和 JSON 结合得更顺,这让我对后续做“附近门店”这类功能更有信心。

升级到 8.4 后我并没有急着上生产,而是先灰度了三个非核心服务,观察了两周,确认没有复制延迟异常、没有权限报错、没有驱动兼容问题(我们用的是 MySQL Connector/J 8.3.0)。现在生产环境已经全量切换到 8.4 LTS,主库 CPU 日常稳定在 45% 左右,而且我知道这个版本能安全维护到 2029 年,不用再频繁折腾大版本升级。

电商订单系统实战:InnoDB事务与MVCC并发控制解析

我们做的电商系统去年双十一前做压测,订单写入接口在 3000 并发下 RT 从 120ms 飙升到 1100ms,数据库主库出现大量 Waiting for row lock 状态。当时我盯着 SHOW ENGINE INNODB STATUS 看了半小时,才意识到问题出在事务设计上,而不是机器性能不够。

这个订单系统核心表是 ordersorder_items,使用 InnoDB 引擎。InnoDB 的 ACID 支持不是靠嘴说的,而是靠 redo log、undo log、锁、MVCC 这一套组合拳。我们当时的问题在于:一个下单事务里不仅插入订单,还更新了库存、写了积分流水,最后还查了一次订单详情返回给前端。这个事务平均执行时间 180ms,在高并发下,大量行锁长时间不释放,后面请求只能排队。

我决定先拆事务。订单写入和库存扣减必须在一个事务里,否则会出现超卖,但积分流水可以异步。改造后事务只保留核心操作:

START TRANSACTION; -- 插入订单 INSERT INTO orders (order_id, user_id, total_amount, status, created_at) VALUES (UUID_TO_BIN('a1b2c3d4-...'), 10001, 299.00, 'PAID', NOW()); -- 扣减库存(假设 sku_id = 5001) UPDATE product_sku SET stock = stock - 1 WHERE sku_id = 5001 AND stock >= 1; -- 如果受影响行数为 0,说明库存不足,回滚 COMMIT;

这里我为什么一定要用事务?因为插入订单和扣库存必须同时成功或同时失败。如果插入成功但库存没扣,就会出现“幽灵订单”;如果库存扣了但订单没写进去,用户付了钱却查不到订单,客诉直接爆炸。

InnoDB 的 MVCC(多版本并发控制) 在这里起到了关键作用。我们订单表默认隔离级别是 REPEATABLE READ,事务启动时会生成一个 Read View。在事务里多次查询订单状态,即使其他事务已经提交了新订单,当前事务看到的数据也和第一次查询时一致。这保证了我们扣库存时的判断不会受到其他未提交事务干扰。

MVCC 的实现依赖 undo log。每次更新订单行,InnoDB 都会把旧版本数据写到 undo log,通过 DB_TRX_IDDB_ROLL_PTR 形成版本链。有一次线上出现一个奇怪的问题:一个定时任务在凌晨批量更新订单状态,导致早上的报表查询突然变慢。排查后发现,那个批量任务是一个大事务,更新了 10 万行,导致 undo log 里堆积了大量旧版本,早上的查询需要沿着版本链回溯,增加了大量 CPU 和 IO 开销。后来我把批量任务拆成每次 1000 行、每批提交一次,问题立刻消失。

-- 批量更新订单状态时的安全写法(伪批量,实际用程序循环) UPDATE orders SET status = 'EXPIRED' WHERE status = 'UNPAID' AND created_at < NOW() - INTERVAL 30 MINUTE LIMIT 1000; -- 程序里循环执行,直到影响行数为 0

InnoDB 和 MyISAM 的区别在这里体现得非常现实。如果我们当初用 MyISAM,这个订单表在并发写入时直接就是表锁,3000 并发下几乎全部阻塞,而且不支持事务,库存和订单数据早就乱了。这也是为什么我所有核心业务表一律只用 InnoDB。

现在这个下单接口在 5000 并发下 RT 稳定在 150ms 左右,主库锁等待时间从之前的平均 400ms 降到了 20ms。事务短了,锁释放快了,MVCC 版本链也不会被一个长事务拖垮,系统自然就稳了。

索引优化复盘:UUID主键引发的性能雪崩与ULID改造方案

这个坑是我两年前亲手挖的。当时做用户系统,我觉得 UUID 作为主键不用担心冲突,分布式生成也方便,就直接用了 VARCHAR(36) 存 UUID v4。用户量到 500 万的时候,系统开始出现随机的慢查询,特别是用户登录后加载个人中心,有时候要 800ms 以上,而用户表加上索引后也没见明显好转。

我查了慢查询日志,发现全都是 SELECT * FROM users WHERE user_id = ? 这种主键查询慢。按理说主键查询不应该慢,我 EXPLAIN 了一下,发现 type 是 ref,但扫描行数远大于 1。再仔细看 SHOW TABLE STATUSData_lengthIndex_length 的比例非常夸张,索引大小几乎是数据本身的两倍。

原因很直接:UUID v4 是完全随机的字符串,作为聚簇索引的主键,InnoDB 在插入新行时,需要把数据插入到 B+ 树的随机位置,而不是像自增 ID 那样追加到末尾。这导致 页分裂 非常频繁,索引碎片率极高。我查了一下当时表的碎片率,用这个语句:

SELECT table_name, ROUND(data_free / (data_length + index_length) * 100, 2) AS fragment_pct FROM information_schema.tables WHERE table_schema = 'user_db' AND table_name = 'users';

结果碎片率达到了 38%。这意味着磁盘上 38% 的空间是无效的,B+ 树层级也更高,查询时要经过更多节点。

我决定改造主键方案。直接改 UUID 不现实,业务已经跑了一年多。我选了 ULID(Universally Unique Lexicographically Sortable Identifier),它和 UUID 一样唯一,但前 48 位是时间戳,后 80 位是随机位,整体是字典序递增的。这样插入时主键趋势递增,页分裂大幅减少。

改造过程我分了三步:

回填数据时我没有直接 UPDATE,而是分批处理,避免长事务锁表:

-- 分批回填 ULID 的示例(程序里循环) UPDATE users SET ulid = UNHEX(REPLACE('01HX8Y9Z2K3M4N5P6Q7R8S9T0A', '-', '')) WHERE id BETWEEN 1 AND 10000 AND ulid IS NULL;

新的 ULID 生成我用的是 Java 里的 ulid-creator 库,生成出来类似 01HX8Y9Z2K3M4N5P6Q7R8S9T0A,转成 BINARY(16) 存储。查询时这样用:

-- 根据 ULID 查询用户 SELECT * FROM users WHERE ulid = UNHEX(REPLACE('01HX8Y9Z2K3M4N5P6Q7R8S9T0A', '-', ''));

改造完成后,我重新查了碎片率,降到了 4%。用户中心接口的 P99 响应时间从 850ms 降到了 120ms,数据库主库写 QPS 从 1200 提升到 2100 没有明显性能衰减。

这个经历让我明白:主键不只是唯一标识,它直接影响聚簇索引的结构。UUID v4 不是不能用,但在 MySQL InnoDB 这种聚簇索引引擎里,随机主键就是性能杀手。如果一定要用 UUID,可以考虑 UUID v7(带时间戳的版本),或者直接用 ULID。现在新项目我一律用 ULID 做主键,再也没出现过类似的索引碎片问题。

4. 深度对比:为何高并发场景下我们弃PG选MySQL?

很多刚入行的同学问我,现在网上都在讨论 PostgreSQL 多厉害,功能多全,为什么我们公司(以及我待过的几个互联网团队)的核心交易系统还是死守着 MySQL?特别是在高并发的电商场景下,技术选型时甚至直接把 PG 排除了。

这其实不是谁好谁坏的问题,而是谁更适合当下的业务场景和团队现状。我拿我们去年做的一个跨境电商订单系统举例。这个系统在大促期间,单库的写入 QPS 峰值要扛住 1.2万,读取 QPS 接近 8万。在这个量级下,我们选择了 MySQL 8.4 LTS(2024年4月刚发布,我们作为早期适配者),而不是 PostgreSQL 16。

为什么?我们当时做了详细的压测对比,核心差异体现在运维成本生态成熟度上。

PostgreSQL 的优化器确实强大,对复杂子查询和多种索引类型(如 GIN、BRIN)的支持非常优雅。但在我们那个订单系统里,99% 的 SQL 都是简单的 INSERTSELECT ... WHERE user_id = ? ORDER BY created_at DESC。这种场景下,MySQL 的优化器已经足够聪明,而且 InnoDB 引擎对热点行更新的处理机制非常符合我们的预期。

我们当时压测发现一个关键问题:PG 的 VACUUM 机制。虽然 PG 9.6 之后做了很多优化,但在高频率的 UPDATE(订单状态流转非常频繁)场景下,PG 的表膨胀和死元组清理对 CPU 的占用波动比 MySQL 的 Purge 线程要大。我们当时用 PG 跑了一晚上,CPU 偶尔会飙到 70% 以上,而 MySQL 8.4 在同样的负载下,CPU 稳定在 40% 左右,且内存控制得更精准。

另外,从高可用架构的落地速度来看,MySQL 8.4 的 Group ReplicationMySQL Shell 配合起来,搭建一套 InnoDB Cluster 真的很快。我们当时为了应对大促,需要快速扩容只读节点。用 MySQL 8.4 的 Clone Plugin,新节点加入集群并同步数据,20G 的数据量大概 5 分钟就搞定了,整个流程自动化程度很高。

当然,我并不是说 PG 不好。如果你做的是数据分析平台,或者你的数据结构极其复杂(比如需要大量的数组、JSONB 嵌套查询),那 PG 绝对是首选。但在我们那个追求极致稳定快速迭代团队对 MySQL 运维经验丰富的电商项目里,MySQL 8.4 是更稳妥的选择。

这里有一段我们在压测时用来模拟订单状态更新的核心代码,也是我们在选型时用来对比两者锁竞争的关键逻辑:

-- 模拟订单支付成功后的状态流转与库存扣减 -- 这是我们在 MySQL 8.4 环境下运行的存储过程片段 DELIMITER $$ CREATE PROCEDURE `sp_update_order_status`(IN p_order_id BIGINT, IN p_user_id BIGINT) BEGIN DECLARE v_stock INT; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 1. 更新订单状态 (行级锁,InnoDB 引擎) UPDATE orders SET status = 'PAID', pay_time = NOW() WHERE order_id = p_order_id AND user_id = p_user_id AND status = 'PENDING'; -- 2. 模拟扣减库存 (这里为了压测,简化为单条更新) -- 在 MySQL 8.4 中,InnoDB 的锁等待超时和死锁检测机制非常成熟 UPDATE product_stock SET stock_count = stock_count - 1 WHERE product_id = (SELECT product_id FROM order_items WHERE order_id = p_order_id) AND stock_count > 0; -- 3. 写入支付流水 INSERT INTO payment_flow (order_id, amount, pay_channel, created_at) SELECT p_order_id, total_amount, 'ALIPAY', NOW() FROM orders WHERE order_id = p_order_id; COMMIT; END$$ DELIMITER ;

这段代码在 MySQL 8.4 下跑得非常稳。我们当时对比了 PG 的类似逻辑,虽然 PG 的 SKIP LOCKED 也很强,但在我们这种简单的“点查+更新”模型里,MySQL 的响应延迟(P99)更低,基本稳定在 12ms 左右,而 PG 在 18ms 左右波动。对于大促时每秒上万次的调用,这 6ms 的差距就是系统会不会雪崩的分界线。

5. 线上救火实录:从慢查询日志到索引失效的排查全流程

讲完选型,我得讲一个我去年 11 月遇到的一次真实线上事故。当时我们刚把核心交易库从 MySQL 8.0.36 升级到 MySQL 8.4 LTS(为了那个 5 年的优质支持周期),结果上线第二天,运营反馈后台的“订单列表”页面打开要 5 秒钟,平时也就 200 毫秒。

我当时心里一紧,第一反应是升级带来的兼容性问题?还是参数没调对?我立刻登录跳板机,开始排查。

第一步:定位慢查询

我没有直接看代码,而是先去翻 MySQL 8.4 的慢查询日志。MySQL 8.4 默认开启了 slow_query_log,并且 log_slow_admin_statements 也建议开启。我执行了下面的命令快速定位:

# 在服务器上实时查看慢日志尾部 tail -f /var/lib/mysql/slow.log | grep -A 5 "Query_time"

很快,我就抓到了一条执行时间高达 4.8 秒 的 SQL。这条 SQL 是用来查询某个商户的订单列表,并且支持按状态筛选。

第二步:分析执行计划

我把那条 SQL 拷贝出来,去掉了动态参数,在测试环境(数据量约为线上的 1/10,约 500 万行)执行 EXPLAIN ANALYZE。这是 MySQL 8.0 之后引入的强大功能,8.4 里更加完善,能显示实际执行时间和行数。

EXPLAIN ANALYZE SELECT order_id, order_sn, total_amount, status, created_at FROM merchant_orders WHERE merchant_id = 1024 AND status = 'SHIPPED' AND DATE_FORMAT(created_at, '%Y-%m-%d') >= '2024-11-01' ORDER BY created_at DESC LIMIT 20;

执行结果让我发现了问题所在。输出显示:

问题很明显了:预估行数和实际行数差距巨大,且出现了 Using filesort

第三步:排查索引失效原因

我查看了 merchant_orders 表的索引结构:

SHOW INDEX FROM merchant_orders;

表上有个联合索引 idx_merchant_status_time (merchant_id, status, created_at)。按理说,这个查询应该能命中索引才对。

我盯着 SQL 看了半天,终于发现了罪魁祸首:DATE_FORMAT(created_at, '%Y-%m-%d') >= '2024-11-01'

这就是典型的索引失效场景。对索引列 created_at 使用了函数包裹。MySQL 8.4 的优化器虽然支持函数索引(Functional Index),但我们这个表是在升级前建的,并没有给 DATE_FORMAT(created_at, ...) 建立函数索引。所以,优化器只能放弃使用 created_at 这一列的索引有序性,导致扫描了海量的数据。

第四步:修复与验证

既然找到了原因,修复方案就很明确了:不要对索引列做函数运算。我改写了 SQL,利用范围查询来替代函数:

-- 修复后的 SQL SELECT order_id, order_sn, total_amount, status, created_at FROM merchant_orders WHERE merchant_id = 1024 AND status = 'SHIPPED' AND created_at >= '2024-11-01 00:00:00' ORDER BY created_at DESC LIMIT 20;

改完之后,我再次执行 EXPLAIN ANALYZE

查询时间直接从 4.8 秒 降到了 0.02 秒

复盘

这次事故其实很低级,但确实很常见。我们在升级到 MySQL 8.4 后,因为数据量随着业务增长变大了(单表已经到了 2000 万),以前在小数据量时,即使索引失效,因为数据都在内存里,感觉不明显。但数据量一旦上来,加上 8.4 的一些优化器策略微调,原本隐藏的问题就被放大了。

这也提醒我,在 MySQL 8.4 里,虽然支持了更多特性,但最左前缀原则避免索引列函数转换依然是铁律。如果真的需要对日期进行格式化查询,在 8.4 里可以考虑建立函数索引,例如:

-- 如果确实需要按天查询,在 MySQL 8.4 中可以这样建索引 CREATE INDEX idx_func_date ON merchant_orders ((DATE(created_at)));

但我的建议是,能不用函数就不用函数,直接利用范围查询,性能永远是最好的。

站长实战手记

一次差点让我背P0故障的UUID主键事故

去年双十一前,我接手了一个电商订单系统的重构。当时为了图省事,我直接给订单表用了 UUID v4 做主键。心想反正全球唯一,分库分表也方便,没什么毛病。

结果上线一周,系统就开始抽风。凌晨两点,我盯着监控里飙升的 磁盘 I/O 和响应延迟,整个人都麻了。那个订单表才 500 万数据,查询就卡得像用了十年前的机械盘。

我连上服务器,打开慢查询日志,发现全是主键回表查询在疯狂扫索引。当时我才反应过来,UUID 是随机字符串,插入时会导致 InnoDB 的聚簇索引页频繁分裂。每次写入都不是追加,而是在 B+Tree 里到处乱插,搞得页分裂和碎片多得离谱。

最后我痛下决心,停服半小时,把主键换成了 ULID(按时间序生成的唯一ID)。改造完重启,效果立竿见影:写操作的 IOPS 直接掉了 60%,查询也顺滑得像切黄油。

我的真实看法

* 别迷信 UUID:如果你用的是 MySQL,且数据量会增长,真的别直接用随机 UUID。除非你不在乎性能和磁盘空间。

* 选型要看场景:我们当时弃用 PG 选 MySQL,纯粹是因为团队对 InnoDB 的运维经验更足,而且业务就是简单的读写,MySQL 够用且省心。

* 别过度设计:很多时候,一个自增 ID 或者时间序 ID 就能解决的事,别为了显得“高大上”去硬上复杂的分布式 ID 方案。

掏心窝的建议

学 MySQL 别光看文档里的概念,什么 MVCC、事务隔离级别,看十遍不如你去本地起一个实例,亲手制造一次死锁或者索引失效。数据库这东西,只有当你亲眼看到慢查询把 CPU 打满的时候,你才会真正理解为什么要优化。