做DBA这些年我犯下的失误比我交过的变更单还多。我始终觉得DBA是一个“不出事就是成功”的岗位可恰恰因为这样很多教训是用实实在在的生产事故换来的。今天把这些血泪教训摊开讲不是为了自揭伤疤而是想让刚入行或者正在路上的同行们知道那些看起来平平无奇的坑掉进去过的人没有一个愿意你再掉一次。大概算下来我从一个执行误删的菜鸟到能独立设计一套容灾恢复体系中间至少经历过五次让我失眠的事故一次误删数据一次备份失效一次在线DDL拖垮主库一次磁盘写满的静默杀局。每一次都让我在深夜里反复复盘也一步步把我从“会敲命令”推向了“理解系统”。这篇文章没有教科书式的高深理论全部来自我在一线数据库运维里踩过的真实脚印。我把每一次事故的来龙去脉、排查链路、根因分析、事后改进都写了出来希望对你在生产环境里少走弯路有所帮助。1. 一条漏了条件的DELETE误删全表后的五小时1.1 事故现场还原我当时在某公司负责核心业务库的日常运维公司有一套内部开发的数据订正平台用来执行各类临时SQL。那天下午业务方提了一张工单要求清理一张保存了三年的订单历史明细表只保留最近一年的数据。听起来是个非常常规的任务。我在订正平台上写了一条SQLDELETE FROM order_history_detail WHERE order_time archive_date;这里的archive_date应该由平台自动绑定成“一年前”的时间点。问题出在平台的一个细节上如果参数没有显式传入平台会把变量值拼成空字符串。而我的SQL在执行时参数绑定环节出了问题最终落到数据库里变成了DELETE FROM order_history_detail WHERE order_time ;MySQL在比较日期和空字符串时会把空字符串转成0值再和order_time做比较。而任何正常的order_time都大于0。也就是说这个条件等价于恒真条件这条delete直接把你想要保留的和不想要保留的全部删光了。一开始我完全没意识到。订正平台返回了“执行成功”影响行数是整个表的行数。我当时心里还咯噔了一下但以为是数据量大的正常表现随手把工单标记成处理完成继续去忙下一条任务。直到二十分钟后业务方的电话打了过来语气很急订单历史明细页面全部空白导出报表下的数据也对不上数。我打开数据库一查心一下子沉到底——整张表只剩下了92行而这张表原本有三千多万行数据。1.2 从慌张到冷静误删后的排查链路电话挂断后我做的第一件事不是道歉也不是写检查而是强迫自己按顺序回答三个问题删了什么、能否恢复、恢复要多长时间。我先看一下binlog是否开启以及binlog_format是什么。如果binlog_format是ROW那每条被删记录都在binlog里留有before image恢复就有了最基本的材料。我用下面命令确认mysql SHOW VARIABLES LIKE log_bin; mysql SHOW VARIABLES LIKE binlog_format;确认binlog_formatROW之后又用SHOW BINARY LOGS列出现有的binlog文件并根据误删时间点定位到对应的日志文件mysql SHOW BINARY LOGS;接着用mysqlbinlog把那个时间段的事件解码出来确认影响行数和删除细节mysqlbinlog --base64-outputDECODE-ROWS -v --start-datetime2024-06-13 14:30:00 --stop-datetime2024-06-13 15:10:00 /var/lib/mysql/binlog.000132 /tmp/delete_events.sql查看解析结果后我确认删除动作确实只有一条但瞬间删掉了几千万行。这里有个非常关键但极容易忽略的点在误删场景下最先要保住的是“现场”。我没有立刻重启数据库也没有马上执行其他任何写操作而是先做了两件事第一个是全库加锁禁止写入第二个是尽快备份当前的binlog文件。为什么因为在binlog_formatROW模式下只要binlog文件还在数据就可以按“删除前镜像”恢复。但如果在恢复前数据库又产生了大量新写入binlog被继续使用、轮转、清理恢复的复杂度会成倍上升。所以第一步永远是保住现场的binlog。1.3 恢复实操利用binlog反推DELETE前的行数据确认现场之后我用了binlog2sql这个工具——它本质上是个解析binlog并生成反向SQL的Python脚本国内很多团队都用过。解压后执行binlog2sql -h127.0.0.1 -P3306 -uadmin -p*** \ -d order_db -t order_history_detail \ --start-datetime2024-06-13 14:30:00 \ --stop-datetime2024-06-13 15:10:00 \ -B /tmp/rollback.sql这里-B参数表示生成反向SQL也就是把binlog里的DELETE事件转换成INSERT语句。生成的rollback.sql里就是三千多万行INSERT。拿到反向SQL之后我没有急着在线上库直接执行而是先在一台备机上做了一个最小化验证抽了前1万条开事务执行再查询确认数据能正常读出来。验证无误后回到主库在业务低峰期开启了事务并分批导入mysql -h127.0.0.1 -uadmin -p*** order_db /tmp/rollback.sql整个导入过程加上分批提交的优化花了四个多小时。业务是在凌晨两点完全恢复的。也就是说从误删到业务恢复前后将近五小时。这五个小时里我最深的体会是恢复速度不是取决于你恢复数据的速度而是取决于你“判断现场和保护现场”的速度。如果一开始慌张重启服务或继续写入哪怕binlog有记录恢复难度也会指数级上升。1.4 为什么忙乱状态下更容易犯这类错事后复盘时我发现在误删之前我的操作习惯里有几个明显的隐患第一我没有先查看实际影响行数就执行了删除。其实在MySQL客户端里只要执行delete时加一句SELECT COUNT(*)生成等价的where条件先确认返回行数就能提前发现问题。第二我过度相信订正平台。平台是我自己参与搭的但参数绑定的代码写得很粗糙异常情况根本没有做日志记录。这个事故的根子其实是代码缺陷不是SQL本身。第三测试环境和生产环境的窗口混在同一个工具里。那天下午我同时开着一个测试库窗口和一个生产库窗口虽然注意力没出岔子但长期在这种环境下工作隐患就是一颗定时炸弹。那次之后我给自己定了几条铁律生产库的任何高危操作DELETE、UPDATE、TRUNCATE、DROP必须先在从库用相同语句跑一遍查看影响行数。MySQL客户端务必开启--safe-updates模式未带主键或大范围条件的一次性删除会被拒绝执行。执行前口头向身边同事复述一遍操作计划和预期影响把“说出口”当成一道复核关卡。订正平台接入审计日志所有动态SQL的最终形态必须落库保存便于事后追溯。提示任何DELETE在执行前先跑一遍同条件的SELECT COUNT(*)。这条规则简单到让人不屑但它救过的库比任何工具都多。这些铁律听上去很基础但没有一次血的教训真的很难坚持。尤其是--safe-updates它看起来“挡事”却能让新手在最上头的时候被拦一下。2. 从不验证的备份等于没有备份容灾体系的慢性死亡2.1 那台主库磁盘故障时的崩溃瞬间误删事件平息了几个月之后我又迎来了一次让我至今记忆犹新的考验。某天凌晨一台承载核心业务的主库磁盘报出大量坏道系统日志里全是IO错误数据库实例在几分钟内直接挂掉。按预案我们准备从备份中恢复数据。但当我打开备份平台点开最近一次全量备份去恢复时解压过程直接报错gzip: /backup/full_20240701.sql.gz: unexpected end of file我不信邪又试前一天的、前两天的一连点了五六个备份文件全部解压失败。那一刻我整个人都凉了。最后翻到9天前的那次备份才终于能正常解压。也就是说过去一周多的时间里每天凌晨的“成功备份”全是坏文件。而调度平台因为只看整体退出码一直显示任务正常。那一夜我一边在机房回忆备份脚本的每一步一边意识到这个事故不是磁盘故障造成的是我亲手埋下的雷。2.2 备份脚本为什么会“假成功”我们的备份脚本长这样#!/bin/bash mysqldump --single-transaction --master-data2 \ --set-gtid-purgedOFF -A | gzip /backup/full_$(date %Y%m%d).sql.gz问题出在哪出在我对Unix管道的退出码做了错误的假设。管道命令的退出码默认是“最后一个命令”的退出码也就是说上面这条命令里shell只会看gzip的退出码不会看mysqldump的退出码。如果gzip因为磁盘空间不足或者其他原因中途失败只要gzip正常退出整个脚本就认为备份成功了。更糟的是那段时间/backup目录所在的磁盘空间本身就非常紧张压缩中途写到一半时因磁盘写满而中断。gzip确实返回了一个非零退出码但我的脚本没有做任何检查。下一个环节把不完整的压缩包当成了成功备份推到了备份平台。等我发现时备份平台上躺着好几个“成功”的空壳。2.3 恢复链路一条被逼到极限的数据回补找到唯一可用的9天前备份后我开始设计恢复方案。这里有一个重要的前提全量备份是9天前的但binlog是完整的。也就是说只要binlog还在我就能用全量备份加binlog增量回放把数据追到故障发生的那个时间点。具体链路是先把9天前的全量备份在临时实例上恢复。gunzip /backup/full_20240701.sql.gz mysql -h127.0.0.1 -uadmin -p*** /backup/full_20240701.sql确认全量恢复成功记录全量备份文件里的MASTER_LOG_FILE和MASTER_LOG_POS。在mysqldump加--master-data2时导出文件头部会带有CHANGE MASTER语句记录了备份开始时的binlog文件名和位置-- CHANGE MASTER TO MASTER_LOG_FILEbinlog.000098, MASTER_LOG_POS154;用mysqlbinlog把从binlog.000098的154位置起到主库宕机前的所有binlog解析成SQL。mysqlbinlog --start-position154 --stop-datetime2024-07-10 03:00:00 \ binlog.000098 binlog.000099 ... binlog.000132 /tmp/incremental.sql在临时实例上回放增量SQL并对比关键业务表的行数和最新时间戳。这个过程说起来只有四步但实际做的时候非常煎熬。9天的binlog文件接近几百GB解析出来的增量SQL有上千万条回放时还会遇到唯一键冲突、临时表和自增ID错位的问题。我们只能分批回放、反复对账牺牲了部分非核心数据的精确性最终把恢复时间点推进到了主库宕机前30分钟左右。整个恢复用时接近七个小时。七个小时里业务处于“能读不能写”的降级状态那是我做DBA以来最漫长的一个夜晚。2.4 把恢复演练从“可选”变成“必须”回到事故本身我一直在问自己为什么备份脚本的问题那么久都没被发现答案是没有恢复演练。备份系统每天都在跑但没有任何人真正在一个新实例上做一次完整restore也从来没人去检查备份文件的完整性。“备份成功”四个字在监控面板上看起来是绿色的可它恢复不出来这个绿色毫无意义。在那之后我把备份体系全部重构了一遍核心变化有三点脚本必须拆分执行不用管道连接重命令每步都检查退出码mysqldump --single-transaction --master-data2 \ --set-gtid-purgedOFF --all-databases /backup/full_$(date %Y%m%d).sql if [ $? -ne 0 ]; then echo $(date): mysqldump failed /backup/backup_fail.log exit 1 fi gzip /backup/full_$(date %Y%m%d).sql备份文件生成后立即做“可读性校验”比如检查文件头部和尾部是否为有效SQL用zcat测试解压完整性避免坏文件进入备份平台。每月在隔离环境做一次完整的恢复演练。用最近的备份重建实例、回放binlog、跑数据校验记录“从故障到可用”的真实RTO时间。演练过程发现的任何一个异常都按生产事故级别对待。我的个人体会是容灾体系最核心的指标不是“备份成功率”而是“备份可恢复率”。一个团队如果连一次恢复演练都没做过那它的容灾体系本质上只是画了张饼。3. 一条在线ALTER主库被拖垮的三小时3.1 事故前夜我对在线DDL的盲目自信前两件事都是数据层面的失误第三件事则让我意识到DBA对“变更操作”的敬畏心一样不能少。某电商业务大促前夜我收到一个加索引的需求要给一张1.2亿行的订单表加一个组合索引。当时MySQL已经是8.0版本我查了下官方文档ALTER TABLE ADD INDEX默认支持ALGORITHMINPLACE官方说可以通过在线方式完成不会阻塞DML。我就天真地以为随时都可以执行于是直接在业务高峰期开了一个会话对生产主库跑了ALTER TABLE order_info ADD INDEX idx_user_created (user_id, created_at), ALGORITHMINPLACE, LOCKNONE;开始的十几分钟里确实没有任何阻塞迹象。我以为一切尽在掌握甚至去泡了杯咖啡。二十分钟后监控告警开始连续弹出主库活跃连接数直线上升监控图表从绿色一路翻红。我打开SHOW FULL PROCESSLIST看到一大片“Waiting for table metadata lock”的会话队列越排越长。而这些会话后面又压着更多的业务请求连接池全部打满。主库的CPU从20%跳到95%以上负载彻底失控。3.2 metadata lock的排队效应当场打脸这里我要先讲清楚一个机制避免有人重蹈覆辙。不管MySQL的InnoDB在线DDL在“表内数据重建”层面做了多少优化DDL在执行时仍然要先获取表的元数据锁MDL而且这个锁的类型会发生冲突。原理上普通DML语句需要的是MDL读锁。DDL语句需要的是MDL写锁。读锁和写锁互斥多个读锁可以共存。当一条ALTER TABLE开始后如果表上恰好有正在执行的长事务那么DDL要等这个长事务提交才能拿到MDL写锁。MDL写锁的等待本身不消耗大量CPU但它会阻塞后续所有新到达的DML语句——因为它们都需要MDL读锁而读锁必须在写锁之后排队。于是链路变成了这样我的ALTER开始尝试获取MDL写锁。表上某条长事务一直不提交ALTER一直拿不到锁。ALTER会话进入“Waiting for table metadata lock”。后续所有业务的SELECT和UPDATE都排队等待MDL读锁。连接池不断创建新连接往队列里塞请求连接数暴涨内存和CPU一起爆炸。我当时评估表大小的时候只想着“1.2亿行INPLACE拷贝要多久”却忘了评估另一个关键变量这个表在业务高峰期是否存在持续的长事务。结果表上有一个报表程序发起的超长查询已经跑了四十多分钟。我的ALTER根本抢不过这条长事务于是整个队列卡死在MDL上。更坑的是我因为看不到ALTER的执行进度又做了一次最糟糕的决定直接执行了KILL。KILL掉ALTER之后MDL写锁等待消失了但堆在后面的数百个DML请求同时涌入主库瞬间被打穿主从复制延迟从几秒飙升到数十分钟。3.3 三小时救火复盘那些原本可以做对的决策整个事故从爆发到稳定持续了大约三小时。我把业务读流量切到从库临时关闭了部分报表接口才终于让主库缓过气来。事后复盘我列了很多“原本可以做对”的决策第一变更时间窗选错了。在线DDL再优化也扛不住“高峰期大表长事务”的组合。一个合理的DBA在这种需求面前应该坚持排到凌晨低峰期而不是被业务方“大促前必须加索引”的紧急感带着走。第二那个组合索引其实并不需要立刻加。索引的收益在查询侧而大促前的主要压力在写侧临时加索引反而会让导入和更新更慢。我需要先验证是否有索引真的能命中如果慢查询记录里根本没有这类SQL模式这个需求本身就是伪需求。第三即使要执行也应该用pt-online-schema-change或gh-ost这类更可控的工具。这类工具的原理是先建一张影子表在影子表上完成表结构变更然后用触发器或row copy的方式回放增量最后通过原子换表完成切换。全程不持有原表的MDL写锁对业务的冲击要小得多。第四我在执行前没有配置lock_wait_timeout。MySQL默认值很大一旦遇到锁等待会话会无限期卡住。合理配置可以设置一个上限比如mysql SET SESSION lock_wait_timeout 60;超过60秒拿不到锁就报错退出给操作人一个明确信号而不是把整个主库拖进锁队列。3.4 线上变更的兜底机制那次事故后我把“变更是故障的第一大来源”这句话写进了团队的运维手册里。现在我们的线上变更流程变成了这样变更前必须给出预期影响范围包括表大小、行数、索引字节数、预计耗时、锁类型。高危变更先在从库执行观察无异常后再排期到主库。使用在线变更工具禁止对超大表直接执行裸ALTER。执行时开启会话超时配置防止无限期锁等待。变更期间持续监控主从复制延迟、连接数、活跃事务数一旦指标异常立即回滚。另外我还养成了一个习惯凡是变更类操作必然先把回滚方案写清楚。比如加索引的回滚就是删除索引但如果是修改字段长度回滚可能涉及数据重建就必须提前估算好回滚耗时。没有回滚预案的变更本质上是在裸奔。4. 只盯着磁盘百分比日志文件撑满数据盘的静默杀局4.1 凌晨一点半的业务报错第四个事故发生在一次看似岁月静好的凌晨。某业务库的写入量在近两周因为新功能上线飙升binlog增长速率比平时快了将近三倍。我们的监控面板上磁盘使用率从60%涨到75%再到85%再到92%——但监控平台设置的严重告警阈值是95%所以一直没人处理。我自己也因为白天太忙没有去看增长趋势曲线。结果在某个凌晨一点半磁盘使用率达到100%数据库刷盘失败业务开始报“No space left on device”。这个事故的讽刺之处在于磁盘空间监控一直在跑警报也一直在响只是响的时候已经晚了。4.2 不是块耗光了可能是inode耗光了接到告警后我登录主机先看了磁盘空间df -h结果确实100%。但奇怪的是数据目录下的文件总大小加起来不到200GB而磁盘有2TB。我又看了文件系统inode使用率df -i才发现inode使用率已经到了100%。也就是说磁盘的“数据容量”还有剩余但“文件索引节点”全部耗完了。数据目录里躺满了海量的日志文件每个文件都很小但数量惊人把inode吃光了。这类问题在实际运维中特别常见文件上传临时目录、core dump文件、慢查询日志、binlog文件都会以极小文件的方式快速消耗inode。只监控磁盘容量百分比永远无法发现这类风险。我查到罪魁祸首是一台业务应用在数据目录下写入大量临时文件同时数据库的慢查询日志被开到了目录里且没有轮转。两个因素叠加直接吃光了inode。紧急处理是清理临时文件和轮转日志rm -rf /data/mysql/tmp_files_* mysql SHOW VARIABLES LIKE slow_query_log;但这只解了燃眉之急。第二天我做了两件事把慢查询日志用logrotate配置了按天轮转同时限制了临时文件的写入策略。更关键的是binlog本身。我们当时虽然设置了expire_logs_days7但对高吞吐的业务来说binlog的生成速度远大于后台清理线程的处理速度。binlog清理不是“一到时间就删”它由后台事件轮询触发且必须等该binlog不能被从库需要。一旦binlog膨胀速度超过清理速度磁盘水位就会持续走高。MySQL 8.0支持了binlog_expire_logs_seconds参数可以把清理粒度从“天”改成“秒”配合高写入场景会更精确。mysql SET GLOBAL binlog_expire_logs_seconds 86400;4.3 监控指标需要“容量速率深度”三层那天之后我重新审视了监控体系。以前我们只监控“磁盘使用率”现在变成了三层容量层磁盘空间使用率、inode使用率、某个目录下的文件数量。速率层binlog生成速率、日志增长速率、慢查询数量增幅。速率用来做“剩余可用时间”预测——即使当前水位不高如果按当前速率推导只剩两小时就要提前干预。深度层主从复制延迟、binlog清理是否卡住、日志轮转是否正常。我还整理了一张监控矩阵表把DBA最关心的指标按级别排好监控对象核心指标告警级别操作系统CPU利用率、负载警告/严重操作系统内存使用率、swap警告/严重操作系统磁盘空间使用率严重90%/紧急95%操作系统inode使用率、文件数严重85%数据库活跃连接数、连接池饱和度严重数据库慢查询数量、长事务警告数据库主从复制延迟严重数据库binlog生成与清理警告/严重业务侧关键SQL响应时间、失败率严重设置阈值的原则是“留有余量”。磁盘空间到80%就要出警告因为还要考虑日志轮转、dump文件、归档操作的临时空间需求inode用满的后果往往比块空间用满更隐蔽更值得单独监控。4.4 事故是最好的监控教材复盘这个磁盘事故时我发现它本质上不是“没有监控”而是“监控没有覆盖关键路径”。监控面板上那一堆绿色的指标给了所有人一种虚幻的安心感。真正会杀死核心业务库的反而不是那些常看的大指标而是那些没人注意的小角落inode、日志文件的增长速度、清理任务的执行情况。在那之后我们的监控告警从“分级阈值”变成了“分级阈值趋势预测”。比如磁盘空间告警不再只判断当前使用率而是计算过去15分钟的增长斜率预测到达100%的时间点。只要预测时间小于2小时就触发紧急告警。这个改动看似简单却在之后多次帮我们提前捕捉到了日志膨胀的风险。我个人最想提醒同行的一句话是监控不是为了好看不是为了面板上全是绿点。监控的唯一目的是在事情真正变得不可收拾之前给你一个足够长的刹车距离。回头看这些年走过的弯路我最大的改变不是学会了更多命令而是心态变了。以前我觉得DBA的价值在于“会修”现在我觉得最大的价值是“让它不要坏”。误删事故教会我敬畏数据备份事故教会我验证信任DDL事故教会我尊重变更磁盘事故教会我看穿指标。没有这些血泪教训我大概到现在还是那个觉得“执行成功”就等于“事情办妥”的新手。如果一定要给同行们留一句话永远保持对生产环境的敬畏永远把最坏情况当成默认情况来准备。备份要当恢复不出来去验证变更要当会出故障去演练监控要当会失灵去设计兜底。数据库管理员这个职业真正的成长之路从来都是由一次次深夜复盘铺出来的。最后再分享一个小技巧每次事故处理完我都会写一份复盘文档文档里固定包含五个部分——事故现场、时间线、根因、修复动作、未来预防。写文档不是为了交差而是为了下一次遇到类似问题我能在一分钟之内知道自己当时做了什么、做错了什么。这份文档比任何培训课程都管用。