1. 这不是语法手册,而是你每天都在写的“数据对话”现场还原
在某次线上故障复盘会上,一位后端同事盯着监控曲线突然说:“查了半小时,发现是那条UPDATE语句没加WHERE,把整张用户表的积分全清零了。”会议室里没人笑——因为我们都干过类似的事。SQL里的CRUD(CREATE、READ、UPDATE、DELETE)从来不是教科书里的四个字母,而是你和数据库之间最频繁、最直接、也最容易出错的日常对话。它不抽象,它就发生在你敲下回车键的0.3秒之后:一行INSERT可能让新用户完成注册,一个漏掉条件的DELETE可能让运营活动数据集体失踪,一次没索引的SELECT可能拖垮整个API响应。我带过的几个刚转行的开发者,90%的线上事故都卡在这四个动作的细节里——不是不会写,而是不知道为什么这样写才安全、高效、可维护。这篇内容专为真实工作场景而生:不讲“SQL是什么”,只拆解你在写增删改查时真正纠结的每一个括号、每一处空格、每一条执行计划背后的逻辑。适合正在写业务代码的后端、需要直连数据库的BI分析师、甚至经常用Excel导入导出但想搞懂底层逻辑的运营同学。你会发现,一条看似简单的SELECT * FROM users WHERE status = 1,背后牵扯着索引结构、字符集排序规则、查询缓存机制,甚至MySQL版本对隐式类型转换的处理差异。这不是理论课,这是你明天早上就要调试的那条语句的实战说明书。
2. CRUD设计底层逻辑:为什么MySQL要这样强制你“说话算数”
2.1 CREATE:建表不是画图纸,而是给数据世界立宪法
很多人以为CREATE TABLE只是定义字段名和类型,其实它是在给未来所有数据操作划定法律边界。我见过最典型的反例是某电商项目初期用VARCHAR(255)存所有ID字段,上线半年后发现订单ID已超长,被迫停服两小时做全量数据迁移。问题根源不在长度预估,而在CREATE阶段没理解MySQL对数据类型的物理约束力。
先看一个被反复误用的场景:INTvsBIGINT。新手常写id INT PRIMARY KEY AUTO_INCREMENT,觉得够用。但MySQL中INT有符号最大值是2147483647,按每天10万单计算,撑不过6年。而BIGINT上限是9223372036854775807,足够支撑到硬件淘汰。这里的关键不是“选大点更保险”,而是AUTO_INCREMENT的底层实现依赖于整型字段的存储空间:当自增列达到类型上限时,MySQL会报错Duplicate entry '2147483647' for key 'PRIMARY',且无法自动切换类型。你必须手动ALTER TABLE,而该操作在大表上会锁表数小时。
再看字符集陷阱。CREATE TABLE user (name VARCHAR(50)) ENGINE=InnoDB DEFAULT CHARSET=utf8;这条语句在MySQL 5.7之前看似正常,但utf8实际是utf8mb3,最多存3字节字符(不支持emoji)。当用户昵称存入😊时,MySQL会静默截断为?。正确做法是显式声明DEFAULT CHARSET=utf8mb4,并配合COLLATE=utf8mb4_0900_ai_ci(MySQL 8.0默认排序规则)。这个细节影响的是数据完整性——你永远无法从被截断的数据里恢复原始内容。
提示:建表时必须明确指定ENGINE、CHARSET、COLLATE三个参数。InnoDB是当前唯一支持事务和外键的引擎;
utf8mb4是完整Unicode支持的底线;_ai_ci后缀表示大小写不敏感且带重音符号识别(如é和e视为相同),避免搜索时漏匹配。
2.2 READ:SELECT不是取数据,而是向存储引擎发出精准作战指令
SELECT常被当成最简单的操作,但它其实是CRUD中性能波动最大、优化空间最深的动作。关键在于理解MySQL如何执行一条SELECT:客户端发送请求 → 连接器验证权限 → 查询缓存(MySQL 8.0已移除)→ 分析器解析语法 → 优化器生成执行计划 → 执行器调用存储引擎接口 → 返回结果。其中优化器决策和执行器与引擎的交互方式,直接决定你的SQL是毫秒级还是分钟级。
举个血泪案例:某内容平台的标签页加载慢,排查发现核心SQL是SELECT * FROM articles WHERE category_id = ? ORDER BY created_at DESC LIMIT 20。表面看很合理,但执行计划显示type: ALL(全表扫描)。原因在于category_id字段没有索引,而ORDER BY created_at又要求排序。MySQL不得不把所有文章读出来再内存排序,当articles表达千万级时,I/O直接打满。
解决方案不是简单加索引。如果只在category_id建单列索引,查询会走索引快速定位,但ORDER BY created_at仍需额外排序;如果建(category_id, created_at)联合索引,则能覆盖查询所需全部字段(Covering Index),存储引擎直接按索引B+树顺序返回20条记录,无需回表。这里的关键认知是:索引不是“加速WHERE”,而是“替代全表扫描”的数据访问路径。
更隐蔽的问题是SELECT *。很多ORM框架默认生成此语句,但它会强制MySQL读取整行数据(包括TEXT/BLOB等大字段),即使前端只显示标题和摘要。实测某新闻表含content TEXT字段,SELECT *比SELECT id,title,created_at慢4.7倍。因为大字段存储在行溢出页,SELECT *触发额外的磁盘随机IO。
2.3 UPDATE:修改不是覆盖值,而是触发一连串数据状态变更
UPDATE常被低估其复杂性。它不仅是改几个字段,而是在事务隔离级别下,对数据行施加锁、生成undo日志、更新索引、触发binlog写入的完整生命周期。我处理过最棘手的死锁案例,起因是一条看似无害的UPDATE user SET last_login = NOW() WHERE id = ?。
表面看是主键更新,应该很快。但问题出在并发场景:A事务更新用户1,B事务更新用户2,但两者都先执行了SELECT ... FOR UPDATE锁定多行(为后续业务逻辑准备),此时A持有用户1锁等待用户2,B持有用户2锁等待用户1,形成环路。根本原因在于UPDATE的锁粒度由WHERE条件决定:主键或唯一索引等值查询锁单行;范围查询(如WHERE age > 18)会锁住所有扫描到的间隙(Gap Lock);无索引条件则升级为表锁。
另一个高频坑是UPDATE ... SET column = column + 1。这看似原子操作,但实际分三步:读取原值 → 计算新值 → 写入。在高并发下,两个请求同时读到column=100,都算出101,最终结果是101而非预期的102。解决方案是使用UPDATE ... SET column = column + 1 WHERE id = ? AND column = ?(乐观锁),或直接用SELECT ... FOR UPDATE加锁后更新。
注意:UPDATE语句务必检查执行计划中的
rows值。若显示扫描行数远大于实际匹配数(如WHERE条件本应只匹配1行,却扫描10万行),说明索引失效,需立即优化。常见失效场景:对索引字段使用函数(WHERE YEAR(create_time) = 2023)、隐式类型转换(WHERE mobile = 13800138000,mobile是VARCHAR)、OR条件未全索引覆盖。
2.4 DELETE:删除不是擦除,而是标记+清理的异步过程
DELETE常被当作“删掉就没了”,但InnoDB引擎中,它本质是将数据行标记为删除状态,并在后续purge线程中物理清除。这意味着DELETE操作本身很快,但后续的磁盘空间回收和索引结构调整是异步的。某金融系统曾因批量DELETE百万级历史订单,导致后续INSERT变慢3倍——因为大量已删除行占据B+树节点,新数据插入需频繁分裂页面。
更危险的是DELETE FROM table。这条语句在InnoDB中会逐行删除并记录binlog,耗时长且占大量undo log空间。而TRUNCATE TABLE是DDL操作,直接重建表结构,速度快且不可回滚。但二者有本质区别:TRUNCATE重置AUTO_INCREMENT计数器,DELETE不重置;TRUNCATE不能带WHERE条件;TRUNCATE会释放磁盘空间(DROP + CREATE效果),DELETE仅标记删除。
实际运维中,我们采用分级策略:
- 单条删除:
DELETE FROM user WHERE id = ?,确保WHERE有主键或唯一索引 - 小批量删除(<1万行):
DELETE FROM log WHERE create_time < '2022-01-01' LIMIT 1000,配合循环执行,避免长事务 - 大批量归档:先
CREATE TABLE log_old AS SELECT * FROM log WHERE create_time < '2022-01-01',再DROP TABLE log_old
关键原则:永远不要在生产环境执行无WHERE条件的DELETE,除非你已备份并确认影响范围。我们团队的铁律是:任何DELETE语句上线前,必须先用EXPLAIN分析扫描行数,再用SELECT COUNT(*)验证匹配量,最后在测试库执行DELETE ... LIMIT 10观察执行时间。
3. 核心操作实操详解:从语法到执行计划的完整链路
3.1 CREATE实操:一张表的诞生全流程
建表不是复制粘贴,而是根据业务场景做技术决策。以用户表为例,我们逐步拆解每个参数的实战意义:
CREATE TABLE `user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID,全局唯一', `username` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '用户名,登录凭证', `email` VARCHAR(100) NOT NULL DEFAULT '' 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_email` (`email`), KEY `idx_status_created` (`status`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户基础信息表';字段设计深意:
id用BIGINT UNSIGNED:无符号避免负数,扩大正数范围;AUTO_INCREMENT保证写入性能(主键递增,B+树右追加)username设DEFAULT ''而非NULL:避免空值在索引中特殊处理,且业务层统一空字符串校验比NULL判断更简单email加UNIQUE KEY:强制唯一性,比应用层校验更可靠(分布式系统下应用层校验有竞态风险)status用TINYINT:比ENUM更灵活(后续扩展状态无需改表),比VARCHAR节省存储(1字节 vs 至少2字节)
索引策略解析:
- 主键
PRIMARY KEY (id):InnoDB聚簇索引,数据按id物理排序存储 - 唯一索引
uk_email:保证邮箱唯一,且作为登录查询入口(SELECT * FROM user WHERE email = ?可走此索引) - 联合索引
idx_status_created:覆盖常用查询SELECT * FROM user WHERE status = 1 ORDER BY created_at DESC,避免filesort
执行验证步骤:
- 创建后立即执行
SHOW CREATE TABLE user\G,确认字符集、引擎、索引是否符合预期 - 插入测试数据:
INSERT INTO user (username, email) VALUES ('test', 'test@example.com') - 验证唯一约束:重复插入相同email,应报错
Duplicate entry 'test@example.com' for key 'uk_email' - 检查索引使用:
EXPLAIN SELECT * FROM user WHERE email = 'test@example.com'\G,确认key: uk_email
实操心得:建表后务必用
SHOW INDEX FROM user检查索引列顺序。联合索引(a,b,c)能加速WHERE a=?、WHERE a=? AND b=?,但不能加速WHERE b=?。我们曾因索引顺序错误,导致订单查询始终走全表扫描。
3.2 READ实操:SELECT性能诊断四步法
诊断慢查询不是靠猜,而是标准化流程。以SELECT id, title, author FROM article WHERE category_id = 5 AND status = 1 ORDER BY publish_time DESC LIMIT 10为例:
第一步:获取执行计划
EXPLAIN FORMAT=JSON SELECT id, title, author FROM article WHERE category_id = 5 AND status = 1 ORDER BY publish_time DESC LIMIT 10;重点关注:
type:const(主键等值)>ref(非唯一索引)>range(范围)>ALL(全表扫描)key: 实际使用的索引名,若为NULL则未走索引rows: 预估扫描行数,越小越好Extra: 出现Using filesort(需额外排序)或Using temporary(需临时表)即存在性能瓶颈
第二步:检查索引覆盖度若执行计划显示key: idx_category_status但Extra: Using filesort,说明索引未覆盖ORDER BY字段。此时需创建覆盖索引:
-- 原索引 ALTER TABLE article DROP INDEX idx_category_status; -- 新联合索引,包含ORDER BY字段 ALTER TABLE article ADD INDEX idx_category_status_time (category_id, status, publish_time);注意:publish_time放在最后,因ORDER BY需按索引顺序扫描,而category_id和status是等值查询,放前面可快速过滤。
第三步:验证索引效果再次执行EXPLAIN,确认Extra变为NULL,且rows显著减少。同时用SELECT COUNT(*)验证匹配行数是否合理:
-- 查看category_id=5且status=1的总记录数 SELECT COUNT(*) FROM article WHERE category_id = 5 AND status = 1; -- 若结果为100万,但LIMIT 10却扫描100万行,说明索引未生效第四步:模拟真实负载压测在测试库用sysbench或简单脚本模拟并发查询:
# 100并发执行1000次 for i in {1..100}; do mysql -u test -ptest -e "SELECT id,title,author FROM article WHERE category_id=5 AND status=1 ORDER BY publish_time DESC LIMIT 10" db & done wait观察SHOW PROCESSLIST中是否有长时间运行的查询,以及SHOW STATUS LIKE 'Threads_running'是否持续高位。
注意:
SELECT *在开发环境可接受,但上线前必须改为明确字段列表。我们团队规定:所有生产SQL必须通过SELECT_FIELD_CHECK脚本验证,禁止出现*。
3.3 UPDATE实操:安全更新的七道防线
UPDATE是线上事故高发区,我们建立七道防线确保万无一失:
防线1:WHERE条件必走索引
-- 错误:无索引字段,全表扫描+锁表 UPDATE user SET status = 0 WHERE phone = '13800138000'; -- 正确:先确认phone有索引 SHOW INDEX FROM user WHERE Column_name = 'phone'; -- 若无,先建索引(注意大表加索引需在低峰期) ALTER TABLE user ADD INDEX idx_phone (phone);防线2:限制影响行数
-- 开启安全模式,防止误更新全表 SET SQL_SAFE_UPDATES = 1; -- 此时UPDATE必须有WHERE且WHERE需用到索引,否则报错 UPDATE user SET last_login = NOW() WHERE id = 123; -- 或用LIMIT控制(MySQL 5.6+支持) UPDATE user SET status = 0 WHERE phone = '13800138000' LIMIT 1;防线3:验证匹配行数
-- 先查再更,养成习惯 SELECT COUNT(*) FROM user WHERE phone = '13800138000'; -- 若结果>1,需确认是否真要更新多行 UPDATE user SET status = 0 WHERE phone = '13800138000';防线4:避免隐式转换
-- phone是VARCHAR,但传入数字会触发全表扫描 UPDATE user SET status = 0 WHERE phone = 13800138000; -- 错误! UPDATE user SET status = 0 WHERE phone = '13800138000'; -- 正确防线5:高并发场景用乐观锁
-- 应用层先查version SELECT id, version, balance FROM account WHERE id = 1001; -- 更新时校验version未变 UPDATE account SET balance = balance + 100, version = version + 1 WHERE id = 1001 AND version = 5; -- 若影响行数为0,说明已被其他事务修改,需重试防线6:大字段更新分离
-- 避免更新大文本字段拖慢事务 UPDATE user SET avatar_url = ? WHERE id = ?; -- 只更新URL -- 大文件存OSS,数据库只存路径防线7:执行后立即验证
-- 更新后立刻查证 SELECT id, status, updated_at FROM user WHERE id = 123; -- 确认status已变,updated_at为当前时间实操心得:我们所有UPDATE语句上线前,必须通过
UPDATE_SAFETY_CHECK脚本验证:① WHERE条件是否含索引字段 ② 是否有LIMIT ③ 是否存在函数/运算符导致索引失效。未通过者禁止发布。
3.4 DELETE实操:从安全删除到空间回收
DELETE操作必须遵循“先查后删、分批执行、验证回收”三原则:
安全删除标准流程:
-- 1. 确认目标数据(务必加LIMIT预览) SELECT id, username, created_at FROM user WHERE status = 0 AND created_at < '2020-01-01' LIMIT 10; -- 2. 统计总数(评估影响范围) SELECT COUNT(*) FROM user WHERE status = 0 AND created_at < '2020-01-01'; -- 3. 分批删除(每次1000行,避免长事务) DELETE FROM user WHERE status = 0 AND created_at < '2020-01-01' LIMIT 1000; -- 4. 循环执行直到影响行为0 -- 在应用层用while循环控制,每次删除后sleep 0.1秒降低IO压力空间回收深度操作: 删除大量数据后,表文件不会自动缩小。需手动优化:
-- 查看表碎片率 SELECT TABLE_NAME, DATA_LENGTH, INDEX_LENGTH, DATA_FREE, ROUND(((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024), 2) AS size_mb, ROUND((DATA_FREE / (DATA_LENGTH + INDEX_LENGTH)) * 100, 2) AS frag_pct FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'db_name' AND TABLE_NAME = 'user'; -- 若frag_pct > 20%,执行优化(会锁表,选低峰期) OPTIMIZE TABLE user; -- 或在线重建(MySQL 5.6+,InnoDB) ALTER TABLE user ENGINE=InnoDB;归档替代删除方案: 对需保留历史数据的场景,用分区表或归档表:
-- 创建归档表(结构相同,无主键冲突) CREATE TABLE user_archive LIKE user; -- 将旧数据迁移到归档表 INSERT INTO user_archive SELECT * FROM user WHERE status = 0 AND created_at < '2020-01-01'; -- 删除原表数据 DELETE FROM user WHERE status = 0 AND created_at < '2020-01-01';注意:
OPTIMIZE TABLE在MySQL 8.0中对InnoDB表实际执行ALTER TABLE ... FORCE,会重建表并更新索引统计信息。我们团队规定:所有超过10GB的大表优化,必须提前申请DBA支持,并在凌晨2-4点执行。
4. 常见问题与避坑指南:那些只有踩过才懂的细节
4.1 索引失效的12种真实场景
索引失效是SELECT慢的头号原因。以下是我们在生产环境抓取的12种典型失效场景,附带修复方案:
| 失效场景 | 示例SQL | 原因分析 | 修复方案 |
|---|---|---|---|
| 1. 对索引字段使用函数 | WHERE YEAR(create_time) = 2023 | 函数导致索引列无法直接比较 | 改为范围查询:WHERE create_time >= '2023-01-01' AND create_time < '2024-01-01' |
| 2. 隐式类型转换 | WHERE mobile = 13800138000(mobile是VARCHAR) | MySQL将字符串转数字,索引失效 | 统一类型:WHERE mobile = '13800138000' |
| 3. OR条件未全索引 | WHERE a = 1 OR b = 2(a有索引,b无) | 优化器放弃索引走全表扫描 | 拆分为UNION:(SELECT ... WHERE a=1) UNION (SELECT ... WHERE b=2) |
| 4. LIKE前导模糊 | WHERE name LIKE '%abc' | 无法利用索引的B+树结构 | 改用全文索引或ES |
| 5. 联合索引顺序错误 | 索引(a,b,c),查询WHERE b=1 AND c=2 | 最左前缀原则不满足 | 调整索引顺序或新建(b,c)索引 |
| 6. 使用!=或NOT IN | WHERE status != 1 | 通常走全表扫描 | 改为IN (0,2,3)或覆盖索引 |
| 7. COUNT(*)无索引 | SELECT COUNT(*) FROM user | InnoDB需遍历聚簇索引 | 添加COUNT专用索引:ALTER TABLE user ADD INDEX idx_count (id) |
| 8. NULL值查询 | WHERE email IS NULL | NULL在B+树中特殊存储 | 为NULL字段建单独索引或改用默认值 |
| 9. 大字段参与WHERE | WHERE content LIKE '%keyword%'(content是TEXT) | 大字段不建索引 | 提取关键词建倒排索引或用ES |
| 10. 统计信息过期 | EXPLAIN显示rows=1000但实际扫描10万行 | ANALYZE TABLE未及时更新 | 定期执行ANALYZE TABLE user |
| 11. 索引选择性低 | WHERE gender = 'M'(男女各50%) | 优化器认为全表扫描更快 | 删除低选择性索引,用覆盖索引 |
| 12. JOIN字段类型不一致 | user.id(BIGINT)JOINorder.user_id(INT) | 类型转换导致索引失效 | 统一字段类型 |
实操心得:我们用
pt-index-usage工具分析慢查询日志,自动生成“未使用索引”报告。某次发现user表的idx_status索引从未被使用,删除后QPS提升12%。
4.2 事务隔离级别的实战陷阱
MySQL默认REPEATABLE READ,但不同场景需调整。以下是四种级别在真实业务中的表现:
READ UNCOMMITTED(读未提交)
- 场景:实时监控类系统,允许脏读
- 陷阱:
SELECT可能读到其他事务未提交的中间状态,如转账中余额为负数 - 我们从未在业务库使用此级别
READ COMMITTED(读已提交)
- 场景:电商库存扣减,需避免不可重复读
- 关键行为:每次
SELECT都生成新快照,UPDATE基于最新数据 - 优势:避免幻读(MVCC机制),
SELECT ... FOR UPDATE只锁匹配行 - 配置:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
REPEATABLE READ(可重复读)
- MySQL默认级别,通过MVCC+间隙锁解决幻读
- 陷阱:
SELECT ... FOR UPDATE在范围查询时会锁住间隙,导致并发插入阻塞 - 案例:
SELECT * FROM order WHERE user_id = 1001 FOR UPDATE,新订单插入user_id=1001会被锁住,直到事务结束 - 解决:缩小锁范围,或改用
READ COMMITTED
SERIALIZABLE(串行化)
- 场景:金融核心账务,绝对一致性要求
- 行为:所有
SELECT自动加共享锁,UPDATE/DELETE加排他锁 - 代价:并发度极低,QPS下降80%以上
- 我们仅在月结报表生成时临时启用
注意:
SELECT ... FOR UPDATE在REPEATABLE READ下会升级为临键锁(Next-Key Lock),即行锁+间隙锁。例如WHERE id > 100 AND id < 200,不仅锁住100-200间的行,还锁住(100,200)这个间隙,阻止新id插入。这是为了解决幻读,但也是死锁高发区。
4.3 字符集与排序规则的隐形战争
字符集问题常在跨库迁移或搜索时爆发。以下是三个致命案例:
案例1:Emoji存储失败
- 现象:用户昵称😊存入后变成
? - 根本原因:建表用
CHARSET=utf8(实际utf8mb3),不支持4字节字符 - 解决:
ALTER TABLE user CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; - 验证:
SHOW CREATE TABLE user确认字符集已更新
案例2:中文搜索不区分大小写
- 现象:
SELECT * FROM user WHERE username = 'ZhangSan'匹配到zhangsan - 原因:
utf8mb4_general_ci排序规则忽略大小写和重音 - 解决:改用
utf8mb4_0900_as_cs(大小写敏感)或utf8mb4_0900_as_cs_ks(大小写+重音敏感) - 注意:
_cs后缀表示case sensitive,_as表示accent sensitive
案例3:ORDER BY中文乱序
- 现象:
SELECT * FROM user ORDER BY username中文按拼音首字母乱序 - 原因:
utf8mb4_0900_ai_ci按Unicode码点排序,非拼音 - 解决:添加拼音列
username_pinyin VARCHAR(100),用pypinyin库预生成,ORDER BY username_pinyin
实操心得:新项目建库时,必须执行
CREATE DATABASE db_name CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;。我们团队的数据库初始化脚本第一行就是这个命令,避免后续所有表继承错误默认值。
4.4 生产环境CRUD黄金守则
基于十年线上经验,我们总结出12条不可妥协的黄金守则:
- 所有SQL必须带注释:
-- [业务场景] [影响范围] [负责人],如-- 【订单超时】关闭30分钟未支付订单 @zhangsan - 禁止在生产库执行无WHERE的UPDATE/DELETE:DBA已配置
sql_safe_updates=ON,违反者连接被拒绝 - SELECT字段必须明确列出:
SELECT *只允许在EXPLAIN和SHOW命令中使用 - IN语句参数不超过1000个:超限拆分为多个批次,避免SQL过长和执行计划异常
- JOIN表数不超过3张:复杂关联拆分为应用层多次查询,用Redis缓存中间结果
- 大表添加索引必须用pt-online-schema-change:避免锁表,我们已封装为
add_index_safe命令 - 所有UPDATE/DELETE必须先EXPLAIN:确认
rows值合理,否则禁止执行 - 时间字段统一用DATETIME:避免TIMESTAMP受时区影响,所有时间存UTC
- NULL值严格管控:字段非空则
NOT NULL DEFAULT '',空值用特定字符串(如'N/A') - 批量INSERT用VALUES多值语法:
INSERT INTO t VALUES (1,'a'),(2,'b')比循环INSERT快10倍 - 慢查询阈值设为100ms:
long_query_time=0.1,所有超时SQL自动记录到slow log - 每月执行
ANALYZE TABLE:更新统计信息,确保优化器选择最优执行计划
最后分享一个血泪技巧:我们所有线上SQL都经过
sql-review工具扫描,它能自动检测12条守则的违反项。某次上线前,工具拦截了一条DELETE FROM log语句,发现它缺少WHERE条件——而开发者声称“测试过没问题”。后来查明,测试库数据量小,删除瞬间完成,掩盖了风险。工具救了我们一次重大事故。
我在实际操作中发现,最可靠的CRUD能力不是背熟语法,而是养成每写一条SQL就问自己三个问题的习惯:它会扫描多少行?它会锁住哪些数据?它在高并发下是否安全?当你把这三个问题变成肌肉记忆,你就真正掌握了和数据库对话的主动权。这个过程没有捷径,只能靠一次次在测试库里执行、观察、修正。就像学开车,教练可以告诉你离合怎么踩,但真正的熟练度,永远来自方向盘后的每一次转弯。