ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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', '张三', 'zhangsan@example.com', 1); INSERT INTO user_account (user_code, user_name, email, status) VALUES ('U002', '李四', 'lisi@example.com', 1); INSERT INTO user_account (user_code, user_name, email, status) VALUES ('U003', '王五', 'wangwu@example.com', 1);

每条记录单独一条INSERT,一次性插个几十条问题不大,但如果你要初始化几万条数据,这么干慢得让人崩溃。每条INSERT都要单独走一次SQL解析、权限检查、事务处理,性能非常感人。

正确的姿势是用批量INSERT的语法:

INSERT INTO user_account (user_code, user_name, email, status) VALUES ('U001', '张三', 'zhangsan@example.com', 1), ('U002', '李四', 'lisi@example.com', 1), ('U003', '王五', 'wangwu@example.com', 1), ('U004', '赵六', 'zhaoliu@example.com', 0);

一次插几百行是很常见的做法,体感速度能提升一个量级。但也不是越多越好,一次插个几万行的超大VALUES列表会导致网络包过大、内存压力大,反而不稳定。我一般建议一次500到1000行,通过循环去分批执行。

如果是往业务表里灌数据,还得考虑一个事:要不要更新已有记录。业务上经常有“初始化或更新”的场景,比如用户档案已经存在就更新,不存在才插入。这时候可以用ON DUPLICATE KEY UPDATE:

INSERT INTO user_account (user_code, user_name, email, status) VALUES ('U001', '张三', 'zhangsan_new@example.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_new@example.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( host='127.0.0.1', port=3306, user='root', password='your_password', database='test_db', charset='utf8mb4' ) cursor = conn.cursor()

这里有个细节:charset='utf8mb4'一定要写。不写的话,连接字符集可能落回数据库默认值,和你的表字符集不一致,中文就会乱码。

4.2 一个完整的批量数据处理脚本示例

我来演示一个实际场景:表user_account里的用户状态需要做批量更新,按照一个外部传入的用户编号列表,把这些用户的status改成指定的值。这个任务如果用纯SQL一条条写,得拼接几千条SQL,而且容易出错。用Python脚本做,逻辑清楚,还能带日志。

import pymysql import logging import time logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') # 模拟待更新的用户编号和状态 # 实际场景中可以是从文件、接口或者另一个数据库读取的 user_updates = [ ('U001', 1), ('U002', 0), ('U003', 1), ] BATCH_SIZE = 100 conn = pymysql.connect( host='127.0.0.1', port=3306, user='root', password='your_password', database='test_db', charset='utf8mb4', autocommit=False ) cursor = conn.cursor() try: total = 0 for i in range(0, len(user_updates), BATCH_SIZE): batch = user_updates[i:i+BATCH_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注入,也能避免特殊字符带来的语法问题。

autocommit=False配合手动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(host='127.0.0.1', port=3306, user='root', password='old_pwd', database='old_db', charset='utf8mb4') new_conn = pymysql.connect(host='127.0.0.1', port=3306, user='root', password='new_pwd', database='new_db', charset='utf8mb4') 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包裹SQL,Cron调度

数据脚本不是写完就完事了,更多时候你是要它每天凌晨自动跑一遍。这时候就需要Shell脚本加系统计划任务来配合了。

一个简单的Shell包装MySQL命令的写法:

#!/bin/bash source /etc/profile MYSQL_BIN=/usr/local/mysql/bin/mysql DB_HOST=127.0.0.1 DB_USER=app_runner DB_PASS='Ifm#2024!Secure' DB_NAME=report_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-set=utf8mb4 -e " UPDATE report_daily SET status = 1 WHERE report_date = CURDATE() - INTERVAL 1 DAY; " >> ${LOG_FILE} 2>&1 echo "===== DONE $(date '+%Y-%m-%d %H:%M:%S') =====" >> ${LOG_FILE}

这里面有几个细节值得注意。--default-character-set=utf8mb4必须带上,防止Shell环境字符集和库表不一致导致乱码。用-e参数执行多条SQL时,用分号分隔。>> LOG_FILE 2>&1是把标准输出和错误输出都写进日志,便于事后排查。

然后配上crontab:

0 2 * * * /data/scripts/run_daily_report.sh >/dev/null 2>&1

这里每天凌晨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分区,跑批只操作昨天的分区,对今天的分区毫无影响,天然隔离。

第二,大数据量更新务必分批执行,每批之间加SLEEP:

SELECT 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,还是乱码,那大概率是你数据源本身就存了乱码。直接查一下Hex:

SELECT 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脚本时,都会显式设置autocommit=False,然后在代码里控制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: raise

7. 真正好用的数据脚本,写在规范里

回到最开始说的那句话,写MySQL数据脚本,怎么写是一方面,怎么写好是另一方面。这里把我最后想说的几条经验集中放在一起。

第一,脚本必须带完整的日志。执行时间、影响行数、错误信息,全都要记录下来。这不是给领导看的,是给你自己看的。三天后你发现数据有问题想排查,靠的就是这些日志。

第二,脚本必须有明确的幂等设计。能写成可重复执行的脚本,就不要写只能跑一次的脚本。上线前先在一个测试环境跑两遍,验证第二遍跑完的数据和第一遍完全一致,这叫回归验证。

第三,脚本的代码风格要一致。用不用大写关键字、SQL缩进是否对齐、表名字段名是否用反引号包裹,这些看着是小事情,但当你维护几十个脚本的时候,统一风格能省下大把时间。我自己是习惯所有MySQL关键字大写,表名字段名统一用反引号包裹,变量名全部小写下划线。

第四,能用一条SQL解决的业务逻辑,坚决不要用Python循环去逐条执行。SQL是数据库原生的操作语言,性能最高、最不涉及网络开销。但反过来,需要判断、循环、异常处理的复杂逻辑,也不要硬凑SQL,用Python包一层是更合理的架构。

第五,写脚本之前必须了解你的数据量级。同一个UPDATE语句,一万行的表和一亿行的表,执行策略完全不同。量级决定方案,这是数据开发里最根本的判断标准。

我个人在实际操作中最大的体会是:每一个看起来“神乎其技”的高效脚本,背后都是无数个踩坑经验堆出来的。刚开始写脚本时总是追求写得快,能跑就行;现在反而求稳、求可维护、求可回滚。MySQL数据脚本这个事,做得好的人不是写SQL写得有多花哨,而是把每一个细节都考虑得足够周全。希望这篇内容能给你省下一些摸索的时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进