2026/10/19 10:43:27

MySQL数据脚本实战指南:从建表规范到Python自动化与排错

MySQL数据脚本实战指南:从建表规范到Python自动化与排错 写MySQL数据脚本这事看起来是简单的“写SQL”但实际上一脚深一脚浅。我在一线做数据开发和运维也有年头了从最开始把一堆INSERT INTO糊在记事本里到现在自己维护着一套完整的MySQL脚本库中间踩过的坑能装满一箩筐。这篇就把我实际经验中觉得最有用的东西整理出来从最基础的建表脚本怎么写才不容易埋雷到用Python批量灌数、迁移数据、更新线上数据这些实操再到乱码、超时、主键冲突这类几乎人人都会遇上的问题一次性说清楚。这篇文章适合谁看如果你刚接触MySQL想找一个规范的脚本写法而不是靠百度东拼西凑或者你已经写过一些脚本但总觉得哪里别扭、不稳定那你花十分钟看完肯定比你自己摸索一周要划算。1. 先把数据脚本这件事想明白1.1 数据脚本到底是个啥它和普通SQL有啥区别很多人把数据脚本直接等同于SQL语句这个理解不算错但实际上差了点火候。SELECT、INSERT这些是SQL语句而数据脚本则是把这些语句按照业务逻辑串起来、自动化跑起来的一个完整文件。打个比方SQL语句是你跟数据库说一句话数据脚本则是你跟整个团队的人开了一次会、布置了一套完整流程。从日常的场景来看MySQL数据脚本通常在下面这几类任务里出现建库建表初始化业务数据比如给一套新系统铺底数据批量更新线上数据比如给某个状态字段做统一调整或者按条件更新几十万条记录数据迁移从一个库导到另一个库从旧表结构转到新表结构定时跑批比如每天凌晨汇总前一天的数据、计算报表数据修复比如发现某张表某些字段值错乱需要刷一遍历史数据。一个合格的数据脚本不应该是一堆SQL的机械拼凑。它应该具备三个特点可重复执行、可追溯结果、出问题的时候能快速止损。可重复执行意味着你跑了两次和跑一次效果一样这个叫幂等性非常重要。可追溯结果是你跑完能知道影响了多少行、有没有报错。快速止损是当你发现脚本有问题时可以马上停止并回滚而不是眼看着数据越刷越乱。1.2 写脚本前先问自己的五个问题动手写第一行SQL之前我建议你先想清楚五件事。这个习惯帮我避开了无数的雷第一这个脚本是一次性任务还是周期性任务如果是一次性的跑完就完那你可能不需要考虑太复杂的容错如果是周期性的就必须考虑重复执行会不会出问题要不要做幂等控制。第二涉及的数据量有多大是几百条还是几百万条量级不同写法和执行方式完全不同。几百万行的更新你用一条UPDATE直接搞大概率会锁表锁到业务报警。第三脚本执行期间线上业务能不能接受短暂的不稳定有的脚本必须在凌晨低峰期跑有的可以随时跑。这决定了你有没有资格用那些简单粗暴的写法。第四跑挂了怎么回滚大部分数据脚本是不会有“后悔药”的所以写之前就要想好如果执行到一半失败了数据可能是中间状态你靠什么恢复。第五脚本有没有可能误伤DELETE和UPDATE一定要带上严格的WHERE条件这个看起来像废话但实际工作中因为少个WHERE或者条件写错而干翻整个表的案例我见得实在太多了。想明白这五个问题你基本就知道这个脚本该怎么写了。下面我按一个完整的数据脚本生命周期从设计到实操到排错把每个环节的关键点都过一遍。2. 建表脚本的设计别让地基歪了2.1 基础建表模板以及每个字段背后的讲究很多人拿到需求就开写CREATE TABLE写出来倒是也快但表里面全是细节问题。我通常用下面这个模板作为基线再根据业务调整CREATE TABLE IF NOT EXISTS user_account ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键ID, user_code VARCHAR(32) NOT NULL COMMENT 用户编号业务唯一标识, user_name VARCHAR(64) NOT NULL COMMENT 用户姓名, email VARCHAR(128) DEFAULT NULL COMMENT 邮箱允许空, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1-正常0-禁用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_user_code (user_code), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT用户账户表;这里面的每一处都不是随随便便定的。主键用BIGINT UNSIGNED而不是INT是因为INT最大才21亿多很多业务表数据量说涨就涨真到了临界值再改主键类型麻烦到让你怀疑人生。UNSIGNED可以让正数范围翻倍而且主键本来就不需要负数。如果你怕单表太大最后可以走分库分表那主键就尽量选BIGINT这是长期主义的考虑。VARCHAR的长度一定要克制。不少新手喜欢把所有字符串都设成VARCHAR(255)甚至VARCHAR(500)图省事但代价是索引大小变大、内存排序变慢、冗余数据变多。实际项目中用户名一般64就够邮箱128也足够超过这个长度的用TEXT。status这类枚举字段用TINYINT而不是VARCHAR省空间且查询效率更高。但是在代码里一定要维护好注释0代表什么、1代表什么写清楚不然三个月后自己都看不懂。时间字段尽量都用DATETIME配合DEFAULT CURRENT_TIMESTAMP新增记录不用手动填时间。ON UPDATE CURRENT_TIMESTAMP更香这行数据只要被更新数据库自动帮你在updated_at里盖上时间戳。做增量同步、排查数据问题的时候这个字段价值千金。2.2 字符集与排序规则的选择乱码的根源在这字符集选不好等到数据表里囤了几十万条记录之后再来改那就非常痛苦了。我的建议是新库新表无脑选utf8mb4排序规则选utf8mb4_unicode_ci。utf8mb4和utf8的区别很多人搞不清楚。MySQL里的utf8其实是utf8mb3只支持最多3个字节的字符。这意味着像emoji表情这种4字节字符它根本存不进去。你想想现在业务方随手一个好评带个笑脸表情你数据库崩给你看然后排查半天发现是字符集的问题血压直接就上来了。utf8mb4_unicode_ci和utf8mb4_general_ci这两个排序规则也经常让人纠结。简单说unicode_ci在排序和比较时对多语言的处理更加准确general_ci在中文场景下问题不大但精度略差。现在两者性能差距基本可以忽略选unicode_ci更保险。还有一个容易忽略的点库、表、字段、连接四个层面的字符集要保持一致。我见过太多情况是库是utf8mb4但某个字段是latin1或者JDBC连接串没指定编码导致写入正常、读取乱码。所以建表脚本里尽量把CHARSET和COLLATE显式写出来不要依赖默认值这样换环境执行时行为是可预期的。2.3 索引不是越多越好这说的是心里话很多开发同学建表时有一个习惯把每个可能查询的字段都加上索引本意是想让查询变快但结果往往是索引占的空间比数据还大写入变慢而且查询优化器反而不知道该走哪个索引了。关于索引我的原则是主键索引必须有业务唯一键必须有高频查询条件必须有其他一律先不加等有慢查询出现了再考虑。比如上面模板里的KEY idx_status (status)如果你有一个页面要按状态筛选用户这个索引有存在价值。但如果你的业务完全不查状态那就别建这个索引。索引要贴合查询不是看字段用不用得到而是看查询条件里用不用得到。联合索引需要注意最左前缀原则。比如要经常查(status, created_at)那就建一个(status, created_at)联合索引而不是建两个单列索引。单列索引在联合查询时只能命中一个另一个字段还是在走回表或文件排序。3. 数据操作脚本的细节先慢想再快写3.1 批量插入的正确姿势别再逐条插了写初始化数据脚本时最常见的错误写法是这样的INSERT INTO user_account (user_code, user_name, email, status) VALUES (U001, 张三, zhangsanexample.com, 1); INSERT INTO user_account (user_code, user_name, email, status) VALUES (U002, 李四, lisiexample.com, 1); INSERT INTO user_account (user_code, user_name, email, status) VALUES (U003, 王五, wangwuexample.com, 1);每条记录单独一条INSERT一次性插个几十条问题不大但如果你要初始化几万条数据这么干慢得让人崩溃。每条INSERT都要单独走一次SQL解析、权限检查、事务处理性能非常感人。正确的姿势是用批量INSERT的语法INSERT INTO user_account (user_code, user_name, email, status) VALUES (U001, 张三, zhangsanexample.com, 1), (U002, 李四, lisiexample.com, 1), (U003, 王五, wangwuexample.com, 1), (U004, 赵六, zhaoliuexample.com, 0);一次插几百行是很常见的做法体感速度能提升一个量级。但也不是越多越好一次插个几万行的超大VALUES列表会导致网络包过大、内存压力大反而不稳定。我一般建议一次500到1000行通过循环去分批执行。如果是往业务表里灌数据还得考虑一个事要不要更新已有记录。业务上经常有“初始化或更新”的场景比如用户档案已经存在就更新不存在才插入。这时候可以用ON DUPLICATE KEY UPDATEINSERT INTO user_account (user_code, user_name, email, status) VALUES (U001, 张三, zhangsan_newexample.com, 1) ON DUPLICATE KEY UPDATE user_name VALUES(user_name), email VALUES(email), status VALUES(status);这样不管是新插入还是已有记录跑完结果都是你想要的脚本天然幂等。注意VALUES()函数在MySQL 8.0.20之后标记为废弃在新版本里建议用别名的方式INSERT INTO user_account (user_code, user_name, email, status) VALUES (U001, 张三, zhangsan_newexample.com, 1) AS new ON DUPLICATE KEY UPDATE user_name new.user_name, email new.email, status new.status;3.2 大数据量更新和删除分批才是王道这是我觉得最值得拿出来讲的实战经验。很多人写数据脚本更新一张百万行的表直接上来就是UPDATE user_account SET status 0 WHERE status 1;如果表不大几十万行以内这条语句几十毫秒或者几百毫秒就跑完了问题不大。但当表达到千万行级别一个UPDATE扫描几百万行会导致几个很严重的问题一个是长时间持有行锁MySQL默认的隔离级别是REPEATABLE READ更新过程中会锁住相关行如果是范围更大可能锁定的行数多到挤占锁资源线上业务CPU飙高、死锁频发另一个是事务日志膨胀大事务提交的时候Binlog和Redo Log的写入量很大从库同步会滞后。我处理大数据量更新基本都走“查主键ID分批更新”的套路。具体是这样做的-- 每批处理1000条循环执行直到没有符合条件的记录 UPDATE user_account SET status 0 WHERE id IN ( SELECT id FROM ( SELECT id FROM user_account WHERE status 1 LIMIT 1000 ) AS tmp );为什么套了个两层子查询因为MySQL不支持直接在UPDATE的子查询里引用本表必须包一层临时表绕一下。或者你也可以先把主键查出来放到程序里再组装UPDATE ... WHERE id IN (...)。删除同理。一次性DELETE几百万行也会造成大事务、长时间锁表。最佳实践是按主键范围或者按时间分批删除每批几百条到一千条批与批之间SLEEP一下给数据库喘息的机会。如果是清理历史数据最简单的写法是DELETE FROM order_log WHERE create_time 2023-01-01 LIMIT 500;循环执行这个语句直到影响行数为0。这样每批事务都比较小从库同步压力小主库锁冲突也少。3.3 备份先行回滚预案这两件事比脚本本身还重要数据脚本执行前一定要先备份这句话我讲一百遍都不嫌多。特别是UPDATE和DELETE一旦执行谁也没法保证100%不出问题。方寸之间必有一备。习惯性的动作是这样如果是小表几万行以内直接CREATE TABLE xxx_bak_20250101 AS SELECT * FROM xxx;一分钟完成心里踏实如果是大表CREATE TABLE AS SELECT会锁表且耗时长建议用mysqldump导出单表或者用percona的pt-archiver工具归档。比如要更新user_account里的关键字段我先跑CREATE TABLE user_account_bak_20250101 AS SELECT * FROM user_account;然后确认备份表行数和原表一致再执行更新。真有业务方说“哎呀数据不对帮我退回昨天”你直接一条TRUNCATE业务表再从备份表刷回来几分钟搞定。没有备份你就只能一边抹冷汗一边找binlog难度不是一个量级。如果不需要保留备份想快速恢复某个字段其实还可以用UPDATE ... JOIN从备份表刷回。比如UPDATE user_account AS u JOIN user_account_bak_20250101 AS b ON u.id b.id SET u.status b.status;这样既不用清表也不用全量导对线上影响最小。4. 用Python写MySQL脚本把重复劳动自动化4.1 为什么用Python连接选什么驱动虽然在MySQL命令行里写脚本也能做事但复杂的业务逻辑、条件判断、循环处理、异常捕获这些用SQL很难优雅地实现。更常见的玩法是用Python等语言写一个自动化脚本把SQL作为子操作包进去。这也是“mysql数据脚本”真正进阶的形态。Python连接MySQL驱动我基本只用pymysql。如果你用的是Django或SQLAlchemy这种ORM那访问链路里已经自动用了mysqlclient或者pymysql了。pymysql是纯Python实现的安装简单没有编译环节对新手最友好。安装命令pip install pymysql连接数据库的基本代码import pymysql conn pymysql.connect( host127.0.0.1, port3306, userroot, passwordyour_password, databasetest_db, charsetutf8mb4 ) cursor conn.cursor()这里有个细节charsetutf8mb4一定要写。不写的话连接字符集可能落回数据库默认值和你的表字符集不一致中文就会乱码。4.2 一个完整的批量数据处理脚本示例我来演示一个实际场景表user_account里的用户状态需要做批量更新按照一个外部传入的用户编号列表把这些用户的status改成指定的值。这个任务如果用纯SQL一条条写得拼接几千条SQL而且容易出错。用Python脚本做逻辑清楚还能带日志。import pymysql import logging import time logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) # 模拟待更新的用户编号和状态 # 实际场景中可以是从文件、接口或者另一个数据库读取的 user_updates [ (U001, 1), (U002, 0), (U003, 1), ] BATCH_SIZE 100 conn pymysql.connect( host127.0.0.1, port3306, userroot, passwordyour_password, databasetest_db, charsetutf8mb4, autocommitFalse ) cursor conn.cursor() try: total 0 for i in range(0, len(user_updates), BATCH_SIZE): batch user_updates[i:iBATCH_SIZE] sql UPDATE user_account SET status CASE user_code values [] for code, status in batch: sql WHEN %s THEN %s values.extend([code, status]) sql END WHERE user_code IN (%s) % ,.join([%s] * len(batch)) values.extend([item[0] for item in batch]) cursor.execute(sql, values) total cursor.rowcount conn.commit() logging.info(f已处理 {min(i BATCH_SIZE, len(user_updates))}/{len(user_updates)} 条受影响 {cursor.rowcount} 行) logging.info(f全部完成共更新 {total} 行) except Exception as e: conn.rollback() logging.error(f执行失败已回滚{e}) finally: cursor.close() conn.close()这段代码里有几个值得品味的地方用CASE WHEN批量更新一条SQL更新多条不同数据而不是每条记录一条UPDATE性能差距非常大。而且我们用了参数占位符%s而不是直接把用户编号拼进SQL这样既能防止SQL注入也能避免特殊字符带来的语法问题。autocommitFalse配合手动commit()是为了保证一批更新要么全部成功要么全部回滚。每处理一批就commit一次是为了避免一个大事务拖垮数据库。批量大小100到500是经验值太小事务多效率低太大锁冲突久。cursor.rowcount是MySQL驱动里返回的受影响行数。这里要注意一个点如果更新的值前后没变化MySQL的rowcount还是会把匹配的行数算进去不一定是真正变化的行数。需要精确统计时可以用ROW_COUNT()或者通过先查再改来控制。4.3 从数据库A迁数据到数据库B别用SELECT *硬糊数据迁移是数据脚本的高频场景。有一次我需要把一个老系统库的表每半小时增量同步到一个新库表结构基本一样但有几个字段类型做了调整。刚接手的人可能直接用select_sql SELECT * FROM old_db.user_account cursor_old.execute(select_sql) rows cursor_old.fetchall() for row in rows: insert_sql INSERT INTO new_db.user_account VALUES ...这种写法有几个问题。SELECT *一旦源表加了字段字段顺序变了目标表插入直接对不上而逐行INSERT在大数据量下性能和自杀差不多。我更推荐的做法是显式指定字段列表然后用分批executemany插入。比如import pymysql old_conn pymysql.connect(host127.0.0.1, port3306, userroot, passwordold_pwd, databaseold_db, charsetutf8mb4) new_conn pymysql.connect(host127.0.0.1, port3306, userroot, passwordnew_pwd, databasenew_db, charsetutf8mb4) old_cur old_conn.cursor() new_cur new_conn.cursor() old_cur.execute(SELECT user_code, user_name, email, status FROM user_account WHERE id %s ORDER BY id, (last_id,)) rows old_cur.fetchmany(5000) insert_sql INSERT INTO new_db.user_account (user_code, user_name, email, status) VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE user_name VALUES(user_name), email VALUES(email), status VALUES(status) new_cur.executemany(insert_sql, rows) new_conn.commit()这里用executemany批量执行相同模板的SQL性能和写一个大VALUES列表差不多但代码更清晰。加上ON DUPLICATE KEY UPDATE整个迁移脚本天然支持增量场景跑两次和跑一次效果一致。fetchmany(5000)是逐步取数避免一次性把几百万行全部加载到内存里内存爆炸是fetchall在大数据量下的常见死法。按主键id作为游标分段拉取是增量迁移最稳的方式还可以配合断点续传。5. 定时跑批与Shell脚本让数据脚本自己跑起来5.1 用Shell包裹SQLCron调度数据脚本不是写完就完事了更多时候你是要它每天凌晨自动跑一遍。这时候就需要Shell脚本加系统计划任务来配合了。一个简单的Shell包装MySQL命令的写法#!/bin/bash source /etc/profile MYSQL_BIN/usr/local/mysql/bin/mysql DB_HOST127.0.0.1 DB_USERapp_runner DB_PASSIfm#2024!Secure DB_NAMEreport_db LOG_FILE/data/logs/mysql_script_$(date \%Y\%m\%d).log echo START $(date %Y-%m-%d %H:%M:%S) ${LOG_FILE} ${MYSQL_BIN} -h${DB_HOST} -u${DB_USER} -p${DB_PASS} ${DB_NAME} --default-character-setutf8mb4 -e UPDATE report_daily SET status 1 WHERE report_date CURDATE() - INTERVAL 1 DAY; ${LOG_FILE} 21 echo DONE $(date %Y-%m-%d %H:%M:%S) ${LOG_FILE}这里面有几个细节值得注意。--default-character-setutf8mb4必须带上防止Shell环境字符集和库表不一致导致乱码。用-e参数执行多条SQL时用分号分隔。 LOG_FILE 21是把标准输出和错误输出都写进日志便于事后排查。然后配上crontab0 2 * * * /data/scripts/run_daily_report.sh /dev/null 21这里每天凌晨2点执行。日志一定要落盘不然哪天数据算错了连个查的地方都没有。5.2 可视化界面不如一个可控的跑批队列有人会纠结要不要搞一套可视化的任务调度平台。我个人的看法是如果你的脚本数量在个位数跑批频率不高Linux的crontab完全够用不要过早引入复杂系统。但如果你有几十个脚本互相依赖、需要看依赖关系、需要失败重跑那确实需要一套调度系统。市面上像某某开源的分布式任务调度框架有很多但引入它们都有不小的运维成本。我自己更推荐的过渡方案是用Shell脚本加锁文件机制让脚本本身具备防止并发执行的能力#!/bin/bash LOCK_FILE/tmp/mysql_script_xxx.lock if [ -f ${LOCK_FILE} ]; then echo 脚本正在执行中本实例退出 /data/logs/mysql_script_xxx.log exit 0 fi touch ${LOCK_FILE} trap rm -f ${LOCK_FILE} EXIT # 实际任务内容这样即使cron执行周期短于脚本的执行时长也能避免两个任务实例同时跑防止重复处理数据。5.3 事务和锁的取舍跑批数据别把线上卡死跑批脚本在低峰期执行是最理想的但这不代表你可以肆无忌惮地锁表。即使凌晨业务量低也可能会有一些实时应用在跑。长时间锁表会让应用连接淤积最终拖垮数据库。我实践下来有几个降锁策略第一能用分区肯定优先用分区。比如报表表按天做RANGE分区跑批只操作昨天的分区对今天的分区毫无影响天然隔离。第二大数据量更新务必分批执行每批之间加SLEEPSELECT SLEEP(1);让数据库喘口气避免持续高压写Binlog。第三能用INSERT INTO ... SELECT就不要先查出来再插。例如把A表的数据汇总到B表直接一条SQL搞定INSERT INTO report_daily (report_date, total_cnt) SELECT report_date, COUNT(*) FROM user_activity WHERE report_date CURDATE() - INTERVAL 1 DAY GROUP BY report_date;这样一条语句数据库内部完成没有网络往返效率最高。6. 常见问题与排查踩坑实录6.1 中文乱码99%的字符集问题都能这样查清乱码问题出现的概率高得惊人。排查步骤我建议按这个顺序来第一步查看库、表、字段的字符集SHOW CREATE TABLE user_account;看CHARSET和COLLATE是不是utf8mb4。第二步查看连接字符集SHOW VARIABLES LIKE character_set%;重点看character_set_client、character_set_connection、character_set_results。如果它们不一致客户端输入的SQL和返回的结果集都可能被转码。第三步如果库表字段都是utf8mb4连接也是utf8mb4还是乱码那大概率是你数据源本身就存了乱码。直接查一下HexSELECT HEX(user_name) FROM user_account WHERE id 1;如果中文“张”的UTF-8编码应该是E5BC A0结果你看到的是类似3F那说明数据在写入时就已经是问号了只能从源头治理。经验总结所有的乱码问题都是因为写入时的编码和读取时的编码不一致。写入用latin1读出来用utf8mb4不乱才怪。所以建库建表时要统一连接参数要统一Shell和Python脚本里也要统一。6.2max_allowed_packet不够用批量插入报错批量插入大字符串或插入很多行时MySQL客户端可能出现这个错误ERROR 1153 (08S01): Got a packet bigger than max_allowed_packet bytes原因是单次SQL包大小超过了数据库配置的最大值。这个时候分两种处理。临时调整会话级变量SET GLOBAL max_allowed_packet 128 * 1024 * 1024;然后重新连接。更一劳永逸的做法是改MySQL配置文件比如my.cnf加max_allowed_packet 128M重启MySQL服务生效。但注意不要无脑调太大max_allowed_packet太大会让超大SQL一次全部加载到内存可能拖垮数据库。我一般调到64M到128M足够日常使用。6.3 执行到一半报错数据到底哪些更新了哪些没更新这个问题的核心是事务边界。如果你脚本里开了事务且坚持到COMMIT才生效那中途失败直接ROLLBACK数据不会出现“一半更新”的情况。最怕的是每执行一条SQL就自动提交了。默认情况下MySQL的自动提交是开启的你每执行一条UPDATE或者DELETE马上就生效了中途哪条出错前面的都不会自动回滚。所以我在写Python脚本时都会显式设置autocommitFalse然后在代码里控制commit()时机。批量处理的任务每批一提交既保证事务不太大又能记录哪一批执行成功。真遇到执行一半失败了又没做事务控制的情况你能靠的就是前面说的备份表。所以备份这件事永远是数据脚本执行前的基本动作没有例外。6.4 脚本重复跑数据被重复插入这个问题尤其在初始化脚本和同步脚本里常见。没有唯一键约束的脚本重复执行就会插入重复数据。解决思路有两个一个是在表结构上建立唯一键让数据库自己拒绝重复插入。另一个是在脚本里先查重再插入但这样并发时还是会打穿所以最靠谱的还是唯一键。有了唯一键之后配合ON DUPLICATE KEY UPDATE或者INSERT IGNORE重复执行脚本就变成了一件非常安全的事。INSERT IGNORE适合那种“有就不动”的场景ON DUPLICATE KEY UPDATE适合“有就更新”的场景。我一般倾向用后者因为跑批脚本大多数时候希望数据最终状态和脚本逻辑一致。6.5 死锁了怎么办批量更新时如果多个事务互相持有对方需要的锁就很容易触发死锁。报错一般是ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction简单说两个事务都锁了一张表或一批行A事务想更新B事务锁住的行B事务想更新A事务锁住的行互相等MySQL检测到僵局后强制回滚其中一个。降低死锁概率的方法多个事务尽量按相同顺序更新数据比如都按主键从小到大处理减小事务范围分批提交锁定的行数就少确保WHERE条件能走索引全表扫描会把大量行的间隙锁都锁上死锁概率直接上升。代码层面遇到死锁报错可以做个重试机制比如最多重试3次每次随机等待1-2秒。我在Python里会这样处理for retry in range(3): try: cursor.execute(sql) conn.commit() break except pymysql.err.OperationalError as e: if e.args[0] 1213: conn.rollback() time.sleep(1 retry) continue else: raise7. 真正好用的数据脚本写在规范里回到最开始说的那句话写MySQL数据脚本怎么写是一方面怎么写好是另一方面。这里把我最后想说的几条经验集中放在一起。第一脚本必须带完整的日志。执行时间、影响行数、错误信息全都要记录下来。这不是给领导看的是给你自己看的。三天后你发现数据有问题想排查靠的就是这些日志。第二脚本必须有明确的幂等设计。能写成可重复执行的脚本就不要写只能跑一次的脚本。上线前先在一个测试环境跑两遍验证第二遍跑完的数据和第一遍完全一致这叫回归验证。第三脚本的代码风格要一致。用不用大写关键字、SQL缩进是否对齐、表名字段名是否用反引号包裹这些看着是小事情但当你维护几十个脚本的时候统一风格能省下大把时间。我自己是习惯所有MySQL关键字大写表名字段名统一用反引号包裹变量名全部小写下划线。第四能用一条SQL解决的业务逻辑坚决不要用Python循环去逐条执行。SQL是数据库原生的操作语言性能最高、最不涉及网络开销。但反过来需要判断、循环、异常处理的复杂逻辑也不要硬凑SQL用Python包一层是更合理的架构。第五写脚本之前必须了解你的数据量级。同一个UPDATE语句一万行的表和一亿行的表执行策略完全不同。量级决定方案这是数据开发里最根本的判断标准。我个人在实际操作中最大的体会是每一个看起来“神乎其技”的高效脚本背后都是无数个踩坑经验堆出来的。刚开始写脚本时总是追求写得快能跑就行现在反而求稳、求可维护、求可回滚。MySQL数据脚本这个事做得好的人不是写SQL写得有多花哨而是把每一个细节都考虑得足够周全。希望这篇内容能给你省下一些摸索的时间。