越翻越慢的订单列表
老王的运营后台有个怪现象:订单列表第一页秒开,翻到第 5000 页要 6 秒。同一张表、同一个索引,慢的不是查询本身,是深分页——这篇连同 count 统计一起,把两大查询慢性病的病理和药方讲全。
深分页为什么慢
LIMIT 100000, 10 的真实成本:没有「跳过」这个动作——数据库老老实实扫出前 100010 行,扔掉 10 万行,只留 10 行。走二级索引还要每行回表(第 3 篇),10 万次回表就是 10 万次 B+ 树查找。偏移量越大越慢,线性恶化。
药方一:游标分页(首选)
# 第一页
SELECT id, order_no, amount, create_time FROM orders
WHERE user_id = 9527
ORDER BY id DESC LIMIT 10;
# 下一页:带上上一页最后一行的 id
SELECT id, order_no, amount, create_time FROM orders
WHERE user_id = 9527 AND id < 987654
ORDER BY id DESC LIMIT 10;把「跳过 10 万行」变成「从 id=987654 位置直接开始」——B+ 树一次定位,成本恒定,翻多深都一样快。代价是只支持连续翻页,不能直接跳第 5000 页。移动端无限下拉、管理端「上一页/下一页」,游标分页都是最优解。
药方二:延迟关联
SELECT o.id, o.order_no, o.amount, o.create_time
FROM orders o
JOIN (SELECT id FROM orders
WHERE user_id = 9527
ORDER BY id DESC LIMIT 100000, 10) tmp ON o.id = tmp.id;产品坚持要页码跳转时的救命方案。核心思路:子查询只让索引干活——select id 恰好是二级索引自带的列,覆盖索引扫描完 100010 个 id 也不回表;外层只对 10 个最终 id 回表取整行。10 万次回表变 10 次,量级差异。
count(*) 的真实成本
带条件的 count 无法回避——要数就得扫(或扫索引),这是 MVCC 的代价:每个事务能看见的行都不一样,InnoDB 存不了全局统一的行数(MyISAM 那个免费的 count(*) 是没有并发版本概念的特权)。几个辨析:
count(*)、count(1)、count(id):性能基本等价,count(*) 是官方推荐的写法,别再纠结;count(col):语义不同——不统计 col 为 NULL 的行,需要遍历取出值判断,多数场景反而更慢;- 给 count 配一个细窄的二级索引(EXPLAIN 里 key 选最小的那棵树)是免费的优化;
- EXPLAIN rows 是估算值,误差可达几十倍,适合监控报警,不适合精确展示。
大表计数方案选型
| 方案 | 做法 | 适用 |
|---|---|---|
| 直接 count | 带窄索引扫 | 百万级以内,低频统计 |
| 估算值 | EXPLAIN rows / information_schema 统计 | 大盘展示「约 xx 条」 |
| 计数表 | 业务写入时同事务增减计数行 | 要求精确且高频读 |
| Redis INCR | 计数放 Redis,定时对账校准 | 超高频读、容忍秒级偏差(呼应 Redis 系列的 Write Behind) |
老王的订单列表最终落地方案:移动端游标分页,管理端延迟关联 + 页码限制(超过 1000 页引导改用筛选条件),总数用计数表。三件套下去,第 5000 页和第一页一样快。
读的病治完,下一篇治「改」的病:千万级大表加个字段,为什么会把线上卡到报警?Online DDL 与 gh-ost 登场。
咖啡凉了,记得趁热喝。
评论 (0)