1. 别把"数据库操作"只当成会写SQL
上周我接到一个线上告警,列表接口的延迟从80毫秒一路涨到20秒,最后数据库连接数被打满,连正常的登录请求都开始超时。查了一圈,问题竟然出在一个看起来再简单不过的操作上——后台任务循环调用了一个分页查询,把一张千万级的订单表来回翻了二十万行。像这样的场景我见过太多次,很多人觉得数据库操作就是会写增删改查,可真到了生产环境,会写SQL和能把数据操作做好,完全不是一回事。
1.1 改一行数据,可能牵连整张表的连锁反应
"数据库操作"这个词听起来很基础,但真正的难点从来不在那几句SQL语法上,而在数据模型设计、访问模式规划、并发与事务控制、容量与性能评估、变更与恢复流程这几个层面。任何一层没想清楚,一条看似简单的SQL都可能引发雪崩。
举个例子,之前有个需求只是要把订单表里超过30分钟未支付的订单状态改成"已取消"。乍一看一条UPDATE就能搞定,但实际上这张表有7200万行,状态字段没有索引,而且同一条SQL在凌晨定时任务里每5分钟全表扫一次。上线后主库负载直接飙升,核心支付接口跟着遭殃。你说这是数据库操作的问题吗?是,但它跟"会不会写UPDATE"一点关系都没有。
问题出在几个被忽略的决策上:字段有没有索引、目标数据量多大、UPDATE会锁多少行、事务要持续多久、有没有和其他任务争抢资源。这些才是数据库操作真正需要花心思的地方。很多人等到线上出事故才回头补课,这是我最不愿意看到的操作方式。
1.2 我见过最贵的教训:表结构先行,代码后行
之前有个项目,团队为了方便把订单明细直接塞进一个JSON大字段,查询的时候用模糊匹配找"里面包含某商品ID"。刚开始每天几百单,一切正常;后来业务涨到每天几万单,这个查询彻底没法用了,一个接口要跑十几秒,最后只能把JSON字段拆成独立的明细表,再做数据迁移和应用改造,前后折腾了一个多月才恢复稳定。
这个教训说明,数据库操作不只是写SQL,更包括一开始怎么定表结构、怎么选字段类型、怎么规划索引。你在表结构阶段偷的懒,后面会用十倍的时间还回来。所以我一直建议,任何项目的数据库操作规范都要从建表那天开始定,而不是等数据量上来了再反过来补。
2. 表结构设计时的关键决策点,每个都影响后续所有操作
这一章我想聊几个每次建表都值得主动问自己的问题。你不需要把设计文档写得天花乱坠,但至少要在字段类型、主键策略、索引规划上想清楚,因为这三个决定了后面所有增删改查操作的体验。
2.1 字段类型选错,后面怎么优化都别扭
先看几个我经常在别人的表里发现问题的字段选型场景:
- 金额字段用FLOAT还是DECIMAL?我见过太多系统用FLOAT存价格,跑一段时间之后出现0.1加0.2不等于0.3的精度误差,对账怎么都对不平。金额应该用DECIMAL,精度可控,别为了省几个字节给自己挖坑。
- 手机号、身份证这类"看着像数字"的字段,如果用BIGINT存,前导0会被吃掉,还有长度溢出的问题。手机号应该用VARCHAR(20),并且加上唯一索引。类似的还有物流单号、外部订单号,保留字符串形态往往更稳。
- 时间字段用DATETIME还是TIMESTAMP?DATETIME范围更大、不受2038年问题影响;TIMESTAMP会受时区影响,如果系统有国际化需求就要注意。很多业务表还会把创建时间、更新时间拆成两个字段,更新时由数据库侧维护,避免应用层传错时间。
字符集和排序规则也容易被忽视。我建议统一用utf8mb4,因为它能存下emoji和一些特殊字符。更重要的是排序规则,比如utf8mb4_general_ci和utf8mb4_0900_ai_ci对大小写和重音的敏感度不一样,会影响查询结果和索引匹配。如果整库不统一,表关联和查询条件里就可能出现隐式转换问题,索引又白建了。
给一个常见字段选型参考表,可以贴在团队Wiki里:
| 字段场景 | 推荐类型 | 原因 |
|---|---|---|
| 金额、价格 | DECIMAL(12,2)起 | 避免浮点误差,保证精度 |
| 手机号、证件号 | VARCHAR(20) | 保留前导0,避免溢出 |
| 状态枚举 | TINYINT 或 VARCHAR加约束 | 空间小或可读性好,但别混用 |
| 创建时间 | DATETIME 或 TIMESTAMP | 注意时区与范围差异 |
| 用户ID、外部单号 | BIGINT 或 VARCHAR | 按是否参与计算和长度决定 |
2.2 主键策略和索引规划要提前想清楚
主键的选择直接影响写入性能。自增主键在InnoDB里插入顺序跟B+树索引的页顺序基本一致,写起来连续,页分裂少。UUID主键因为随机性,插入时会造成大量随机页分裂,写放大明显,表一大就很疼。分布式场景可以用雪花算法生成的BIGINT,既保持递增趋势又不暴露行数。三种方案没有绝对好坏,但建表前必须想清楚:
| 主键方案 | 写入性能 | 适用场景 | 注意点 |
|---|---|---|---|
| 自增BIGINT | 好 | 单库单表常规业务 | 分布式合并容易冲突 |
| 雪花ID | 较好 | 微服务、分布式 | 需要时钟协调 |
| UUID字符串 | 差 | 很少场景 | 页分裂严重,尽量别用 |
索引规划方面,我的建议是"按查询设计,而不是顺手乱建"。每个索引都有代价:占用磁盘空间,写入时需要维护,数据变更时索引更新也有开销。理想状态是让一条查询可以用一个索引覆盖主要的过滤和排序字段,但组合索引的列顺序有讲究——通常把等值查询的列放在前面,把范围查询的列放在后面。比如查询条件是 user_id = ? AND status = ? AND create_time >= ?,索引(user_id, status, create_time)就比(create_time, status, user_id)合理。
还有一个非常常见的"我以为":给WHERE条件涉及的每个字段各自建一个单列索引,以为所有查询都被覆盖了。实际上优化器通常只能选一个索引,真正高效的往往是组合索引,而不是多个单列索引堆在一起。这话听起来简单,但我见过太多表上堆了四五个单列索引,全是业务发展过程中顺手加的,优化效果没多少,写性能倒是被拖累了。
3. 读操作优化:让一条查询从100ms降到5ms的完整路径
读操作是数据库操作里最日常的一类,但很多慢查询并不是因为数据库不行,而是因为SQL写法绕过了索引、执行计划选错了路径。这一章讲一条查询在数据库里的完整经历,以及怎么用EXPLAIN找出瓶颈。
3.1 查询在数据库里到底经历了什么
一条SELECT语句在MySQL里会经过连接器、分析器、优化器、执行器,最后才在存储引擎里扫描数据。很多人把精力全花在改SQL写法上,却忽略了连接层和优化器的行为。比如连接池配置太小、每次新建连接都要重新鉴权,SQL再优化也扛不住高并发。
为什么我推荐别用SELECT *?因为普通二级索引只保存索引字段和主键值,想取其他列必须回到主键索引再查一次,这个过程叫回表。如果查询只需要几个字段,把字段写进SELECT并设计一个覆盖索引,查询结果从索引页就能直接拿到,可以省掉大量回表操作。在高频接口里,这个收益极其明显。
3.2 EXPLAIN是排查慢查询的第一工具
拿到慢SQL,先不要猜,打开EXPLAIN看执行计划。我通常重点看四列:
- type:从好到坏大致是const、eq_ref、ref、range、index、ALL。出现ALL基本就是全表扫描,要警惕。
- key:实际用到哪个索引。如果为空,说明没走任何索引。
- rows:优化器估算的扫描行数。不需要追求绝对精确,看趋势更重要。
- Extra:出现Using filesort说明排序要另开临时内存或临时文件;出现Using temporary说明要建临时表;出现Using index说明是覆盖索引,这是好信号。
举个例子,一条查询是按用户查最近订单:
SELECT o.order_id, o.amount FROM orders o WHERE o.user_id = 123 ORDER BY o.created_at DESC LIMIT 10;如果没有合适的复合索引,执行计划里大概率出现Using filesort。加上(user_id, created_at)这样一个组合索引之后,ORDER BY可以顺着索引直接取,Extra里不再有filesort,查询速度通常会有数量级提升。这个优化操作很小,但前提是你知道去看执行计划。
3.3 一个隐式类型转换的现场
有次排查慢查询,SQL长这样:
SELECT * FROM users WHERE mobile = 13800138000;users表明明有mobile索引,执行计划的rows却显示全表扫描。后来把mobile字段类型一看,是VARCHAR(20),而查询条件里那个数字是整数。MySQL会把字符串字段隐式转换为数字再比较,索引就失效了。改成 mobile = '13800138000' 之后,扫描行数立刻从几十万变成1。
关键点:查询参数类型必须和字段类型一致。这是所有数据库操作里最隐蔽也最常见的索引失效原因,一定要在代码评审时盯着。
类似的还有函数包裹字段。WHERE DATE(create_time) = '2024-01-01' 看着方便,但对create_time字段做了函数计算后,索引用不上了。正确写法是:
SELECT * FROM orders WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00';这样既准确匹配当天数据,又能走范围索引。
3.4 深分页为什么越翻越慢
另一个读操作的经典坑是 LIMIT 100000, 20。很多人一开始不理解:不就取20条吗,为什么这么慢?因为MySQL得先扫描出前100020行,再把前100000行扔掉。数据量越大越慢,甚至能把数据库拖垮。
常见替代方案有三种:
- 如果是InnoDB自增主键,且业务可以接受"下一页"模式,就改成 WHERE id > 上次最大id ORDER BY id LIMIT 20。这适合下拉加载更多场景。
- 如果不能改业务,用延迟关联:先查出这次需要的20个主键ID,再拿ID去关联原表取完整行。扫描的数据量小很多。
- 如果业务上必须跳页,那可能需要引入缓存或搜索引擎,数据库本身不太适合深翻页这种操作。
4. 写操作没那么轻松:事务、锁和并发
读操作慢还好排查,写操作的问题往往更隐蔽,因为它牵扯到事务边界、锁等待、死锁和隔离级别。这一章挑几个我实际踩过的点展开。
4.1 事务边界划错,比写错SQL更可怕
事务的本质是保证一组操作要么全部成功、要么全部回滚。但很多人忽略了一个隐含条件:事务占用连接和锁的时间越短越好。我见过有人在一个事务里先更新订单,然后调远程接口发短信,再更新积分。结果远程接口超时3秒,事务一直没提交,相关行锁一直被占住,其他请求全部堵在后面,这就是"长事务"的危害。
更隐蔽的是大事务。一次UPDATE 200万行,InnoDB为了支持回滚要生成大量undo日志,同时锁很多行,期间还会阻塞purge线程清理历史版本,磁盘空间和CPU都可能异常。这类操作的正确姿势是拆成小批量,比如按主键范围每次更新1万行,每批提交一次,停顿几秒再继续下一批。这样单次锁的范围小,也不会长时间堵住其他操作。
4.2 锁等待和死锁:两个事务怎么互相卡住的
并发写的时候,锁等待很正常,死锁就比较麻烦。拿InnoDB举例,假设两个事务同时执行:
事务A先更新user_id=1,再更新user_id=2;事务B先更新user_id=2,再更新user_id=1。两条事务都先把第一行锁住,再去等对方的第二行,就会死锁。InnoDB检测到死锁后会回滚其中一个事务,应用层会收到死锁报错。
所以写代码的时候要约定一致的加锁顺序,比如都按user_id从小到大更新,死锁概率会大幅下降。遇到死锁也最好不要简单重试一次就完事,先查SHOW ENGINE INNODB STATUS,看是不是程序里有多个互相矛盾的更新顺序。
4.3 隔离级别和更新丢失,不是理论课
我经常被问:为什么两个不同进程同时给同一行加积分,最后只加了一次?这跟事务隔离级别有关。MySQL默认的可重复读隔离级别下,快照读不会看到其他事务未提交的内容,但两个事务如果都先SELECT读出来,再在应用层算好新值后UPDATE,后提交的会把先提交的覆盖,这就是更新丢失。
要避免这种问题,有几种做法:
- 用原子操作一步完成,比如 UPDATE ... SET score = score + 1,不需要先查再算。
- 需要先查再写的业务,用SELECT ... FOR UPDATE加行锁,或者用版本号做乐观锁控制。
- 不要靠改隔离级别来解决所有问题。隔离级别本身适用于不同业务,改了它,别的事务行为也会跟着变。
5. 从慢查询到连接池打满:一次生产事故的完整复盘
这一章我想完整还原一次事故的排查链路,而不是直接给结论。因为遇到数据库问题的时候,最难的不是找不到答案,而是没有一个清晰的排查顺序。
5.1 现象:接口超时和数据库会话堆积
那天收到告警,某个订单列表接口的P99从80ms涨到3秒,紧接着大量请求直接失败。登录数据库执行SHOW FULL PROCESSLIST,发现同一时间有上百个会话都在执行一条结构长得一模一样的SQL,time字段从几十秒到几百秒不等,state是Sending data。数据库CPU其实没打满,但连接数已经涨到阈值,新请求连获取数据库连接都做不到。
这种时候第一反应永远是先保护数据库,而不是继续对着慢SQL发呆。我确认这些慢会话都来自同一个后台任务之后,把这几个会话断开,让连接数降下来,接口先恢复响应。这里要特别注意:直接KILL所有会话可能导致前端重试风暴,所以要评估存量请求和重试机制,必要时先在网关或接入层做限流。
5.2 定位:深分页排序击穿了索引
恢复之后我开始查慢SQL日志。那条SQL长这样:
SELECT * FROM order_list WHERE status = 1 AND create_time BETWEEN ? AND ? ORDER BY id DESC LIMIT 200000, 20;单看SQL好像没什么问题,真正的坑是LIMIT 200000, 20。这意味着数据库要扫描并排序至少20万行,然后丢掉前面20万行,只取20行返回。而order_list表已经超过千万行,每次查询都生成一个不小的临时排序结果,加上并发量高,连接很快就被这类查询占满了。
执行计划也证实了这一点:虽然走了create_time范围索引,但Extra里出现了Using index condition和Using filesort,本质上还是把所有命中的行找出来做了排序,再完成深分页截断。
5.3 修复:从SQL改成游标式翻页
修复过程分几步:
- 第一步是应急,把接口的翻页逻辑改成"下一页按 id > last_id 查询"。配合业务参数先定位上次最大ID,然后LIMIT 20取下一页。同样一次返回20条,但每页只扫一个小范围,不再扫描20万行。
- 第二步是治理,给后台任务和运营查询提供单独的只读从库,避免突发的大查询直接打到主库连接池。
- 第三步是配置慢查询监控,超过500ms的SQL自动进慢日志平台,每天扫一遍,不允许出现"上线后才发现"的情况。
这次事故给我的最大感受是:数据库操作里,SQL写法只是最后一公里,前面的数据量评估、连接数规划、并发模型、监控告警缺一不可。很多事故不是突然发生的,是慢慢积累到临界点才爆发的。
6. 数据量增长后的几个实操心法
当表从几十万行涨到几千万行之后,同样的SQL会突然变得"不稳"。不是数据库变了,而是操作方式必须跟着数据规模调整。
6.1 批量操作要拆批,不要一把梭
数据量上来之后,几乎所有写操作都要拆批。无论是UPDATE还是DELETE,我建议单批控制在1万行以内,每批提交后停顿一段时间,观察锁等待和主从延迟。这个停顿时间不是随意定的,可以看从库Seconds_Behind_Master之类的监控指标,如果延迟还在涨,就再降低批次大小。
批处理的安全写法通常是按主键范围循环。比如清理一年前的日志:
SELECT id FROM log_table WHERE create_time < '2023-01-01' ORDER BY id LIMIT 500;每轮拿到这500个ID,再执行DELETE FROM log_table WHERE id IN (...),重复处理直到没有剩余数据。为什么不用一条DELETE带全量条件?因为一条语句会锁大量行、产生超大事务,还容易在从库上复制太久。拆批之后每一条都很快,万一中途出错,恢复范围也可控。
6.2 该归档就归档,别让历史数据拖垮在线业务
很多业务表的问题是"什么都想留"。几年前的流水确实有分析价值,但不一定需要永远待在一张在线业务表里。当活跃数据只占全表很小比例时,索引效果和缓存命中率都会变差,一次范围查询可能扫过大量历史区块。
归档方案可以考虑按时间分区,比如按月建分区,老月份数据通过分离分区或者迁移到冷表来做。如果不想大改表结构,就在应用层做数据搬迁,每天把超过N天的数据复制到archive表并删除原表记录,同样遵循拆批原则。归档完成后,记得重新统计表信息,否则优化器可能还按照旧的数据分布估算执行计划。
6.3 读写分离和缓存不是万能药
读写分离看起来能解决主库压力,但最常见的问题是主从延迟。刚写入的数据,马上从从库读,可能读不到,客服那边立刻来投诉"为什么我提交的订单不见了"。如果业务对读已提交有强诉求,要么强制走主库,要么接受短暂延迟,或者用中间件做同一用户路由。
缓存更不是遮羞布。缓存只能缓解"读热点",不能解决"写热点"。如果一个接口的数据库操作本身设计得很烂,比如深分页、隐式转换、大事务,加缓存只是把问题往后移,甚至会让数据一致性更麻烦。我比较喜欢的顺序是:先把SQL和索引做到合理,再考虑缓存。
7. 这些数据库操作习惯,让我少熬了很多夜
最后分享一些我给自己定的操作习惯。这些不是团队文档里的标准流程,而是踩过坑之后形成的条件反射。
7.1 操作生产数据前,先过一遍回滚问题
我现在给自己的规矩是:任何对生产数据的变更,都要先回答三个问题——能不能回滚、怎么判断成功、失败了影响面多大。UPDATE之前先SELECT核对目标集合,把影响行数和主键范围记录到操作单里;UPDATE之后再用对比查询验证结果,而不是只信Rows matched。真的出现误操作时,删除的物理行还能从备份恢复,但UPDATE覆盖掉原始值就麻烦得多,所以很多高危场景我会先把目标行的旧值导出存一份。
7.2 日常只做四件事
这些年总结下来,我在团队里反复强调的只有四件事:第一,上线SQL必须过执行计划;第二,所有自动任务必须分批;第三,慢查询监控从第一天就打开;第四,每个季度做一次备份恢复演练。这四件事占不了多少时间,但能把数据库操作的稳定性拉高一个档次。
还有一个小技巧:如果手边没有专用的变更工具,可以把要执行的SQL写成带输出日志的脚本,通过代码评审再执行,事务里包含关键步骤的日志,方便复盘。这套流程看起来很土,但真的救过我很多次。