作者小头像 Lv.3
275 成长值

个人介绍

这个人很懒,什么都没有留下

感兴趣或擅长的领域

开发语言
个人勋章
TA还没获得勋章~
成长雷达
275
0
0
0
0

个人资料

个人介绍

这个人很懒,什么都没有留下

感兴趣或擅长的领域

开发语言

达成规则

他的回复:
你的观察基本对,先纠正一个前提:**RDS 的物理备份默认是在备节点(或只读节点)上做的**,不是主库。但主备共享同一套底层存储(云盘),所以备份的大量顺序读仍然会抢主库的 IO 带宽——这就是「主库没跑备份,IO 却打满」的原因。 ## 一、备份能不能限速 **控制台没有「备份限速」这个开关**,参数组里也没有对应的参数(`innodb_io_capacity` 是控制 InnoDB 刷脏速度的,和备份读流量无关,改它没用,反而可能让刷脏变慢导致脏页堆积)。 能间接影响备份 IO 的手段只有两个: 1. **改备份时间段**:控制台 → 实例详情 → 备份恢复 → 备份策略,可以选一个一小时的窗口。修改后**下一个备份周期生效**(这点你没记错),当天那次不会变。2. **改用增量备份 / 缩短全量频率**:如果实例支持,把全备从每天一次改成每周一次、其余走增量,能显著降低平均 IO 压力。 ## 二、为什么备份会把慢查询顶起来 不是锁的问题,是**page cache 被冲掉**: - 物理备份要全量扫一遍数据文件,这是纯顺序读、量很大(你 400GB 数据量,备份至少要读几百 GB);- 大顺序读会把 buffer pool 里的热数据挤出去(尤其是你 buffer pool 只占一部分内存时);- 备份结束后业务查询全部变成物理读,`await` 从 2ms 涨到 40ms,慢查询自然爆发。 所以慢查询集中在**备份窗口内以及刚结束的一段时间**,而不是备份持续期间均匀分布——如果你的慢查询是这种分布,就基本可以确认是 page cache 冲刷,不是锁。 ## 三、怎么区分「物理等待」和「锁等待」 这两个在监控上表现完全不同: | 现象 | 物理 IO 等待 | 锁等待 ||---|---|---|| CPU | 低(20% 左右) | 低 || 磁盘 await | 高(40ms+) | 正常 || `Threads_running` | 中等 | 飙高(接近连接数) || 慢日志等待事件 | `wait/io/table/sql/handler`、`innodb_data_file` 相关 | `lock wait`、`Waiting for table metadata lock` || `SHOW ENGINE INNODB STATUS` | 无锁等待段落 | 有 `TRANSACTIONS` 等待链 | 在 RDS 上最直接的是看**慢日志的等待事件名**:如果是 `wait/io/...` 开头就是物理 IO;如果是 `lock/...` 或者 `synch/...` 就是锁。你那批 UPDATE 走主键、无锁冲突,基本可以确定是 IO 等待。 ## 四、能做的优化 1. **对账任务挪到备份窗口之外**(你已经做了,这是最高性价比的);2. **提升云盘 IOPS**:SSD 云盘的 IOPS 是可调的,备份期间 IO 打满说明盘的上限不够,把 IOPS 提一档比什么都直接;3. **让只读业务走只读实例**:把报表/对账这类重读负载拆到只读实例,主库只留交易;4. **检查 buffer pool 大小**:如果 `innodb_buffer_pool_size` 只占内存的 40%,备份冲刷的影响会很明显,调到 60~70% 能缓解(8C32G 建议 20~22GB);5. **避开「备份 + 大事务」叠加**:备份期间如果有大批量 DML,脏页 + 备份读会双重打满 IO,DDL 和批量任务都别排在这个窗口。 ## 五、关于「走备库备份」 主备架构下备份本来就在备节点执行,这一点你已经享受到了。但如果是**单机版(无备节点)**,备份会直接在主库上跑,影响会大得多——从你「IO util 95% 但 CPU 只有 15%」看,你是主备架构,只是共享存储的带宽被打满了。 如果实例支持**只读实例**,还有一个做法:把备份任务配置到只读实例上(部分版本支持),主库完全不受影响。具体要看你的实例规格是否支持挂只读。 ## 小结 备份限速没有开关,`innodb_io_capacity` 不对症。慢查询的根因是备份顺序读冲刷 page cache,不是锁。可控的手段是:错峰 + 提 IOPS + 扩 buffer pool + 重读负载下沉到只读实例。判断依据看慢日志的等待事件前缀(`wait/io` vs `lock`),一眼就能分清。
他的回复:
你的判断基本都对,问题就在「DDL 在从库单线程 + row log 上限」这两点上。分四段说,包括 RDS 托管环境下能做什么、不能做什么。 ## 一、DDL 在从库为什么这么慢 MySQL 8.0 的并行复制(`replica_parallel_workers`)对 DDL 事件不并行:SQL 线程遇到 DDL 时,必须等所有 worker 把之前的事务回放完(`Waiting for dependent transaction to commit`),然后**自己单线程执行**这条 DDL。执行期间整个 SQL 线程被占住,后面所有事务排队。 所以从库延迟 ≈ DDL 执行时间 + 积压事务的回放时间。你主库 6 分 20 秒,从库执行同样要 6 分钟,加上 40 分钟的积压回放 —— 这 1800 秒延迟是必然结果,不是异常。 顺带一个容易被忽略的点:`Seconds_Behind_Master` 在 DDL 执行期间是不可靠的(它按最后一个回放事件的 timestamp 算),会出现「突然从 1800 掉到 0」的假象,别拿它做判断。用 `pt-heartbeat` 或者自己写心跳表更准。 ## 二、`innodb_online_alter_log_max_size` 的风险要提前算 默认 128MB。DDL 期间这张表所有 DML 都会写进这个 row log,超过就直接失败: ```ERROR 1799 (HY000): Creating index 'idx_user_created' requires rebuild of table``` 粗算:`row log 用量 ≈ 表写入速率 × DDL 耗时`。你这张表 1.2 亿行,如果 DDL 期间写入 1MB/s、耗时 6 分钟,就是 360MB,已经超了。之所以能过,说明你挑的凌晨窗口写入确实低。 在 RDS 上这个参数**可以调**(8.0 支持动态设置,属于可修改参数): ```sqlSET GLOBAL innodb_online_alter_log_max_size = 1073741824; -- 1GB``` 或者在控制台「参数修改」里改。影响是:这个 row log 是**内存 + 临时表空间**,调大会在 DDL 期间多吃内存和 `ibtmp1` 的空间,DDL 结束后释放。1GB 对我们这种场景是可接受的,但别调到 4GB 以上,DDL 失败回滚会很慢。 ## 三、托管环境里能让从库少受影响的三个手段 **手段 1:先停只读实例的 SQL 线程(最直接)** RDS 控制台一般不允许直接 `STOP SLAVE`,但可以通过「只读实例 → 暂停复制」之类的功能实现(不同版本入口不同)。核心思路是:主库 DDL 执行期间,从库不要一边执行 DDL 一边积压;等主库 DDL 完成后 `START SLAVE`,让 relay log 里的 DML 先回放,再执行 DDL。 注意:停止期间从库的数据是**冻结**的,只读业务会读到旧数据;且 relay log 会持续堆积,要确认磁盘够。 **手段 2:用 DAS 的无锁变更(推荐)** 华为云 DAS 的「SQL 变更」/「无锁结构变更」就是官方的 gh-ost/pt-osc 封装,原理是影子表 + 增量同步 + rename。优点: - 不产生大 row log,不会 1799;- 分批 copy,主库压力可控;- 从库只在最后 rename 的瞬间有短延迟,不会出现 40 分钟积压。 限制:需要额外的磁盘空间(约 1 倍表大小),400GB 的表要先确认实例剩余空间;另外 rename 阶段会有短暂元数据锁。 **手段 3:拆成多次小 DDL** 先加 `(user_id)` 单列索引,再 `ADD INDEX (user_id, created_at)` 扩展成联合索引。两次的 row log 用量都减半。但注意:MySQL 8.0 的 `ADD INDEX` 不支持 INSTANT,每次都要扫全表,总耗时是增加的(两次各 6 分钟),只是峰值风险降低。 ## 四、执行前的预估清单 ```sql-- 1) 表大小和行数SELECT table_rows, data_length/1024/1024/1024 AS data_gb FROM information_schema.tables WHERE table_name='t_order';-- 2) 当前写入速率(间隔 60s 看两次 binlog 位点)SHOW MASTER STATUS;-- 3) 从库延迟基线和并行配置SHOW SLAVE STATUS\GSHOW VARIABLES LIKE 'replica_parallel%';-- 4) row log 上限SHOW VARIABLES LIKE 'innodb_online_alter_log_max_size';``` 用 `写入速率 × 预估耗时` 和 128MB 比一下,超了就提前调大或改用 DAS。 ## 五、我的建议 400GB / 1.2 亿行这个量级,别再用原生 online DDL 硬跑了。走 DAS 无锁变更,配一个低峰窗口,从库延迟最多几十秒。如果一定要用原生 DDL,至少做三件事:调大 row log 到 1GB、提前停只读实例的复制、DDL 后立刻用 `pt-heartbeat` 验证延迟而不是看 `Seconds_Behind_Master`。
他的回复:
你踩的这三个点(NO PAD、filesort、DDL 锁)是同一件事的三个面。分开答。 **一、怎么高效捞出「PAD SPACE 语义下重复」的数据** `GROUP BY account COLLATE utf8mb4_general_ci` 在 800 万行上必然全表扫 + 临时表,跑不动很正常。换个思路——**尾空格重复只可能是「trim 之后相等」,那就把 trim 之后的值算出来比**: ```sqlSELECT TRIM(account) AS t, COUNT(*) c, MIN(id) keep_idFROM user_accountGROUP BY t HAVING c > 1;``` 这条能走 `account` 上的索引吗?不一定(函数作用于列,索引失效)。所以实操要用**分批 + 覆盖索引**: ```sql-- 按主键区间分批,每批 20 万SELECT id, account FROM user_accountWHERE id BETWEEN ? AND ? AND account TRIM(account);``` `account TRIM(account)` 只筛出「带空格」的行——这一类通常极少(几十到几百条),先在应用层或小表里比对,成本立刻降下来。**别一上来就全表 GROUP BY**,先用这个条件把候选集缩到几百行,逻辑在脚本里做。 如果非要纯 SQL 找出重复,加一个虚拟列 + 索引是最省事的: ```sqlALTER TABLE user_account  ADD COLUMN account_norm VARCHAR(64)  GENERATED ALWAYS AS (TRIM(account)) VIRTUAL,  ADD INDEX idx_norm (account_norm);``` 虚拟列在 8.0 上加索引是 INPLACE 的(不需要 copy 表),建完直接 `GROUP BY account_norm HAVING count(*)>1`,秒级。**这是我推荐的做法**:先加虚拟列+索引排查,查完再决定要不要留。 **二、要不要改回 general_ci** 我不建议为了这一个行为把整列改回 `general_ci`,理由: 1. **改 collation 是 copy 表 DDL**。8.0 支持 `ALGORITHM=INSTANT` 的场景是「加列/改默认值」这类,改字符集或排序规则**不在 INSTANT 范围内**,只能 INPLACE(重建表 + 重建索引),800 万行会锁写一段时间。RDS 上有无锁变更工具可以用,但要看你的版本支持。2. `general_ci` 是 UCA 之前的简化实现,排序结果在中文/多语言场景下本来就不准(按字节权重排,不是按拼音)。为了「尾空格相等」退回去,等于用一个更老的坑换一个新坑。3. **尾空格相等这件事,本质上不该由数据库来兜**。应用层 trim 是必须做的,数据库唯一索引只是最后一道防线。 我的建议:**保留 0900_ai_ci,做两件事**——① 清一次历史脏数据(用上面的虚拟列方案找);② 应用层入口统一 `trim()`,并在写库前做长度/字符校验。如果确实需要「PAD SPACE」语义,可以用虚拟列 `TRIM(account)` 建唯一索引,语义精确可控,也不用改整表的 collation: ```sqlALTER TABLE user_account  ADD UNIQUE KEY uk_account_norm (account_norm); -- account_norm 是 TRIM 后的虚拟列``` 这个方案最合我意:不改 collation、不锁表、语义明确。 **三、ORDER BY 变成 filesort 怎么办** 迁移后 filesort 常见两个原因: 1. **collation 混用**。JOIN/UNION 两边列的 collation 不一致时会发生隐式转换,索引直接失效。查: ```sqlSELECT table_name, column_name, collation_nameFROM information_schema.columnsWHERE table_schema = DATABASE() AND collation_name IS NOT NULL  AND collation_name 'utf8mb4_0900_ai_ci';``` 2. **索引的排序权重变了**。0900 用的是 UCA 9.0.0,同一批字符串的排序结果可能和 general_ci 不同,但**索引本身仍然可用**——filesort 一般不是权重变化导致的,而是上面第 1 条或统计信息问题。先跑 `ANALYZE TABLE` 再看 `EXPLAIN`。 **四、不停业务改唯一索引** 你的想法(先降级成普通索引 → 清数据 → 再升级回唯一索引)可行,顺序是: ```sqlALTER TABLE user_account DROP INDEX uk_account, ADD INDEX idx_account (account), ALGORITHM=INPLACE, LOCK=NONE;-- 清数据(业务低峰)ALTER TABLE user_account DROP INDEX idx_account, ADD UNIQUE INDEX uk_account (account), ALGORITHM=INPLACE, LOCK=NONE;``` 注意两点:① 加回唯一索引时如果还有重复会直接失败,先确认清干净;② 加唯一索引期间会有短暂的元数据锁,主库上建议在低峰做,且用 `lock_wait_timeout` 控制排队时间,别让它把后续 DDL 全堵住。 读流量在从库上不影响这个操作,但从库会重放 DDL,**从库延迟会短时上升**,监控一下 `Seconds_Behind_Master`。
发布时间 2026/11/06 10:33:30 最后回复 ack 2026/11/07 21:43:29 版块 开发者空间
31 6 0
他的回复:
你的判断基本正确,先把结论说清楚:**单个事务是最小并行单位,31 万行的 update 在从库上就是一个事务,无论开 LOGICAL_CLOCK 还是 WRITESET 都无法拆开并行回放**。WRITESET 的作用是让「多个并发事务」之间能安全并行(按 row hash 判断有没有冲突),不是让「一个事务内部」并行。 所以这类延迟只能靠「不让它变成大事务」来解决。逐条回答。 一、并行复制为什么救不了 - `LOGICAL_CLOCK`:按「事务提交时的锁区间」分组,同一组内的事务可以并行。一个大事务自己占一组,组内只有一个事务,并行度为 1。- `WRITESET`:8.0 默认 `binlog_transaction_dependency_tracking=WRITESET`,它记录每个事务写过的行的 hash,回放时按 hash 判断冲突。同样,粒度是事务。- 还有一条更硬的限制:**同一张表上的事务在 applier 里是按顺序回放的**(`replication_applier_status_by_worker` 里能看到每个 worker 当前处理的表)。即使两个事务无冲突,只要碰同一张表,也大概率被分到同一个 worker。 所以对「单表大批量订正」这个场景,并行复制基本无效,这不是配置问题。 二、binlog_row_image=minimal 值得开,但要看链路 这条 update 没改主键和唯一键,`minimal` 下 before image 只记录主键(用于定位行),after image 只记录被改的列。对 31 万行来说,binlog 体积通常能降 40%~60%,从库回放的 IO 和解析开销同步下降。 三个坑要注意: 1. **依赖 binlog 的下游会坏**。如果还有 CDC 链路(DTS/DRS 同步到数仓、Canal 订阅),`minimal` 会导致下游拿不到完整行镜像,更新后的一行可能是「半条记录」。上这条参数前先确认下游用的是 `FULL` 还是能处理 `minimal`。2. **回滚/闪回工具**。像 binlog2sql 这类闪回工具依赖 before image 还原整行,`minimal` 下还原出来的行是不完整的,闪回会出错。3. RDS 上改这个参数通常需要**重启实例**(它是只读参数,部分版本支持控制台参数模板修改后重启生效)。做主备切换窗口时一起改。 如果确认没有 CDC 和闪回需求,我建议开。如果不敢动全局参数,退一步只针对这次订正「拆小事务」也能达到类似效果。 三、批次大小怎么定 5000 行 / sleep 50ms 这个组合对 400G 的库、同规格主从是合理的起点,但更科学的做法是按「回放耗时」反推,而不是按行数拍: 1. 先测一次:跑 5000 行,看从库 `Seconds_Behind_Master` 涨了多少、多久追平;2. 目标是把单批的从库回放时间控制在 **1 秒以内**,这样延迟不会累积;3. sleep 的作用是给从库喘息,不是为了限速主库。50ms 在「主库执行 200ms/批」的量级下基本够;如果主库执行很快(10ms/批),sleep 要相应加长,否则主库会把从库越拉越远。 另外两个实操建议:- **按主键范围切**,不要用 `LIMIT` 翻页(`LIMIT` 在无主键排序时会出现漏行/重复,而且越翻越慢);- 每批结束记录「已处理到的最大主键」,中断了能续跑,别用 offset。 四、Seconds_Behind_Master 准不准 不准,而且在大事务场景下会**偏小**:它算的是「从库当前时间戳 - 正在执行的事件的时间戳」,如果从库卡在一个大事务里,事件时间戳是事务开始的时间,这个差值一直不变,看起来像「延迟没涨」,实际上队列在后面堆着。你这次能到 2400 秒,说明事务已经执行完了、在追后面的积压。 更可靠的观测: 1. `performance_schema.replication_applier_status_by_worker`:看每个 worker 的 `APPLYING_TRANSACTION_IMMEDIATE_COMMIT_TIMESTAMP`,和主库当前时间比;2. `SHOW REPLICA STATUS` 里的 `Executed_Gtid_Set` / `Retrieved_Gtid_Set` 差值;3. **pt-heartbeat**(Percona 工具):主库每秒写一条心跳,从库读出来算真实延迟,这是最准的。RDS 上可以用只读实例跑,不占主库资源。 建议把 pt-heartbeat 或者 worker 级监控接进告警,`Seconds_Behind_Master` 只作为粗略参考。 五、必须一次性改大量行的业务场景 业界常见的三种做法: 1. **分批 + 校验 + 可中断**:就是你打算做的,配合「记录进度 + 幂等」(每条 update 带 `AND status = 1` 条件,重复执行不会重复改)。这是最通用的,适合业务表。2. **影子表 + 切换**:建新表、批量写入、双写校验、原子 rename。适合「改结构 + 改数据」的场景,缺点是占空间、要停写窗口。3. **DTS/DRS 数据迁移**:适合跨实例、跨版本的整体迁移,不适合单表订正。 RDS 上没有官方的「批量订正工具」,`gh-ost` / `pt-online-schema-change` 是改表结构用的,不解决 DML 大事务。所以还是分批脚本最实际。 六、监控为什么没告警 RDS 控制台的「复制延迟」指标采集粒度通常是分钟级,而且是取 `Seconds_Behind_Master`,大事务期间它可能显示「正常」或者波动不大,等追平了才回落——等于完美错过了整个故障窗口。 建议自己加两条:- 只读实例的 `relay log 堆积量`(`SHOW REPLICA STATUS` 里 `Relay_Log_Space` 增长速度);- pt-heartbeat 延迟 > 10 秒告警。 这次能从「业务反馈」变成「监控先发现」,就靠这两条。
他的回复:
先回答最关键的:CodeArts 流水线没有 GitLab `resource_group` 那种资源互斥原语,官方路径就是把会争抢同一资源的任务串行化。但“串行”不等于“整个部署阶段串行”——可以做到“同一台机器串行、不同机器并行”,只是要自己加锁。你现在的四个现象(compose 互删、目录覆盖、端口抢占、目录被清)都源于“并行任务没有共享资源的协调机制”,加 -p 和软链只解决了其中两个。 一、关于资源互斥 CodeArts 的阶段里可以配“任务串行执行”(阶段内任务的并发度设为 1),也可以把两个部署任务放到同一个阶段的串行任务组里。跨阶段、跨流水线之间没有内置的分布式互斥。所以有三条路: 1. **改成串行**(最稳):把部署阶段收成一个任务,任务内部按服务顺序执行。缺点是耗时叠加。2. **加分布式锁**(推荐):在部署脚本的最前面抢锁,抢不到就等待或直接失败。用文件锁 + 目标机器维度最合适——因为竞争资源就是那台机器。3. **拆机器**:给 api 和 worker 各一台 ECS。测试环境可能没这个预算,但这是根治。 我现在用的方案是 2,效果最好,也不用改流水线结构。 二、分布式锁怎么写才不卡死 在目标机器上用 `flock`,锁文件按机器维度(不是按服务),这样两个服务天然互斥: ```bashexec 9>/var/lock/deploy.lockif ! flock -w 300 9; then  echo "等待部署锁超时,可能有另一个部署正在进行" >&2  exit 1fitrap 'flock -u 9' EXIT# ... 部署逻辑 ...``` 关键点:- `-w 300` 是等待 300 秒,超时失败而不是无限等;- `trap ... EXIT` 保证脚本异常退出也释放锁(`flock` 的锁在 fd 关闭时自动释放,进程被杀也会释放,这是它比 Redis 锁好的地方);- **不要用 Redis SETNX**:任务被平台强制中止时,锁不会释放,得靠 TTL;而 TTL 又要在“部署可能跑很久”和“进程死了要尽快释放”之间取舍,还容易因为续期失败导致两个部署同时跑。文件锁没有这个问题,因为锁的持有者是进程本身。 如果部署是跨机器的(多台 ECS),锁就要按机器来:每台机器上各抢各的,脚本里循环处理。别用一把全局锁,那等于把并行完全废掉。 三、软链切换的竞态 `ln -sfn` **不是原子的**:它的实现是先 unlink 再 symlink,中间有一个极短的空窗,这期间 `current` 不存在。要原子切换得用: ```bashln -sfn /opt/app/api/releases/20261105-1201 /opt/app/api/.current.tmpmv -T /opt/app/api/.current.tmp /opt/app/api/current # rename(2),原子``` `mv -T` 走的是 `rename(2)`,同文件系统内是原子的,要么旧指向要么新指向,不会有中间态。注意 `-T` 必须加(否则 mv 一个软链到已存在的目录会变成“移动到目录里面”),而且 `.current.tmp` 和 `current` 要在同一个文件系统。 四、软链切换瞬间的 502 你的判断基本对,但成因有两层: 1. **Nginx 已打开的 fd**:Nginx 启动/重载时会打开配置文件、日志文件,但静态文件是每次请求 open 的,所以软链切换对静态文件影响很小。2. **真正的原因更可能是“旧 release 目录被删”**:如果部署脚本在切完软链后立刻 `rm -rf` 旧目录,而某个正在处理的请求(比如上游超时重试、或者 worker 还在读旧路径的文件)还在访问旧 inode,就会 502/404。 解法就是你说的:**保留最近 3 个 release 不删**,删除动作延后(比如每次部署时只删“第 4 个及更早”的)。这样旧 inode 还有硬链接存在,正在读的请求不受影响。这一条能消掉绝大部分切换瞬间的 502。 另外,如果 502 是 Nginx 反代到上游(api 容器)产生的,那和软链无关,是**容器重启期间的连接空窗**。这种情况要用“先起新容器 → healthcheck 通过 → 切 upstream → 再停旧容器”的蓝绿流程,而不是 `docker compose up -d` 直接替换(compose 会先停旧再起新,必然有空窗)。 五、Nginx upstream 配置互相覆盖 拆文件是对的,但两个任务同时 `nginx -s reload` 还有竞态:reload 会重新读整个配置目录,如果此刻另一个任务正写到一半(文件被 truncate 但内容还没写完),Nginx 可能读到半个文件导致 reload 失败(老 worker 还在跑,所以业务不中断,但新配置没生效)。 做法: 1. `conf.d/api.conf`、`conf.d/worker.conf` 各自独立,互不 include;2. 写入时**先写临时文件再 `mv`**:`cat > /etc/nginx/conf.d/api.conf.tmp && nginx -t && mv api.conf.tmp api.conf`;3. `nginx -t` 必须在 mv 之前对临时文件做(`nginx -t -c` 指向临时配置比较麻烦,实际做法是 mv 之后立刻 `nginx -t`,失败就回滚);4. reload 也放进同一把锁里,别两个任务同时 reload。 更彻底的做法是**别让部署任务碰 Nginx**:upstream 用动态解析(`resolver` + 变量式 proxy_pass)或者服务发现,部署只改 DNS/注册中心,Nginx 配置不动。这样两个服务的部署完全解耦,也不需要 reload。 六、端口冲突 `--network host` 是端口冲突的根源——它让容器直接用宿主机网络栈,两个服务抢同一个端口必然冲突。三个选择:1. **bridge 网络 + 端口映射**:`-p 127.0.0.1:8080:8080`,端口冲突在 `docker run` 阶段就会报错,能提前发现;2. **docker compose 自定义网络 + 服务名访问**:容器之间用服务名通信,只把 80 暴露出去,由 Nginx 按 host/path 路由;3. **固定端口段 + 配置校验**:启动前检查端口是否被占(`ss -lntp | grep :8080`),占了就明确失败,别让 compose 静默失败。 我倾向 2:只暴露一个 80,所有路由交给 Nginx,端口冲突这个问题从架构上消失。代价是要维护 upstream 配置(见上一条)。 七、并行/串行的取舍 你问的“只对同一台机器串行、不同机器并行”完全可行,而且是最优解: - 构建阶段:保持并行(互不干扰,纯 CPU 消耗);- 部署阶段:每个部署任务内部先抢“目标机器锁”,抢到才干活。这样同一台机器上的两个部署自然串行,不同机器的部署并行不受影响;- 锁粒度按“机器 + 环境”组合,别按服务。 实现上不需要改流水线结构,只在部署脚本最前面加 4 行 `flock` 就够。 八、失败回滚 两种都可以,但**脚本内自己判断更可靠**:CodeArts 的“任务失败后置处理”能保证被触发,但它拿不到部署脚本内部的上下文(比如“软链已经切了但 healthcheck 失败”这种中间态)。建议: ```bashdeploy_release() {  ln -sfn $NEW .current.tmp && mv -T .current.tmp current  if ! curl -fsS --max-time 10 http://127.0.0.1:8080/healthz; then    ln -sfn $PREV .current.tmp && mv -T .current.tmp current    systemctl reload nginx || true    return 1  fi}``` 注意回滚也要在锁内、也要做 healthcheck,否则会出现“回滚到上一个坏版本”。另外“后置处理”里再补一层兜底(比如脚本超时没返回时强制切回上一 release),两层配合最稳。 九、给你的落地清单 1. 部署脚本开头加 `flock -w 300`(按机器维度);2. 目录已经是 `releases/`,把软链切换改成 `mv -T`;3. 保留最近 3 个 release,删除延后;4. Nginx 配置拆文件 + 临时文件写 + mv,reload 放进锁里;5. 端口改 bridge + 只暴露 80,或者至少加端口占用预检;6. compose 用 `-p `(已做),再加 `--remove-orphans` 的反面:**不要**在同一个 project 下混服务;7. 部署失败自动回滚 + healthcheck,回滚也在锁内。 这套下来,并行部署同一台机器就不会再互相踩了。真要彻底解决,还是给两个服务各一台机器,或者上 K8s——那才是“并行部署”的天然形态。
他的回复:
逐条回答,先说最关键的判断:`rpl_semi_sync_master_status` 反复 OFF 又自己回来,说明不是"半同步坏了",而是"个别事务的 ack 超过了 timeout"。这个区别决定了后面怎么改。 1. timeout=1000 是不是太小 是偏小。MySQL 官方插件的默认值是 10000(10 秒),RDS 上常见默认给到 1000 或 3000,目的是让"备库真的挂了"时不要拖住主库太久。但 1 秒对"备库 IO 偶尔打满"这种场景太敏感了——一次 fsync 抖动、一次大事务的 relay log 落盘,就足以超过 1 秒。 调大的代价要说清楚:**不退化的前提下没有代价**(正常情况下 ack 是亚毫秒级,timeout 只是个上限);代价体现在"备库真的挂了的时候",主库的写事务会多等这么久才退化成异步。所以 3000~5000 是个合理区间:既盖住 IO 抖动,又不至于在真故障时把主库拖死。 RDS 上这几个参数通常在参数模板里可以改(控制台 → 参数修改),`rpl_semi_sync_master_timeout` 属于动态参数,改完立即生效、不需要重启。如果参数模板里是灰色的,说明被平台锁定,只能提工单。改之前建议先在只读实例或者测试实例上确认一下能不能改。 2. 会不会自动恢复 会。机制是这样的:退化之后,主库对**每个**新事务仍然会尝试走半同步(发 binlog 等 ack)。只要某个事务在 timeout 内收到了 ack,`rpl_semi_sync_master_status` 就立刻回到 ON。所以它是"逐事务重试"而不是"退化后一直异步"。 这解释了你看到的现象:OFF 几秒到几十秒又回 ON。同时也说明——**这期间真的有事务是异步提交的**,那部分事务在主库宕机时确实可能丢。 3. AFTER_SYNC 与 AFTER_COMMIT 在退化时的差别 这是最需要说清的一点: - `AFTER_SYNC`:主库把 binlog 写到磁盘、发给备库、**等 ack 之后才返回客户端成功**。退化发生时,那笔超时的事务已经在主库提交(对客户端返回成功),但备库没 ack。所以退化瞬间的 RPO 缺口是"这一个事务"(以及退化期间的所有事务)。- `AFTER_COMMIT`:主库先提交、返回客户端,再等 ack。退化时缺口更大,因为提交和 ack 之间本来就有一个窗口,退化把这个窗口放大了。 所以 AFTER_SYNC 更好,你现在的选择是对的。它不能消除退化带来的丢数据风险,只能把风险窗口压到最小。 顺带说一句,`AFTER_SYNC` 下如果备库在 ack 之前挂了,主库会等到 timeout 再退化,这期间客户端是**卡住**的(不是失败,是等待)。所以 timeout 调大之后,应用的 SQL 超时要相应放宽,否则会出现"数据库没报错、应用先超时"的怪现象。 4. 备库 IO 打满导致 ack 变慢,因果关系成立吗 成立,但要看是哪个环节。半同步的 ack 是备库的 **IO 线程**在把 relay log 写到磁盘(`sync_relay_log` / `innodb_flush_log_at_trx_commit` 相关的 fsync)之后发的,**不等 SQL 线程回放**。所以: - `Seconds_Behind_Master` 小,只说明 SQL 线程回放跟得上,和 ack 延迟没有直接关系——这也解释了为什么你看到"延迟很小但一直退化"。- 真正要看的是备库的磁盘写延迟。`iostat -x 1` 里看 `await` 和 `%util`,如果 `%util` 到 90% 以上、`await` 上到几十毫秒,那 relay log 的 fsync 就会被拖慢,ack 自然超时。 可以在备库上确认:   SHOW GLOBAL STATUS LIKE 'Rpl_semi_sync_slave_status'; -- 备库侧是否还在半同步  SHOW GLOBAL STATUS LIKE 'Slave_relay_log_pos'; -- 观察 relay log 位置推进速度 如果 relay log 位置在退化时间点明显停滞,基本就锁定了。 5. 大事务会不会直接导致退化 会,而且这是最常见的诱因。原因:一个大事务(比如删 50 万行)在提交时会产生一个很大的 binlog event(或者很多 event),备库 IO 线程要把它完整写进 relay log 并 fsync,这个时间可能是几百毫秒到几秒。超过 1 秒的 timeout 就退化。 你的观察"退化时间点和批量任务重叠"非常符合这个模式。验证方法:在退化时间点看主库的 `SHOW BINLOG EVENTS` 或者 `mysqlbinlog` 的 event 大小,如果对得上,结论就清楚了。 对策不是调 timeout,而是**拆大事务**:50 万行的 DELETE 拆成 5000 行一批、每批提交、批间 sleep 几十毫秒。这既避免退化,也避免主库的 undo 膨胀和锁等待。 6. 怎么统计退化次数 `SHOW GLOBAL STATUS LIKE 'Rpl_semi_sync%'` 里两个关键计数器: - `Rpl_semi_sync_master_no_tx`:**没能走半同步**(退化)的事务数——这个就是你要的"退化次数"。- `Rpl_semi_sync_master_yes_tx`:成功走半同步的事务数。 用 `no_tx / (no_tx + yes_tx)` 就是退化比例。正常应该是 0,出现个位数就值得看一眼。这两个是累计值,做监控要取增量(`rate()`)。 RDS 控制台的监控项里确实没有半同步状态,这是平台侧的缺口。可行的替代方案:- 用 RDS 的"自定义监控"或者 DAS 的实时会话/性能洞察,配一个 SQL 定时采集 `SHOW GLOBAL STATUS`;- 或者干脆在业务侧装一个探针,每分钟连一次执行这个 SHOW,把结果推到你的 Prometheus。开销极小。 7. 升配还是挪批量任务 先做便宜的两件事,都不行再考虑升配: 1. 调 timeout 到 3000(几乎零成本,立刻能减少误判型退化);2. 拆大事务 + 把批量任务挪到业务低峰(零成本,解决根因)。 如果做完还是每天退化,再看磁盘:RDS 的 IOPS 和盘容量是挂钩的,很多时候"升配"其实是"扩盘"——把 500G 扩到 1T,IOPS 上限会跟着涨。这一步之前建议先看云监控里 `DiskWriteIOPS` / `DiskWriteThroughput` 是不是长期贴着上限,贴着才说明是 IO 瓶颈。 最后一个容易忽略的点:如果这个实例的备库和主库在同 AZ,半同步的 RTT 很小,理论上不太会因为网络退化。如果哪天把备库迁到另一个 AZ(跨 AZ 容灾),RTT 会上升到 1~2ms,那时候 timeout 一定要重新评估,1 秒可能就不够了。
他的回复:
你这 40 分钟不是"OPTIMIZE 不 online"造成的,而是**等 MDL 锁**等出来的,最后拿到锁的那一瞬间把所有写入堵住了。逐条答。 1. OPTIMIZE TABLE 到底 online 吗分两段看: - InnoDB 的 rebuild 本身是 online 的:`ALGORITHM=INPLACE` 允许并发 DML,边拷贝边把增量写进 row log,最后应用一次。所以"重建期间的 40 分钟"里,写入本来是可以继续的。- 但整个过程需要**两次 MDL 排他锁**:开始前一次(升级元数据)、结束前一次(短暂排他,应用 row log 并切换表)。这两次都很短。 你观察到的"写入全部超时"说明卡在了抢锁阶段,不是 rebuild 阶段。证据是:KILL 之后又过了十几分钟才真正结束——那是在等当前正在执行的语句结束、释放锁,不是在回滚 40 分钟的工作量。 为什么会等这么久:MDL 是队列制。一旦有一个请求在排队等排他锁,后面的所有请求(包括读)都会排在它后面,形成"锁队列堆积"。这时候业务看到的不是"变慢",而是"全部超时"。你说的"读也变慢但没完全阻塞",正好符合这个特征。 **所以第一件要改的事:给 DDL 加 `lock_wait_timeout`。** 默认 31536000 秒(一年),等于无限等。改成 5 秒:     SET SESSION lock_wait_timeout = 5;    ALTER TABLE t_order_flow FORCE, ALGORITHM=INPLACE, LOCK=NONE; 拿不到锁就立刻失败退出,不会排队堵住业务。这是最简单也最有效的一条防护。 2. OPTIMIZE 和 ALTER FORCE 的区别本质上是同一件事:`OPTIMIZE TABLE` 对 InnoDB 就是 `ALTER TABLE ... FORCE` 的语法糖(官方文档明确写了这一点),都会做 rebuild + 重建索引 + 更新统计信息。 差别在可控性:- `OPTIMIZE TABLE` 不能指定 ALGORITHM / LOCK。- `ALTER TABLE ... FORCE, ALGORITHM=INPLACE, LOCK=NONE` 可以显式声明。`LOCK=NONE` 的语义是"如果拿不到允许并发 DML 的锁,就**报错退出**",不会降级。这是你想要的"拿不到就立刻失败"。 注意 `LOCK=NONE` 只能保证 rebuild 阶段允许 DML,**不保证**那两次短暂的排他锁能立刻拿到。所以还是要配 `lock_wait_timeout`。 3. RDS 上做表重建的推荐路径三条,按安全度排: - 控制台的「结构变更」/「无锁变更」入口:本质是 DMS 在后台起一个在线变更任务,对加字段、加索引、改字段类型这类操作支持得比较好。**表重建(FORCE/OPTIMIZE)不一定在支持列表里**,具体要看你的实例版本和控制台提示,建议先在测试实例上试一次。- `ALTER TABLE ... FORCE, ALGORITHM=INPLACE, LOCK=NONE` + `lock_wait_timeout=5`:自己控制,适合业务低峰执行。8 核 32G、90GB 表、5 个二级索引,40 分钟是合理量级,别指望更快。- 分批迁移:建一张同结构的新表 `t_order_flow_new`,用主键区间分批 `INSERT ... SELECT`(每批 1 万行、每批 commit),追平后短暂加锁 rename。缺点是应用要短暂停写,优点是全程可控、可中断、可回滚(改回旧表名即可)。4000 万行的量级,这条路最稳。 4. pt-online-schema-change / gh-ost 在 RDS 上能用吗能用,但有前提: - **权限**:需要 CREATE / DROP / ALTER / TRIGGER / INSERT / UPDATE / DELETE 权限,以及 `SUPER` 或 `SYSTEM_VARIABLES_ADMIN`(gh-ost 需要设置 `binlog_format=ROW`、`binlog_row_image=FULL`)。RDS 的高级账号(高权限账号)基本满足;普通账号不行。- **触发器**:RDS 不禁用触发器,pt-osc 可以跑。但如果表上已经有触发器,pt-osc 会拒绝执行(可以用 `--preserve-triggers`,8.0 支持)。- **外键**:有外键引用的表 pt-osc 会拒绝,需要 `--alter-foreign-keys-method`,比较麻烦。- **gh-ost** 更推荐:它不建触发器,靠解析 binlog 拷贝增量,对业务侵入更小;但需要 binlog 是 ROW 格式,且要有 REPLICATION CLIENT 权限。RDS 默认 binlog_format 是 ROW,这点没问题。 你表上有 5 个二级索引、两个含 varchar(64) 的联合索引,gh-ost 的拷贝速度会明显慢于 pt-osc(没有触发器但有 binlog 解析开销),预估会比 40 分钟更长,但**全程不锁业务**。这个取舍看你的停机窗口。 5. Data_free 要不要处理不急。Data_free 是"表空间内可复用的空闲页",新插入会优先用这些页,所以只要你的表还在增长,空间会被自然吃掉,文件不缩完全没问题。 经验阈值:Data_free / (Data_length + Index_length) 超过 **30%~40%** 且表**不再增长**(或者增长很慢),才值得重建。你的情况是删了 60% 的行、Data_free 60GB / 表 90GB ≈ 66%,确实偏高,但如果你后续还会持续写入,其实可以先不重建,让新数据填进去。 真正需要重建的场景是:备份体积和恢复时间成为瓶颈(你说全量恢复要 2 小时,这个确实该优化)、或者全表扫描因为页碎片变慢。 6. 耗时预估粗算公式(InnoDB rebuild):     耗时 ≈ 表总大小 / 有效写入带宽 × (1 + 索引数 × 0.2) 90GB、5 个索引、假设有效写入带宽 100MB/s:90/0.1 = 900 秒 × 2 = 1800 秒 = 30 分钟。实测 40 分钟,量级吻合。带宽受磁盘 IOPS 和 `innodb_io_capacity` 限制,云盘上一般按「盘的类型标称 IOPS × 16KB」估算。 RDS 控制台目前不提供 DDL 耗时预估,只能自己按上面这个公式估,再留 1.5 倍余量。 几个补充建议 - 备份要做好:40 分钟的 DDL 期间如果主备延迟到 300 秒,一旦主库故障,切换会丢数据。**做重建前先手动打一个备份**,并且确认 binlog 保留时长足够(至少覆盖 DDL 时长 + 恢复时长)。- 主备延迟 300 秒这件事本身要查:大表 rebuild 会产生巨量 binlog,备库单线程回放跟不上。8.0 可以开 `binlog_transaction_compression=ON` 减少 binlog 体积,或者用并行复制(`slave_parallel_workers`)。这是独立于本次问题的隐患。- 以后删数据,优先用**分区表**:按月份分区,`DROP PARTITION` 是秒级操作,不产生 undo、不产生碎片、不锁表。这是从根上避免"删了不降磁盘"的方案,比任何重建手段都干净。你这种按时间归档的流水表,天然适合分区。 一句话总结:这次不是 online DDL 失效,是 MDL 排队把业务堵死了;下次先 `SET SESSION lock_wait_timeout=5`,再用 `ALTER ... LOCK=NONE`,低峰跑;想彻底避免,改成按时间分区。
他的回复:
先把「有索引为什么还慢」这件事说清楚,这是理解后面所有方案的前提。 1. LIMIT offset 到底扫了多少行MySQL 执行 `LIMIT 1000000, 20` 的语义是:**先按 ORDER BY 的顺序定位,然后跳过前 1000000 行(逐行丢弃),再返回 20 行**。它没有「跳到第 100 万行」的能力——B+ 树能 O(log n) 定位到某个 key,但「第 100 万行」不是一个 key,只是「按顺序数的第 100 万条」。 所以它必须沿着索引顺序一路读下去,读一行、判断 WHERE、丢弃、再读下一行。这就是 offset 越大越慢的唯一原因。而且如果查的列不在索引里(你查了 order_no、user_id、amount),它还要**每读一行就回表一次**去主键上取这些列——100 万次随机 IO。 8 秒这个数字很典型:100 万次回表 + 顺序扫索引,在 RDS 的普通云盘上是这个量级。 2. rows 是估算值EXPLAIN 里的 rows 是优化器根据统计信息估的,不是实际扫描行数。真实值要看:- `EXPLAIN ANALYZE`(8.0.18+),直接给 actual rows 和实际耗时;- 或 `SHOW SESSION STATUS LIKE 'Handler_read_next'`,跑 SQL 前后取差值——这个数字就是实际沿着索引读了多少行,对深分页来说通常正好等于 offset + limit。 你这个场景里 `Handler_read_next` 会显示 1000020,很有说服力。 3. 为什么 (created_at, id) 联合索引没根治两个原因叠加:- **排序不匹配**:你的 ORDER BY 是 `id DESC`,而索引是 `(created_at, id)`。只有当 `created_at` 是等值条件时,索引内的 id 才天然有序。你是**范围条件**(`created_at >= ? AND ?`),范围扫描时索引内按 created_at 排序、每个 created_at 内部才按 id 排,全局不是 id 有序。所以 MySQL 要么放弃用索引排序、改走 filesort,要么选 idx_created 然后回表后排序。两种情况都绕不开「扫很多行」。- **回表**:索引上没有 order_no、amount,每一行都要回表。这就是我说的 100 万次随机 IO。 4. 延迟关联(deferred join)在 8.0 上还有效有效,而且是最省事的改法。核心思路是「先用覆盖索引把 20 个主键找出来,再用主键回表取列」,把 100 万次回表压成 20 次: ```sqlSELECT o.id, o.order_no, o.user_id, o.amount, o.created_atFROM order_item oINNER JOIN (  SELECT id  FROM order_item  WHERE created_at >= ? AND created_at ?  ORDER BY id DESC  LIMIT 1000000, 20) t ON o.id = t.idORDER BY o.id DESC;``` 子查询里只查 id,如果 id 在某个索引里(主键就在二级索引的叶子节点上),可以做到全索引扫描不回表。代价是仍然要扫 100 万行索引条目——只是从「扫 100 万行 + 回表 100 万次」变成「扫 100 万行索引页」,通常能从 8 秒降到 1~2 秒。**是数量级的改善,但不是根治**。 要让它真的用上覆盖索引,最好有一个 `(created_at, id)` 的索引——你已经有了,正好合适。 5. 任意跳页 + 按 id 排序,100ms 内有解吗诚实地说:**在「任意跳页」这个约束下,没有完美解**。原因是 offset 的语义本身要求「数到第 N 条」,而数据库没有「第 N 条」的概念。能做的只有三条路: **路线 A:keyset 分页(游标分页)**```sqlWHERE created_at >= ? AND created_at ? AND id ?last_idORDER BY id DESC LIMIT 20```从索引定位,O(log n) 直接到起点,永远 100ms 内。代价是不能跳页,只能「下一页/上一页」。 **路线 B:给「跳页」做一个近似实现**前端展示「1 2 3 ... 5000」,但用户点第 5000 页时,不真去 offset 5000×20,而是:- 如果用户是按时间筛选的,可以用时间做锚点:把「第 5000 页」换算成一个时间区间,再用 keyset 定位;- 或者干脆不支持跳到任意页,只支持「跳到某个时间点附近」——业务语义上其实更合理。 **路线 C:接受慢,但把慢的部分异步化**总数和页码用缓存(Redis 存 `total`,按筛选条件做 key,TTL 5 分钟),列表只提供「前 N 页 + 最近 N 页」的深分页,中间页返回「请缩小时间范围」。这是很多后台系统的实际做法。 6. 业界常见妥协按性价比排:1. **限制最大可翻页数**(比如 500 页),超出时提示「请使用更精确的筛选条件」。产品通常能接受,因为真实用户翻到 500 页的概率接近零——深分页 99% 是爬虫或者误操作。2. **总数和列表分开查**:`COUNT(*)` 单独用缓存或近似值(`information_schema.tables.table_rows` 或者维护一张计数表),列表查询不带总数。你原来用 `SQL_CALC_FOUND_ROWS` 更慢,正是因为它把总数计算塞进了同一条 SQL。3. **延迟关联**兜底,把最坏情况从 8 秒降到 1~2 秒。4. **强制带时间范围**:默认只查最近 7 天,用户要查更早就必须选时间。时间范围窄了,offset 自然上不去。这是最有效的一招,而且业务上完全说得通。 我的推荐组合:**延迟关联 + 强制时间范围 + 最大页数限制**。前两个是技术手段,第三个是产品约束。只靠技术手段解决「任意深度的任意跳页」是在跟 offset 的语义对抗,不划算。 补充一个容易忽略的点:你现在的 `ORDER BY id DESC` 如果 id 是自增主键,那它和时间序基本一致,可以考虑把业务排序语义直接定义成「按创建时间倒序,同一时间按 id 倒序」,然后建 `(created_at, id)` 索引并把 WHERE 也改成 keyset 写法。语义上没损失,性能上有本质差别。
他的回复:
你这个现象基本可以确定是 MDL 被长事务挡住了,而且 State 停在 "Waiting for table metadata lock" 说明 DDL 连第一阶段都没进去——它还在排队拿表级的元数据锁,所以 CPU 和 IO 都是闲的。下面按你的四个问题回答。 1. 谁在持有 MDL,怎么定位先明确 MDL 的语义:一个 DDL 要拿表的 EXCLUSIVE 元数据锁,而任何开着事务、访问过这张表的会话都会持有一个 SHARED 元数据锁。只要有一个这样的会话没结束,DDL 就一直等。RDS for MySQL 8.0 可以直接查 performance_schema.metadata_locks(一般需要 performance_schema 开启,RDS 默认是开的):  SELECT * FROM performance_schema.metadata_locks WHERE OBJECT_NAME='order_item'\G看 GRANTED='1' 且 LOCK_TYPE='SHARED_READ' 或 'SHARED_WRITE' 的行,它们的 OWNER_THREAD_ID 就是持有者。再用:  SELECT * FROM performance_schema.threads WHERE THREAD_ID=上一步的 OWNER_THREAD_ID>\G拿到 PROCESSLIST_ID,这就是你在 show processlist 里要找的会话号。如果 metadata_locks 查不到(有些版本或参数下为空),退一步用 sys 库的视图(RDS 通常自带 sys schema):  SELECT * FROM sys.schema_table_lock_waits WHERE object_name='order_item'\G这个视图直接把「谁在等、谁在挡」列成一行,非常好用。 2. 「执行完但没提交」的事务会不会一直持锁会,而且这就是你这个场景最常见的原因。一个连接池里的连接执行了 SELECT 或 UPDATE 之后没 commit/rollback,事务一直是活跃状态,它持有的 SHARED 元数据锁一直不放。连接池把连接借出去忘了还(或者代码里 try 块没有 finally 释放),这个事务可以挂几个小时甚至更久。快速找出来的办法:查 innodb_trx 里 trx_state='RUNNING' 且 trx_started 很早的记录:  SELECT trx_id, trx_mysql_thread_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS secs, trx_rows_locked, trx_query  FROM information_schema.innodb_trx  WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60  ORDER BY trx_started;注意一个坑:如果事务处于「已执行完 SQL、等客户端发下一条命令」的状态,trx_query 会是 NULL。这时候光看 innodb_trx 看不出它在干什么,需要配合 performance_schema.events_statements_current 按 THREAD_ID 找它上一条执行过的 SQL。看到 trx_query 为 NULL 但 trx_started 很久以前的,八成就是这种「挂着不提交」的僵尸事务。这类问题在 RDS 上还有个更省事的入口:控制台的「会话管理 / 慢 SQL 与锁分析」里通常有「长事务」列表,能直接看到持续时间和会话 ID,比手写 SQL 快。 3. kill 掉长事务之后 DDL 会自动继续吗会自动继续,不需要重跑。MDL 是排队等锁,持有者释放后,等待队列里的 DDL 会被唤醒并拿到锁,然后进入真正的建索引阶段(INPLACE + LOCK=NONE 的情况下这时才真正开始扫表建索引)。但有两点要注意:  - 要让长事务真正结束,得 kill 那个**连接会话**(KILL ),而不是 kill DDL。KILL 之后事务回滚也要时间,如果那个事务改了很多行,回滚可能几十秒到几分钟,这期间 DDL 还在等;  - DDL 等待本身也是有超时和重试的,如果等的过程中客户端连接断了,DDL 会失败退出。所以补跑前先确认那条 ALTER 的连接还活着。还有一个反直觉的点:**DDL 自己在排队的时候,也会挡住后面所有的查询**。因为 MySQL 的 MDL 队列是先来先服务的,一个等 EXCLUSIVE 锁的 DDL 会把后面新来的 SHARED 锁请求全堵住。所以一旦发现 DDL 卡在 Waiting for metadata lock,别放着不管——它会雪崩式地把整张表的访问都拖死。这是 DBA 圈里说的「MDL 雪崩」,处置优先级很高。 4. 3000 万行加索引要多久,有没有更稳的做法在 4U8G 上,3000 万行 / 12GB 的表用 INPLACE 加一个两列联合索引,大致量级是 10~30 分钟,取决于索引的选择性和 buffer pool 大小。你的表 12GB 而实例内存 8G,buffer pool 大概 5~6G,装不下全表,会大量走磁盘读,偏慢的那一端。如果确认要跑很久,几个更稳的选择:  - 调大 innodb_online_alter_log_max_size:online DDL 期间的并发 DML 会写进这个 log,超过上限 DDL 会直接失败回滚(报 1846)。默认 128MB 对大表偏小,临时调到 1GB。你这个 40 分钟卡住不是这个原因(还没进入 DDL 阶段),但真跑起来可能会撞上;  - 用 gh-ost / pt-online-schema-change:它们用影子表 + 触发器/binlog 同步,对主库的 MDL 占用极短(只在 rename 那一刻),适合超大表。代价是要额外磁盘(一份全表拷贝)和更长的总时长;  - 华为云 RDS 控制台有「无锁结构变更」这类功能(不同版本入口名可能不同),本质也是走影子表方案,比自己部署 gh-ost 省事。如果你的实例支持,建议优先用它;  - 无论如何,先在只读实例上跑一遍测时间,别直接在主库上试。 一个事后建议把「长事务监控」加上:定期跑上面那条 innodb_trx 的 SQL,超过 5 分钟就告警。绝大多数 MDL 阻塞都是长事务引起的,而这个监控成本极低。另外连接池一定要配 idle_in_transaction_session_timeout(MySQL 8.0 支持,RDS 可设),让挂着不提交的事务自动被杀,从根上减少这类问题。
他的回复:
这个现象在 5.7 升 8.0 时挺常见,根因基本可以定位到「索引代价估算变了」,不是索引本身有问题。逐条说。 1. 8.0 在「走索引+回表」和「全表扫描」之间怎么选优化器算的是代价,不是「能不能用索引」。走 idx_status_created 的代价 ≈ 扫描索引的页数 + 回表的随机 IO 页数。你这条 SQL 的 status=2 占 35%,一整个月的数据假设 100 万行里 35 万行命中,那就是 35 万次回表——按随机 IO 算,代价远超顺序扫 300 万行的全表。5.7 也会算这个,但它对「索引扫描 + 回表」的代价模型比较粗糙,倾向于「能用索引就用」;8.0 的代价模型更贴近真实 IO,所以会在这种低选择性列上主动放弃索引。这是行为差异的来源,不是 bug。 2. 为什么 possible_keys 有、key 是 NULLpossible_keys 只表示「这个索引在语法上可用于本查询的过滤条件」,跟代价无关。key 是 NULL 说明优化器评估后认为用它更贵。两者的关系就是「能用」和「划算」的区别。和 index_condition_pushdown 关系不大:ICP 是减少回表次数的手段,不能改变「要回表 35 万次」这个量级。至于 eq_range_index_dive_limit,它影响的是等值条件的基数估算精度(默认 200,超过就用统计信息近似),你这里是范围条件加等值条件混合,dive 的影响有限。真正值得看的是 ANALYZE 之后 mysql.innodb_index_stats 里这个索引的 cardinality——如果 status 的 cardinality 只有 2~3,估算必然认为它没选择性。 3. 不改 SQL 的稳定做法,按推荐顺序第一选择:加覆盖索引,把查询变成索引覆盖扫描,彻底消掉回表。你 select 的是 id, user_id, amount, created_at,如果建 (status, created_at, user_id, amount),那 35 万行的扫描全在索引里做,代价会低一个量级,优化器自然就选它了。代价是索引变大、写入略慢,但对这种查询为主的表很划算。第二选择:改索引顺序为 (created_at, status)。你的 where 里 created_at 是一个月的时间范围,如果一个月的数据只占全表的 1/12,那按 created_at 定位之后只剩 25 万行左右,再在里面过滤 status=2,扫描量直接降下来。而且 ORDER BY created_at DESC 也能顺带消掉 filesort。这个改动通常比加覆盖索引更小。第三选择:optimizer hint,SQL 里写 SELECT /*+ INDEX(orders idx_status_created) */ ...。它和 FORCE INDEX 的效果一样,区别在于 hint 写在 SQL 文本里、跟语句一起版本管理,而 FORCE INDEX 也是写在 SQL 里——两者其实都在 SQL 里。真正的差别是 hint 可以被 optimizer_switch 统一关掉(有些环境禁用了 hint),FORCE INDEX 则是硬绑定。可维护性上我更推荐 hint,因为它表达的是「建议」而不是「强制」,将来索引顺序调整后不至于直接报错。不建议长期依赖 FORCE INDEX:一旦数据分布变化(比如 status=2 变成只占 2%),强制走索引反而会阻止优化器选更优的全表扫描,变成新的性能问题。 4. 把联合索引改成 (created_at, status) 行不行行,而且在 8.0 上这种「范围在前、等值在后」的组合依然有效,原理是:范围条件先把索引定位到一个区间,区间内的 status 虽然不能再用索引做精确定位(因为范围列之后的有序列失效),但仍然可以走索引条件下推在索引层过滤,不必回表。关键是「范围列在前」能大幅缩小要扫的索引页数。但要注意一个前提:created_at 的范围必须足够窄。如果查询跨度是一年,那范围本身就没选择性,把 created_at 放前面不会更好,这时候要么加覆盖索引,要么接受全表扫描但优化排序(去掉 filesort)。 几个补充建议一是先做一次真实测量再改:用 EXPLAIN ANALYZE(8.0.18+ 支持)跑一次,它会给出实际耗时和实际扫描行数,比估算的 rows 可靠得多。5.7 没有这个命令,这也是升级后值得用起来的一个能力。二是别急着删旧索引。先加新索引,观察一到两周的执行计划和慢查询,确认稳定了再删。多一个索引的写入开销远小于误删导致的回退风险。三是如果这张表写入很频繁,加索引的 DDL 用 Online DDL(ALGORITHM=INPLACE, LOCK=NONE)或者走 RDS 的无锁变更,避免长时间锁表。 最后提醒一句:35% 的选择性本身说明 status 这个字段的区分度太低,如果业务上有「只查最近未完成订单」这类高频查询,更好的做法是用一个冗余字段(比如 is_active)或者按时间分区,从数据模型上把扫描量降下来,比调索引更治本。