☰
MySQL与Oracle的DDL对比:DECIMAL精度扩展为何一个秒改一个锁死
2026/10/1 11:23:12 网站建设 项目流程

凌晨1点40分,我被DBA值班电话吵醒。客户在Oracle那边执行了一条ALTER TABLE PAY_ORDER MODIFY (AMOUNT NUMBER(14,2)),几毫秒就返回了;同一套业务需求在MySQL这边执行ALTER TABLE pay_order MODIFY amount DECIMAL(14,2),一执行,业务立刻开始堆告警,进程列表里全是Waiting for table metadata lock。两块库,一个秒改,一个锁死。当时我就想,这个问题值得好好写一写:同样是"精度扩展"这种看起来人畜无害的DDL,MySQL和Oracle的表现为什么差这么多?本文就把这个对比拆开讲清楚,包括算法原理、阻塞根源、实操方案,以及我踩过的一些坑。

1. 同样是"改精度",两边的表现为什么天差地别

1.1 MySQL这边发生了什么

MySQL里的"改精度"最常见两种:一种是把VARCHAR(50)加长到VARCHAR(100),另一种是把DECIMAL(10,2)变成DECIMAL(12,2)。这类操作从业务角度理解就是"给字段挪个更大一点的盒子",数据一条都不用动,理应很快。

但MySQL的实际行为是:在8.0.12之前,绝大多数这类操作都会触发表重建。InnoDB会按新的表定义创建一个临时表,然后一行一行把旧数据拷进去,拷完再删旧表换新表。几千万行的表,拷一遍就是几十分钟甚至几小时的大事。更麻烦的是,整个拷贝过程里,DML操作能不能并发执行、会不会被阻塞,取决于走的是哪种算法。很多老版本走COPY算法,插入、更新、删除全部排队,业务直接卡死。

即使到了MySQL 8.0,情况也没好到哪去:DECIMAL精度扩展依然是不支持INSTANT算法的,InnoDB只能选择重建表的路子。重建表期间就算理论上允许并发DML,MDL锁的排队机制也会在DDL开始和结束的瞬间把大量请求堵在门外。这就是生产环境最常见的"一个ALTER拖垮整个库"的真相。

1.2 Oracle这边发生了什么

同一个需求放到Oracle上,完全是另一套逻辑。ALTER TABLE PAY_ORDER MODIFY (AMOUNT NUMBER(14,2))这句话,Oracle做的是数据字典层面的更新:把列定义里的精度从10改成14,仅此而已。存量数据行一字节都不用改,更不用扫描全表。

Oracle为什么敢这么干?因为它把"精度声明"和"数据存储格式"解耦了。NUMBER类型在行内是变长存储,实际占用多少字节取决于这一行存的值本身,跟定义的精度没有直接关系。声明精度更大,只是放宽了一个约束,已存在的行完全不受影响。所以Oracle在很多场景下改精度是"秒级"操作,DML还没感觉到锁的存在,事情已经办完了。

这个差异不是谁优化得好、谁优化得差,而是数据存储引擎的底层设计决定的。不把这一点讲透,后面所有运维决策都会踩坑。

2. 先把MySQL的三种DDL算法和锁机制捋清楚

MySQL处理DDL的算法分三类:COPY、INPLACE、INSTANT。很多人只记了个名字,不清楚背后到底怎么锁、怎么阻塞,结果一到生产环境就抓瞎。

2.1 COPY:最古老也最坑,全程阻塞DML

COPY算法就是前面说的"创建临时表-拷贝数据-换表名"三件套。它的特点是:整个执行过程中,原表上的写入操作基本是要排队的。MySQL会用锁把表保护起来,避免拷贝过程中数据不一致,代价就是业务写入直接停摆。

在MySQL 5.6之前,几乎所有的ALTER TABLE都是这个命。直到今天,如果表上有全文索引、空间索引这类InnoDB在线DDL管不了的对象,或者某些操作被判定为不支持INPLACE,它还是会乖乖退回COPY。我见过不少团队在5.7上跑ALTER TABLE ... MODIFY COLUMN ... DECIMAL(14,2),跑了两个小时,期间订单系统只读不可写,最后还因为日志爆了失败回滚,那感觉真是酸爽。

判断一条ALTER是不是COPY,最简单的方法是执行前用EXPLAIN ALTER TABLE预演一下,结果里会明确告诉你它打算用哪个算法;执行中看SHOW PROCESSLIST,如果State列出现copy to tmp table,说明它已经在COPY算法里挣扎了。

2.2 INPLACE:不复制表,却躲不开MDL锁

INPLACE是MySQL 5.6引入的在线DDL算法,它不创建整表的临时副本,而是在原表所在的空间里直接做结构变更,很多操作需要重建表空间但不重建整表数据。重点来了:INPLACE算法在执行的大部分阶段是允许并发DML的,InnoDB会把并发的插入、更新、删除记录到一份"在线日志"里,等DDL快结束时再回放,保证数据一致。

听起来很美好对不对?但实际生产中,INPLACE照样能卡死业务。两个原因:

第一个原因是执行窗口的MDL锁。DDL开始的瞬间需要拿表的MDL写锁,结束的瞬间也要再拿一次。虽然理论上只是毫秒级,但只要有任何一条长事务或长查询先握着这张表的MDL读锁,ALTER就会进入Waiting for table metadata lock状态。这一等,可能就不是毫秒级,而是等到那个查询跑完。更麻烦的是,MDL等待队列是"写优先"的,一旦ALTER排在队首,后面新来的所有SELECT、INSERT、UPDATE都会排在它后面。一个本来只需要几秒的DDL,因为一条烂SQL,能把整张表的访问全部堵死。这场景特别像Java里用阻塞队列然后队列头堵了一个大任务,后面全等着。

第二个原因是在线日志容量。INPLACE期间并发写越多,在线日志涨得越快,默认上限是128MB(innodb_online_alter_log_max_size)。如果大表上的DDL跑得慢、同时业务写入又猛,日志爆了,DDL直接失败回滚,之前重建的那些工作全部白费。

2.3 INSTANT:8.0的秒级算法,但适用范围很窄

INSTANT是MySQL 8.0推出的"只改元数据"算法。它不碰数据页,只在数据字典里做修改,所以执行时间是微秒到毫秒级,可以认为是无阻塞的。它支持的操作包括:在表末尾加列、修改列默认值、修改ENUM/SET定义,以及——重点——8.0.12之后,支持把VARCHAR列的声明长度增大,条件是增大后的最大字节数不超过255。

这条VARCHAR规则很关键,后面专门讲。INSTANT看起来是救命稻草,可惜MySQL对它非常吝啬:DECIMAL精度扩展不支持,VARCHAR从255字节以内扩到255字节以上不支持,其他类型的修改更不支持。所以别指望靠INSTANT解决所有精度扩展问题,它只覆盖了一条很窄的路。

2.4 元数据锁(MDL):真正的连锁阻塞根源

MDL是MySQL 5.5引入的,专门管理表结构元数据并发访问的锁。所有CRUD语句进表前都要拿MDL读锁,DDL要拿MDL写锁。读锁之间不互斥,写锁和任何读锁/写锁都互斥。

生产环境里最常见的连环事故链是:

  1. 某个大查询卡了十分钟,一直持有表的MDL读锁;
  2. DBA执行ALTER,申请MDL写锁,开始在队列里等待;
  3. 新的应用请求进来,发现ALTER已经排在前面,全部跟着排队;
  4. 连接池耗尽,应用大量报错,整个业务雪崩。

所以判断一个DDL会不会影响业务,不能只看它本身的耗时,还要看执行前有没有人在这个表上长事务、长查询。这条经验我后面实操章节还会强调。

3. MySQL扩展字段长度时到底哪些能快、哪些必须重建

3.1 VARCHAR小扩展:8.0.12之后有惊喜,但255字节是分水岭

先说好消息。在MySQL 8.0.12及以上版本,如果你把VARCHAR(50)改成VARCHAR(100),而且这个表的字符集是utf8mb4,那么新字段最大字节数是100 × 4 = 400,超过255了,不好意思,INSTANT不支持。但如果你在latin1字符集下把VARCHAR(50)改成VARCHAR(100),100字节没超过255,MySQL会走INSTANT,秒改。

判断单位是字节,不是字符数。utf8mb4一个汉字占4字节,所以别看字符数没多少,字节数很容易就冲破255了。举个例子:

  • VARCHAR(60)在utf8mb4下是240字节,小于255,可以INSTANT;
  • VARCHAR(64)在utf8mb4下是256字节,大于255,不能INSTANT。

这个细节如果不清楚,在表上敲了ALTER TABLE ... MODIFY name VARCHAR(64)然后发现锁了一小时,回头查文档才知道是字符集的锅,那就太冤了。

3.2 VARCHAR跨过255字节:数据行结构变了,必须重写

为什么255是个坎?因为InnoDB的行格式里,变长字段的长度信息是用可变字节数记录的。当列的最大字节数不超过255时,长度信息一个字节就够;一旦超过255,就需要两个字节。跨越这个阈值,意味着每行数据的物理结构都变了,必须逐行重写。

所以从VARCHAR(60)(240字节)扩到VARCHAR(100)(400字节)这种操作,在MySQL 8.0里无法INSTANT,只能重建表。如果表是一张大表,就得接受几十分钟到几小时的重建代价。

那有没有办法从物理上避免?没有。这是存储引擎格式决定的。Oracle的VARCHAR2完全没这个问题,因为VARCHAR2行内存的是"实际数据长度+实际数据字节",不是按声明长度预留空间的,声明从100扩到300,已存在行的物理长度一点没变,改字典就行。两边一对比,MySQL是"改定义=改全表",Oracle是"改定义=改字典",这是存储设计的分水岭。

3.3 DECIMAL精度扩展:存储格式摆在那,绕不开重建

再来看标题里的主角:DECIMAL。MySQL的DECIMAL是定长二进制存储,存储占用由精度(M,D)直接决定。规则是:每9位十进制数占4字节,剩余部分按位数另占1到4字节。

以DECIMAL(10,2)为例:整数部分8位,分成1组,占4字节;小数2位,占1字节;合计一行5字节。如果改成DECIMAL(12,2):整数部分10位,拆成9位+1位,分别占4字节和1字节;小数2位还是1字节;合计一行6字节。看起来只多了1字节,但千万行的表就是几千万字节的物理重写,加上二级索引里的字段也要一起重建,成本是巨大的。

更关键的是,DECIMAL精度扩展在MySQL 8.0的INSTANT支持列表里根本没有位置。你就算用ALGORITHM=INSTANT显式指定,MySQL也会报错说这个操作不支持INSTANT。它只能走INPLACE或COPY,也就是必然重建表。这一点和Oracle的NUMBER又是天壤之别。

我干过一件"蠢事":在一张6000万行的流水表上,把金额字段从DECIMAL(10,2)改成DECIMAL(12,2),当时想着MySQL 8.0在线DDL应该没啥事,就挑了个业务低峰直接ALTER。结果跑了40分钟,期间一堆长事务占着MDL读锁,ALTER卡在排队状态,业务全部被堵住了,最后只能忍痛kill掉DDL,改用Percona的工具慢慢搞。

4. 为什么Oracle改精度几乎不费吹灰之力

4.1 NUMBER的变长存储与MySQL DECIMAL的定长存储

Oracle的NUMBER类型在内部是变长存储的,底层用类似科学计数法的格式:指数+尾数,每两位十进制数字压缩成一个字节,总的存储长度取决于这个值本身的有效位数。精度定义里的p只是一个约束上限,改大它,并不意味着数据行要重新编码。

MySQL的DECIMAL则相反,它是定长的,每行按定义好的M和D分配好固定字节数。改大精度,意味着每行的"盒子"尺寸变了,必须把所有数据重新塞进新盒子,所以必须重建。

一句话总结:Oracle的NUMBER是"按值存",MySQL的DECIMAL是"按定义存"。这决定了"扩展精度"这个动作在两边的代价完全不同。

4.2 VARCHAR2:实际数据长度不变,所以只动字典

Oracle的VARCHAR2也一样。行内记录的是该行实际存的字节数和真实数据,声明长度只是数据字典里的一条规则。把VARCHAR2(100)改到VARCHAR2(300),已存在的数据行每一行的实际占用都没有变化,Oracle只需要更新字典里"允许的最大长度"这个值,剩下的什么都不用做。

当然,如果修改声明长度需要改变行内存储格式的情况(比如要把普通VARCHAR2改成超长字符串相关的存储结构、或者缩小到比现有数据还小、或者改类型),Oracle也会扫描验证,那就不是秒级了。但单纯扩大声明长度/精度,Oracle是真的快。

4.3 Oracle 12c以后的DDL有什么新变化

Oracle 12c开始DDL默认是事务性的,也就是说DDL可以和DML处在同一个事务体系里,失败可以回滚。这对"改精度"这类快速DDL影响不大,但对"改类型"这类重型DDL体验改善很明显。Oracle原生还提供了DBMS_REDEFINITION在线重定义,可以在业务不中断的情况下改表结构,这相当于把MySQL要做的大量外部工具工作变成了数据库原生能力。

所以拿Oracle做对比,不是要吹Oracle多牛,而是想说明:MySQL的DDL成本高,是因为它把列定义刻进了每一行数据的物理布局里;Oracle把列定义当作元数据来管理,日常变更自然就轻。理解了这一点,你在MySQL上做精度扩展时就会有一个基本判断:这事儿在MySQL里大概率是重量级操作,不能想当然。

5. 实操避坑手册:大表精度扩展的完整操作流程

这一节是真正能直接拿去用的。我在多次踩坑之后总结了一套流程,不敢说100%保证不阻塞,但至少能帮你避开90%的连环雷。

5.1 操作前查清楚:表多大、有没有长事务、会不会排队

动手之前,先把这几样查明白:

-- 1. 表行数和数据大小 SELECT table_rows, ROUND(data_length / 1024 / 1024, 2) AS data_mb, ROUND(index_length / 1024 / 1024, 2) AS index_mb FROM information_schema.tables WHERE table_schema = 'your_db' AND table_name = 'your_table'; -- 2. 正在运行的事务,重点看 trx_started 老不老 SELECT * FROM information_schema.innodb_trx\G -- 3. 这张表当前的MDL锁占用 SELECT * FROM performance_schema.metadata_locks WHERE object_schema = 'your_db' AND object_name = 'your_table'\G

如果innodb_trx里有一个跑了很久的事务,或者metadata_locks里有长期SHARED_READ锁,千万别直接执行ALTER。这种时候执行DDL,大概率是排队半小时起步。

一个小技巧:用MySQL 8.0的EXPLAIN ALTER TABLE预演一下要执行的语句,看它到底走INSTANT、INPLACE还是COPY。预演不实际执行,但能让你提前知道这条DDL的重量级:

EXPLAIN ALTER TABLE your_table MODIFY amount DECIMAL(14,2);

如果结果显示需要重建表,就按下面的思路走,别拿生产环境赌运气。

5.2 三种安全改法的取舍:直连ALTER / pt-osc / gh-ost

第一种:表比较小(百万行以下)+ 低峰期,直连ALTER。这种情况下走INPLACE重建其实也就是几分钟的事,配合lock_wait_timeout设置一个几秒钟的等待上限,能排队就排队,等不到就放弃,别硬扛。

第二种:表大且需要在线,用pt-online-schema-change。这是Percona Toolkit里的明星工具。原理解起来不复杂:先按新结构创建一张影子表,在旧表上建触发器捕获增量变更,然后分批把旧数据拷贝到新表,最后通过RENAME TABLE原子切换。这个过程中旧表始终在线,业务写入走触发器被同步到影子表,切换瞬间业务基本无感。

命令大概长这样:

pt-online-schema-change \ --alter="MODIFY COLUMN amount DECIMAL(14,2)" \ D=your_db,t=your_table \ --host=127.0.0.1 --port=3306 \ --max-load=Threads_running=50 \ --chunk-size=1000 \ --execute

注意几个前提:表必须有主键或唯一键;binlog必须开ROW格式;表上不能有复杂的触发器。还有,pt-osc本身会给业务写入带来一些额外负载,因为触发器会对每个DML多做一些事,所以最好加--max-load限制一下,别把数据库IO打满。

第三种:用gh-ost。gh-ost是GitHub开源的在线表结构变更工具,它的特点是不用触发器,而是通过解析binlog来捕获增量变更,对业务写入的影响通常比pt-osc小,但部署和配置要复杂一些。如果你对工具链熟悉,gh-ost是更优雅的选择;如果不熟悉,pt-osc是更稳妥的入门选择。

我在生产环境真实跑过几次大表精度扩展,最终结论是:能不用原生ALTER就别用,尤其是那种注定要走重建流程的DECIMAL精度扩展。用pt-osc虽然耗时也不短,但业务是无感的,比锁死强太多。

5.3 执行过程中的监控与止损

不管用哪种方式,执行期间都要盯几个东西:

  • SHOW PROCESSLIST:看有没有大量Waiting for table metadata lock;
  • information_schema.innodb_trx:看有没有事务卡住;
  • iostat:看磁盘IO是不是已经饱和了;
  • 如果是pt-osc,看它是不是在反复重试某个chunk。

一旦发现MDL锁排队已经影响到业务,止损动作要快:优先考虑kill掉持锁的长事务或长查询,如果找不到源头,就直接kill掉DDL本身。MySQL 8.0的DDL是原子的,kill掉一般不会留残表,比5.7里那种留下一堆#sql-xxxx临时文件的情况干净多了。

6. 从根上减少"精度扩展"的机会:类型与设计

最后说点治本的东西。我这些年最大的体会是:在MySQL上,少做DDL比学会做DDL更重要。

精度扩展这件事,Oracle可以"先上线再说,不够再改",因为改起来的成本几乎为零。MySQL不行,一次大表精度扩展动辄几十分钟到几小时,还伴随阻塞风险。所以选MySQL当存储的话,建表阶段就要把精度想清楚:

  • 金额字段,能预留到DECIMAL(18,2)就尽量别用DECIMAL(10,2)。你不能预测业务几年后会不会把单笔金额上限翻几倍,但DECIMAL(18,2)基本能覆盖绝大多数交易系统的量级了;
  • 如果业务允许,也可以用最小货币单位存BIGINT,比如"分",这样压根没有小数精度问题。坏处是以后如果想存更小单位(厘、毫)又会遇到类型变更,所以在建模时要把单位也定死;
  • 字符串长度同样如此,VARCHAR(50)和VARCHAR(255)在utf8mb4下存储结构差异不大,但前者未来扩到超过255字节时要付出全表重建的代价。与其日后痛苦,不如初始就把长度拍够——当然也要注意别随手VARCHAR(5000),那可能把行长撑爆,还要考虑最大行限制。

再分享一个我自己的经验:跨库迁移或者双写方案设计时,别把"反正以后可以在线改表"当成默认前提。MySQL这边一条改精度的DDL,是能写进故障复盘的那种操作。我在实际项目里给过一个建议:如果预期这张表会超过千万行、而且字段定义有大概率要调整,要么一开始就给足余量,要么就把表设计成可以快速切换新表的结构(比如按天分表),而不是把希望寄托在一次"不阻塞的在线DDL"上。

说回开头那次事故。后来我在MySQL上用了pt-osc,花了大概一个半小时把那张订单表的金额精度从DECIMAL(10,2)扩到DECIMAL(14,2),中途业务完全无感。那个晚上之后,我把"MySQL精度扩展必先评估算法和MDL锁"写进了团队的操作规范,也把"Oracle可以随心所欲改精度,MySQL不能"写进了新项目的技术选型清单。搞数据库就是这样,很多坑不是躲不开,而是你还没意识到它是个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询