我们团队之前开发了一个相对简单的 API,用来分页获取用户的交易记录。刚上线运行时一切顺利,查询一般只需要 200ms 就能返回结果。但随着数据量的不断增长,情况开始急转直下——同样的查询竟然要花上 2 到 3 秒。
起初我们以为是系统压力过大,或者遗漏了必要的索引。但在深入排查并查看数据库的执行计划后,才发现问题其实很基础:罪魁祸首就是 OFFSET。
我们当时的查询语句是这样的:
SELECT * FROM transactions
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000;
看上去挺干净利落,对吧?但实际上数据库执行这条 SQL 语句时背后发生的故事是这样的:数据库会先取出 10,020 条记录,然后丢掉前 10,000 条,只返回最后 20 条。也就是说,即使用户只需要 20 条数据,但数据库却要做一大堆无用功。
更糟的是,随着用户不断下拉或翻页,OFFSET 的数值会越来越大,查询性能也会越来越差。
解决方案:Keyset 分页
我们尝试过一些优化方法,但效果都不明显。最后把查询改写成 Keyset 分页,问题才真正得到解决。
改写后的查询语句是这样的:
SELECT * FROM transactions
WHERE user_id = 42
AND created_at
ORDER BY created_at DESC
LIMIT 20;
和之前的 SQL 语句不同,这里我们不是告诉数据库“跳过前 10,000 条数据”,而是明确指定一个时间点:“从这个时间点之后,给我接下来的 20 条记录。”
这种方式被称为 Seek Pagination 或 Keyset Pagination,它的效率要高得多,因为数据库可以直接利用索引快速定位到目标位置,而无需再扫描一大堆无用的数据然后丢掉。
改完之后,API 响应时间从 2.6 秒直接降到了 200ms 以内——没有引入缓存,也没有升级基础设施,仅仅是换了一种更聪明的 SQL 写法。
不过这里我们遇到了一个小坑:有时候多条交易记录的 created_at 时间戳完全相同(特别是在用户批量上传数据时),在这种情况下,如果分页只依赖 created_at,就可能出现 重复数据(上一页的最后一条又出现在下一页)或者 遗漏数据(某些记录被跳过)的情况,导致分页结果不准确。
为了解决这个问题,我们在排序和条件里增加了一个“次级排序字段”——id。改写后的语句如下:
WHERE (created_at, id)
ORDER BY created_at DESC, id DESC
这样一来,即使多条记录的 created_at 相同,id 也能保证它们有稳定的顺序。分页逻辑就不会再出现重复或遗漏了。
其它的一些思路
虽然 Keyset 分页已经解决了我们的大部分问题,但我们也探索过一些其它的替代方案。
基于游标的分页(Cursor-Based Pagination)
严格来说,游标分页本质上就是 Keyset 分页,只是我们在 API 中不会直接传递时间戳或 ID,而是把这些信息封装成一个 游标(cursor),比如:
"next_cursor": "2024-05-01T10:00:00Z_98765"
客户端在请求下一页数据时,只需要带上这个 next_cursor,后端就能精确返回后续的数据。
Instagram、Twitter 等大多数现代 API 的滚动加载机制,基本都是这样实现的。它的优势在于接口更简洁、无状态,前端使用起来也更加自然。
基于索引的 OFFSET(Index-Only OFFSET)
并不是所有场景都适合 Keyset 分页。比如在一些内部工具(如管理后台)里,往往需要支持“跳转到指定页”(第 7 页、第 10 页……),这种情况下 Keyset 分页就不太方便了。
我们的做法是:
在查询涉及的字段上建立覆盖索引;
查询时只选择索引里包含的列。
CREATE INDEX idx_user_created_id_amount
ON transactions(user_id, created_at DESC, id, amount);
这种方式并不能真正让 OFFSET 本身变快,但至少可以减少回表操作,从而提高查询效率。
物化视图(Materialized Views)
在另一个场景里,我们的报表后台需要反复执行同一条查询,用来按天汇总用户交易数据。但因为每次都要扫描和计算大量原始记录,查询速度一直很慢。
为了解决这个问题,我们引入了 物化视图:
CREATE MATERIALIZED VIEW user_summary AS
SELECT user_id, DATE(created_at), SUM(amount)
FROM transactions
GROUP BY user_id, DATE(created_at);
然后通过定时任务(cron)每隔几分钟刷新一次视图。这样一来,报表系统只需要从预先聚合好的数据里读取结果,查询速度瞬间快了许多,同时也大大减轻了对实时表的压力。
最终效果
在不同优化方式下,查询耗时的对比如下:
查询方式
平均响应时间
OFFSET 10000
~2600 ms
OFFSET + 索引优化
~1300 ms
Keyset / Seek 分页
~180 ms
Keyset + 游标方式
~190 ms
物化视图
~50–100 ms
没想到仅仅是换了一种 SQL 写法,就能带来这么大的性能提升,这也说明,很多时候系统的瓶颈并不在硬件,而是在数据访问方式本身。
最后的思考
这次的经历让我们意识到,性能优化并不一定意味着大规模的架构改造,或者花钱去堆硬件资源。
很多时候,仅仅换一种更合理的 SQL 写法,就能带来意想不到的性能提升,既更省时、省钱,也更省心。
所以,如果你的应用还在使用 OFFSET 分页,并且在数据规模增大后出现明显的性能下降,强烈建议切换到 Keyset 分页,这种方式简单、优雅,而且效率极高。
希望这段经验能对后来者有所帮助,少踩我们曾经踩过的坑。
天宸配资,贝盈策略,靠谱的配资提示:文章来自网络,不代表本站观点。