1. 这4个SQL查询,为什么是数据从业者真正用得上的“中间态”硬功夫?
你有没有过这种经历:刚学完SELECT、WHERE、GROUP BY,信心满满去处理业务数据,结果发现——
- 产品经理突然要你“实时拦截所有年龄小于18岁的注册请求,并返回明确提示”,你翻遍文档才想起触发器这回事;
- 运营同事甩来一张Excel,说“把每个城市的销售额排个名,但别聚合,我要看到每条订单的排名”,你下意识写GROUP BY,结果只返回20行,而原始数据有5万条;
- 需求评审会上,后端同事问“这个分页接口,第100页怎么查?LIMIT 990,10 能扛住吗?”,你卡壳了,因为从没想过LIMIT的偏移量会随页码指数级增长;
- 清洗用户地址字段时,发现location、addr、address_three列全有NULL,你写了3个CASE WHEN嵌套,而隔壁组用一行COALESCE就搞定了。
这些不是“高级技巧”,而是每天在真实数据流水线上卡住你推进节奏的“中间态”问题——它不上不下:基础查询解决不了,窗口函数、CTE、递归查询又太重。它们恰恰是数据工程师写ETL脚本、分析师做临时取数、BI工程师调通看板SQL时,最常撞墙、也最该一击破防的4个节点。
我带过7个数据团队,面试过200+候选人,发现一个铁律:能流畅写出这4类查询的人,上手新业务系统平均快3天,SQL Review一次通过率高67%。为什么?因为它们直指数据工作的三个核心矛盾:数据质量与业务规则的实时耦合(TRIGGER)、聚合逻辑与明细保留的不可兼得(PARTITION BY)、结果集规模与响应效率的天然冲突(LIMIT)、缺失值语义与业务可读性的转换成本(COALESCE)。
这不是教科书里的语法罗列。接下来我会用真实生产环境的代码、踩过的坑、数据库执行计划截图(文字描述版)、以及不同数据库的兼容性陷阱,带你把这4个查询从“知道”变成“肌肉记忆”。你不需要记住所有语法,但必须理解:什么时候该用它,为什么不用别的方案,以及出错了怎么三秒定位。
2. 核心设计思路:为什么是这4个,而不是其他?
2.1 选型逻辑:拒绝“炫技”,只留“刚需”
很多教程讲“中间SQL”会塞进ROW_NUMBER()、LAG/LEAD、CTE递归,甚至JSON函数。我删掉了——不是它们不重要,而是在日均处理百万级数据的真实场景中,这4个查询的“问题出现频率×单次修复耗时×影响范围”加权值,稳居前四。我们用一个简单公式衡量:
技术价值 = (业务方投诉次数 / 月) × (平均排查+修复耗时) × (阻塞下游任务数)
根据我们团队过去18个月的工单统计:
- TRIGGER类问题:月均12次投诉,平均修复47分钟,阻塞3.2个下游任务(主要是风控、合规、报表);
- PARTITION BY误用:月均29次(最高频!),平均修复22分钟,阻塞1.8个任务(主要是运营活动AB测试、用户分群);
- LIMIT分页性能崩坏:月均8次,平均修复63分钟,阻塞4.5个任务(全部是前端接口超时,直接影响DAU);
- COALESCE缺失值处理错误:月均37次(最多!),但平均修复仅9分钟,阻塞0.7个任务(多为内部看板显示异常)。
你看,COALESCE虽然频次最高,但单次成本低;而LIMIT看似简单,一旦出错就是线上事故。所以这4个,是平衡了高频性、高危性和高学习性价比的黄金组合。下面逐个拆解为什么不能用替代方案。
2.2 TRIGGER:为什么不用应用层校验?
有人会说:“年龄校验放Java/Python里做不就行了?更灵活。” 确实,但这是典型的事后诸葛亮。我们曾把所有业务规则移到应用层,结果遇到两个血泪教训:
- 分布式事务一致性灾难:用户注册(微服务A)和创建默认配置(微服务B)是两个独立事务。当A校验通过后,B因网络抖动失败,用户状态已写入DB但配置为空。此时触发器在INSERT瞬间拦截,整个事务回滚,状态绝对一致。
- 多入口数据污染:除了APP注册,还有CRM后台批量导入、客服系统手工补录、第三方数据同步等11个入口。如果只在主APP做校验,其他入口的数据直接绕过,导致DB里混入大量脏数据。触发器是DB层面的“最后防线”,对所有入口一视同仁。
提示:触发器不是万能的。我们明确规定——只用于强业务规则(如年龄、金额正负、状态机流转),绝不用于发邮件、调外部API等耗时操作。后者交给消息队列异步处理。
2.3 PARTITION BY:为什么GROUP BY不是万能解?
GROUP BY和PARTITION BY的根本差异,在于数据粒度是否被破坏。举个血淋淋的例子:某次大促,运营要“查看每个省份的GMV,同时保留每个订单的详细信息(商品ID、数量、优惠券)”。
如果用GROUP BY:
SELECT province, SUM(gmv) as total_gmv FROM orders GROUP BY province;你得到的是34行(中国34个省级行政区),但丢失了所有订单细节——无法知道江苏的1.2亿GMV里,有多少来自iPhone,多少来自优惠券核销。
而PARTITION BY:
SELECT *, SUM(gmv) OVER (PARTITION BY province) as province_total_gmv, RANK() OVER (PARTITION BY province ORDER BY gmv DESC) as order_rank_in_province FROM orders;你得到的是原始订单行数(比如50万行),每行都新增了两列:所在省份的总GMV、以及该订单在本省的销售额排名。这才是运营真正要的“既见森林,又见树木”。
注意:PARTITION BY的开销比GROUP BY大得多。它需要对全表排序(或哈希分组),内存占用高。我们生产库规定——单次查询PARTITION BY的分区键基数(如province有34个值)超过1000,必须走物化视图预计算,否则DBA有权Kill Query。
2.4 LIMIT:为什么OFFSET分页在大数据量下是定时炸弹?
LIMIT 1000, 10看似无害,但它在MySQL/PostgreSQL中的执行逻辑是:先扫描前1000行,丢弃,再取后10行。当你要查第10万页(LIMIT 999990, 10)时,数据库必须顺序扫描近百万行。我们线上一个订单表(1.2亿行),LIMIT 999990, 10平均耗时8.2秒,峰值达23秒,直接拖垮整个DB连接池。
替代方案不是不用LIMIT,而是用游标分页(Cursor-based Pagination):
- 不基于行号,而基于上一页最后一条记录的唯一有序字段(如
created_at,order_id); - 查询条件改为
WHERE created_at < '2023-10-01 12:00:00' ORDER BY created_at DESC LIMIT 10; - 下一页的游标,就是本次结果集第一条记录的
created_at值。
这样每次查询都是索引范围扫描,耗时稳定在20ms内。但前提是——你必须说服产品接受“不能跳转到任意页”,只能“下一页/上一页”。这是技术向业务妥协的经典案例。
2.5 COALESCE:为什么CASE WHEN不是最优解?
COALESCE(col, 'N/A')和CASE WHEN col IS NULL THEN 'N/A' ELSE col END功能等价,但差异在可维护性和扩展性:
- 链式兜底:当需要多级fallback时,COALESCE写法清爽:
COALESCE(phone_work, phone_home, phone_mobile, 'No Contact')
而CASE WHEN要嵌套三层,可读性暴跌; - 类型安全:COALESCE要求所有参数类型兼容,数据库会自动隐式转换(如字符串和数字),而CASE WHEN各分支类型不一致会报错,需手动CAST;
- NULL传播:COALESCE是短路求值,遇到第一个非NULL就返回,后续参数不计算。这对含函数的参数很关键,比如
COALESCE(some_expensive_function(), 'default'),如果函数结果非NULL,就不会执行函数。
我们团队规范:只要涉及NULL替换,无条件用COALESCE。只有当需要复杂条件判断(如col > 100 THEN 'High')时,才用CASE WHEN。
3. 实操详解:4个查询的生产级写法与避坑指南
3.1 TRIGGER:不只是拦截,更是数据守门员
3.1.1 完整生产环境代码(MySQL 8.0+)
-- 步骤1:先删除可能存在的同名触发器(避免重复创建报错) DROP TRIGGER IF EXISTS tr_voter_age_check; -- 步骤2:设置分隔符,避免END;被误认为SQL结束 DELIMITER $$ -- 步骤3:创建触发器 CREATE TRIGGER tr_voter_age_check BEFORE INSERT ON voter -- 在插入voter表前触发 FOR EACH ROW -- 对每一行新数据执行 BEGIN -- 关键点1:使用NEW关键字引用即将插入的行 -- 关键点2:严格校验,避免空值干扰 IF NEW.age IS NULL OR NEW.age < 18 OR NEW.age > 150 THEN -- 关键点3:使用标准SQLSTATE码,便于应用层统一捕获 SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Invalid age: must be between 18 and 150'; END IF; -- 关键点4:业务增强——自动填充注册时间戳(即使应用层没传) IF NEW.created_at IS NULL THEN SET NEW.created_at = NOW(); END IF; -- 关键点5:数据标准化——强制小写邮箱(防止同一用户重复注册) IF NEW.email IS NOT NULL THEN SET NEW.email = LOWER(TRIM(NEW.email)); END IF; END$$ -- 步骤4:恢复分隔符 DELIMITER ;3.1.2 为什么这样写?逐行原理剖析
BEFORE INSERTvsAFTER INSERT:BEFORE允许你修改NEW行的数据(如自动填充created_at、标准化email),AFTER则只能读取,无法修改。拦截必须用BEFORE,否则数据已落库,再抛错也无法回滚。NEW.age IS NULL OR NEW.age < 18 OR NEW.age > 150:
为什么加>150?真实业务中,我们收到过age=999的脏数据(前端JS校验被绕过)。业务规则必须比前端更严苛。150是人类寿命上限,合理且易记。SIGNAL SQLSTATE '45000':'45000'是通用未定义异常码。不要用自定义码(如'99999'),因为应用框架(Spring Boot, Django)的异常处理器可能不认识,导致500错误而非400。统一用45000,后端统一捕获SQLState [45000]即可。SET NEW.email = LOWER(TRIM(NEW.email)):
这是触发器的隐藏价值——数据清洗。很多团队把清洗逻辑放在ETL或应用层,但触发器能保证:无论数据从哪个入口进来(API、后台、脚本),入库前都经过同一套清洗。
3.1.3 生产环境必踩的3个坑(附解决方案)
| 坑位 | 现象 | 根本原因 | 解决方案 |
|---|---|---|---|
| 循环触发 | 插入voter表时,触发器内部又INSERT另一张表,导致无限递归 | MySQL默认允许触发器内DML操作 | 在触发器开头加IF NOT EXISTS (SELECT 1 FROM information_schema.triggers WHERE trigger_name = 'tr_voter_age_check' AND event_object_table = 'voter') THEN ... END IF;或更简单:禁用递归SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION',''));(不推荐,改全局配置风险大)→最佳实践:触发器内只做校验和字段赋值,DML操作全交由应用层或存储过程 |
| 事务回滚不彻底 | 触发器抛错后,部分关联表数据已写入,主表回滚但关联表残留 | 触发器在主表事务内,但关联表操作若在不同连接,则不在同一事务 | 绝对禁止在触发器内执行INSERT/UPDATE/DELETE其他表!所有跨表操作,用消息队列异步完成。 |
| 性能雪崩 | 单次INSERT变慢10倍,监控显示CPU飙升 | 触发器内调用了复杂函数(如正则匹配、HTTP请求) | 触发器内严禁任何I/O操作!只允许: - 字段赋值( SET NEW.col = value)- 简单计算( NEW.total = NEW.qty * NEW.price)- 内置函数( NOW(),LOWER(),TRIM())复杂逻辑,必须前置到应用层。 |
实操心得:我们给所有触发器加了监控埋点。在触发器开头写
INSERT INTO trigger_log (trigger_name, table_name, exec_time) VALUES ('tr_voter_age_check', 'voter', NOW());,并设置告警——单日触发次数突增300%,立即排查是否前端校验失效或爬虫攻击。
3.2 PARTITION BY:窗口函数的正确打开方式
3.2.1 从需求倒推:如何写出不翻车的PARTITION BY
假设需求是:“统计每个销售员的业绩,并标记其在本部门的排名,同时显示部门平均业绩”。
错误写法(新手常犯):
-- ❌ 错!GROUP BY破坏了明细 SELECT salesperson, department, SUM(sales) as dept_total, AVG(sales) as dept_avg -- 这里AVG是整个部门的,但salesperson字段是分组后的,逻辑混乱 FROM sales GROUP BY salesperson, department;正确写法(用PARTITION BY):
SELECT salesperson, department, sales, -- 关键:SUM() OVER() 计算部门总业绩,不改变行数 SUM(sales) OVER (PARTITION BY department) as dept_total, -- 关键:AVG() OVER() 计算部门平均业绩 ROUND(AVG(sales) OVER (PARTITION BY department), 2) as dept_avg, -- 关键:RANK() 给销售员在部门内排名(相同业绩并列,跳过后续名次) RANK() OVER (PARTITION BY department ORDER BY sales DESC) as dept_rank, -- 关键:DENSE_RANK() 同样并列,但不跳过名次(更符合业务习惯) DENSE_RANK() OVER (PARTITION BY department ORDER BY sales DESC) as dept_dense_rank, -- 关键:ROW_NUMBER() 强制唯一排名(相同业绩按ID排序) ROW_NUMBER() OVER (PARTITION BY department ORDER BY sales DESC, id ASC) as dept_row_num FROM sales;3.2.2 深度解析:PARTITION BY的执行引擎内幕
很多人以为PARTITION BY只是“分组后计算”,其实数据库执行器做了三件事:
物理分组(Sort or Hash):
- 如果
PARTITION BY字段有索引(如department),执行器用索引范围扫描,按索引顺序读取数据,天然分组; - 如果无索引,执行器用哈希分组:为每个
department值计算哈希值,放入内存哈希桶。内存不足时,溢出到磁盘临时文件。
- 如果
窗口帧定义(Window Frame):
默认ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,即“从分区第一行到当前行”。
例如计算移动平均:AVG(sales) OVER (PARTITION BY department ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW),表示“当前月及前两个月的平均”。结果合并:
将计算出的窗口函数结果,按原表行序合并回结果集。这是PARTITION BY保持行数不变的核心机制。
提示:用
EXPLAIN FORMAT=TREE看执行计划(MySQL 8.0+)。如果看到Window aggregate with buffering,说明用了磁盘缓冲,性能堪忧,必须加索引。
3.2.3 兼容性陷阱:不同数据库的写法差异
| 数据库 | PARTITION BY支持 | 替代方案(不支持时) | 注意事项 |
|---|---|---|---|
| MySQL 8.0+ | ✅ 原生支持 | — | 无 |
| MySQL 5.7- | ❌ 不支持 | 用自连接或变量模拟 | 性能极差,10万行以上基本不可用。强烈建议升级。 |
| PostgreSQL | ✅ 原生支持 | — | 支持更丰富的窗口函数(如FIRST_VALUE,LAST_VALUE) |
| SQL Server | ✅ 原生支持(2005+) | — | OVER()语法完全一致 |
| Oracle | ✅ 原生支持(8i+) | — | 语法一致,但旧版本(<12c)不支持FETCH FIRST分页 |
| SQLite | ✅ 3.25.0+支持 | 用子查询+GROUP BY模拟 | 3.25.0是重大更新,务必确认版本 |
实操心得:我们团队的SQL Linter强制检查——所有含
OVER()的查询,必须在PARTITION BY后紧跟ORDER BY(除非明确不需要排序)。因为ORDER BY决定窗口帧的顺序,没有它,ROW_NUMBER()等函数结果不可预测。
3.3 LIMIT:分页的生死线
3.3.1 两种LIMIT模式的底层执行差异
模式1:LIMIT N(取前N行)
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;- 执行器:先按
created_at倒序排序,再取前20行。 - 优化点:如果
created_at有索引,直接索引逆序扫描,无需排序,毫秒级。
模式2:LIMIT M, N(跳过M行,取N行)
SELECT * FROM orders ORDER BY created_at DESC LIMIT 1000, 20;- 执行器:先按
created_at倒序排序,再顺序扫描前1000行(丢弃),再取后20行。 - 致命伤:
M越大,扫描行数越多,I/O和CPU消耗指数级增长。
3.3.2 生产环境分页方案对比表
| 方案 | 适用场景 | 优点 | 缺点 | 我们的选择 |
|---|---|---|---|---|
OFFSET分页(LIMIT M,N) | 小数据量(<1万行)、管理后台 | 简单,前端实现容易 | 大数据量性能崩坏,无法跳转 | ❌ 禁用(除管理后台且数据量<1000行) |
游标分页(WHERE cursor_col < last_value) | 高并发列表(APP首页、订单列表) | 性能稳定,无深度分页问题 | 无法跳转任意页,需前端配合 | ✅ 主力方案 |
Keyset分页(WHERE (col1,col2) > (val1,val2)) | 需要精确跳转(如“跳到第100页”) | 性能好,支持跳转 | 实现复杂,需复合索引 | ⚠️ 仅用于导出功能 |
| 物化分页表 | 超大数据量(>10亿行)、固定维度分页 | 查询飞快,毫秒级 | 占用额外存储,需定时刷新 | ✅ 用于日志分析场景 |
3.3.3 游标分页实战:从零搭建
步骤1:确定游标字段
必须满足:
- 唯一性:不能重复(如
id、created_at + id); - 有序性:有明确大小关系(如时间、数字ID);
- 高选择性:区分度高(避免用
status,只有0/1两个值)。
我们选created_at(时间精度到毫秒,几乎唯一)+id(绝对唯一)作为复合游标。
步骤2:建索引(性能基石)
-- 必须!按排序字段+游标字段建联合索引 CREATE INDEX idx_orders_created_id ON orders(created_at DESC, id DESC);步骤3:首次查询(第1页)
-- 获取最新20条订单 SELECT id, created_at, amount, status FROM orders ORDER BY created_at DESC, id DESC LIMIT 20;返回结果中,取最后一条记录的created_at和id,作为下一页游标。例如:"2023-10-01 12:00:00.123", 999999。
步骤4:后续查询(第2页起)
-- 关键:用游标值构造WHERE条件 SELECT id, created_at, amount, status FROM orders WHERE (created_at, id) < ('2023-10-01 12:00:00.123', 999999) ORDER BY created_at DESC, id DESC LIMIT 20;注意:(created_at, id) < (...)是元组比较,MySQL/PostgreSQL原生支持。它等价于:created_at < '2023-10-01 12:00:00.123' OR (created_at = '2023-10-01 12:00:00.123' AND id < 999999)。
提示:PostgreSQL用
IS NOT NULL处理NULL,MySQL用<=>安全比较。我们统一用元组比较,兼容性最好。
3.3.4 游标分页的3个致命误区
游标值未URL编码:
created_at含冒号、空格,直接拼在URL里会400。必须encodeURIComponent(),后端再decodeURIComponent()。未处理游标值不存在:
用户篡改URL游标值(如?cursor=9999999999),查询无结果。必须检查COUNT(*) == 0,返回404 Not Found或重定向到首页。未考虑数据实时变更:
分页过程中,新订单插入,导致“重复出现”或“漏掉”订单。解决方案:- 乐观锁:前端记录首次查询时间戳,后续请求带上,后端
WHERE created_at > first_time; - 最终一致性:告知用户“列表可能有延迟”,这是互联网产品的常态。
- 乐观锁:前端记录首次查询时间戳,后续请求带上,后端
3.4 COALESCE:NULL处理的终极武器
3.4.1 COALESCE的完整语法与执行逻辑
COALESCE(value1, value2, ..., valueN)- 执行逻辑:从左到右依次求值,返回第一个非NULL的值;
- 类型转换:所有参数必须兼容。数据库按类型优先级隐式转换(如字符串 > 数字 > 布尔),若无法转换则报错;
- NULL传播:如果所有参数都是NULL,返回NULL。
3.4.2 生产环境高频用例详解
用例1:多字段兜底(地址补全)
SELECT user_id, -- 优先用详细地址,没有则用城市,再没有则用国家,最后兜底'Unknown' COALESCE(address_detail, city, country, 'Unknown') as full_location, -- 电话号码:工作号>家庭号>手机>空字符串(非NULL) COALESCE(phone_work, phone_home, phone_mobile, '') as contact_phone FROM users;用例2:数值计算防NULL(避免整个表达式为NULL)
SELECT order_id, -- 如果discount为NULL,SUM(price)也会为NULL,用COALESCE兜底0 COALESCE(SUM(price), 0) as total_price, COALESCE(discount, 0) as discount_amount, -- 最终金额:避免NULL参与运算 COALESCE(SUM(price), 0) - COALESCE(discount, 0) as final_amount FROM orders GROUP BY order_id;用例3:与CASE WHEN联用(复杂业务逻辑)
SELECT user_id, -- 先用COALESCE处理NULL,再用CASE做业务判断 CASE WHEN COALESCE(age, 0) < 18 THEN 'Minor' WHEN COALESCE(age, 0) BETWEEN 18 AND 65 THEN 'Adult' ELSE 'Senior' END as age_group FROM users;3.4.3 COALESCE vs ISNULL vs NVL:跨数据库兼容性指南
| 函数 | MySQL | PostgreSQL | SQL Server | Oracle | 兼容性 |
|---|---|---|---|---|---|
| COALESCE | ✅ | ✅ | ✅ | ✅ | ✅ 推荐!ANSI标准,全兼容 |
| IFNULL | ✅ | ❌ | ❌ | ❌ | ❌ MySQL专属 |
| ISNULL | ❌ | ❌ | ✅ | ❌ | ❌ SQL Server专属 |
| NVL | ❌ | ❌ | ❌ | ✅ | ❌ Oracle专属 |
提示:我们团队的SQL模板库,所有NULL处理一律用
COALESCE。CI/CD流程中,SQL Linter会扫描IFNULL/ISNULL/NVL,发现即报错。
3.4.4 性能真相:COALESCE真的快吗?
很多人担心COALESCE有函数调用开销。实测结论:
- 在绝大多数场景,COALESCE开销可忽略。它只是简单的值判断,不涉及I/O或复杂计算;
- 真正的性能杀手是NULL本身:当字段为NULL时,索引可能失效(如
WHERE col = 'A',若col有NULL,索引仍有效;但WHERE col IS NULL,某些索引策略下会全表扫描); - 优化重点应是:为常用于WHERE/JOIN的字段建索引,并确保NULL值比例<5%。我们用
SELECT COUNT(*) FILTER (WHERE col IS NULL) / COUNT(*) FROM table;监控NULL率。
4. 常见问题与排查技巧实录
4.1 TRIGGER常见问题速查表
| 问题现象 | 排查命令 | 根本原因 | 解决方案 |
|---|---|---|---|
| 触发器不执行 | SHOW TRIGGERS LIKE 'voter'; | 表名大小写不匹配(Linux下MySQL表名区分大小写) | 确保ON voter中的voter与实际表名完全一致(建议全小写) |
| 触发器报错:Can't update table 'voter' in stored function/trigger | SELECT @@log_bin; | 开启了二进制日志(binlog),而触发器内修改了同一表 | 方案1:关闭binlog(不推荐,影响主从);方案2:用INSERT ... SELECT替代UPDATE;方案3:将逻辑移到应用层 |
| 触发器内NEW字段值异常 | SELECT * FROM information_schema.COLUMNS WHERE TABLE_NAME='voter' AND COLUMN_NAME='age'; | 字段类型不匹配(如age定义为VARCHAR,但传入数字) | 在触发器开头加类型检查:IF NEW.age REGEXP '^[0-9]+$' THEN ... ELSE SIGNAL ... END IF; |
| 触发器执行缓慢 | SELECT * FROM performance_schema.events_statements_history_long WHERE SQL_TEXT LIKE '%tr_voter_age_check%'; | 触发器内调用了未索引的子查询 | 删除子查询,改用JOIN或预计算字段 |
实操心得:我们给所有触发器加了“熔断开关”。在触发器开头:
IF (SELECT COUNT(*) FROM config WHERE key='tr_voter_age_check_enabled' AND value='false') > 0 THEN LEAVE proc_label; -- 直接退出,不执行任何逻辑 END IF;这样线上出问题,DBA一条SQL就能关闭触发器,比停应用快10倍。
4.2 PARTITION BY性能瓶颈诊断
症状:查询执行时间长,EXPLAIN显示Using temporary; Using filesort。
三步诊断法:
- 查分区键基数:
SELECT COUNT(DISTINCT department) as partition_count FROM sales; -- 如果>1000,大概率要优化 - 查内存使用:
SHOW VARIABLES LIKE 'sort_buffer_size'; -- 默认256K,太小 SET SESSION sort_buffer_size = 4*1024*1024; -- 临时调大到4MB - 强制走索引:
SELECT /*+ USE_INDEX(sales, idx_sales_dept) */ * , SUM(sales) OVER (PARTITION BY department) as dept_total FROM sales;
注意:
sort_buffer_size是会话级变量,调大后只影响当前连接,不会拖垮其他查询。
4.3 LIMIT分页慢的根因分析
不是所有LIMIT都慢!用这个SQL快速定位:
-- 查看慢查询日志中,哪些LIMIT查询最耗时 SELECT LEFT(query, 50) as query_snippet, COUNT(*) as exec_count, AVG(query_time) as avg_time, MAX(query_time) as max_time FROM mysql.slow_log WHERE query LIKE '%LIMIT %' GROUP BY LEFT(query, 50) ORDER BY avg_time DESC LIMIT 10;如果发现LIMIT 10000,20高频出现:
- 立即联系产品,问:“这个分页场景,用户真的会翻到10000行之后吗?”
- 99%的情况,答案是“不会”。改成“加载更多”或“搜索过滤”,比优化SQL更治本。
4.4 COALESCE返回NULL的诡异问题
现象:COALESCE(col, 'default')有时返回NULL,不是'default'。
排查清单:
- ✅
col字段是否为NULL?用SELECT col, col IS NULL FROM table LIMIT 10;验证; - ✅
col是否为空字符串''?COALESCE不处理空字符串,只处理NULL。用NULLIF(col, '')先转:COALESCE(NULLIF(col, ''), 'default'); - ✅
col是否为空格字符串' '?用TRIM(col):COALESCE(NULLIF(TRIM(col), ''), 'default'); - ✅
col是否为数字0?COALESCE(0, 'default')返回0,因为0不是NULL。需用CASE WHEN:CASE WHEN col = 0 OR col IS NULL THEN 'default' ELSE col END。
实操心得:我们封装了一个通用函数:
CREATE FUNCTION safe_coalesce(str TEXT, default_val TEXT) RETURNS TEXT DETERMINISTIC BEGIN RETURN COALESCE(NULLIF(TRIM(str), ''), default_val); END;这样
SELECT safe_coalesce(location, 'Missing') FROM users;一行搞定所有NULL/空/空格。
5. 实战总结:我的4条血泪经验
我在数据领域摸爬滚打12年,亲手写过2700+个SQL脚本,被DBA叫去喝茶的次数,比喝咖啡还多。这4个查询,是我用无数个深夜加班换来的认知:
TRIGGER不是银弹,而是最后一道保险丝:
它应该像保险丝一样——平时看不见,但关键时刻熔断,保护整个系统。我们团队规定:触发器代码必须写单元测试(用INSERT触发,SELECT验证结果),上线前跑通所有测试用例。没测试的触发器,DBA有权拒绝发布。PARTITION BY的威力,90%在于你敢不敢用ORDER BY:
很多人怕ORDER BY慢,只写PARTITION BY department。但不排序,ROW_NUMBER()就是随机的。我们的经验:**先加索引,再放心