深分页性能优化完全指南:从一次"翻到第 5000 页、查询慢了 100 倍"看懂 OFFSET 陷阱与游标分页

2021 年我做一个后端服务有一大堆列表页要分页订单列表操作日志消息记录。分页这件事我压根没多想。第一版我做得很省事分页不就是 LIMIT 和 OFFSET 前端要第几页我就 OFFSET 页码减一乘每页条数 LIMIT 每页条数跳过前面那些取这一页该取的。本地开发时真不错测试库里就几百条数据我从第一页翻到最后一页每一页都是瞬间出结果顺畅得很。我心里很踏实分页嘛不就是跳过 N 条取 size 条。可等这个服务真正上线表里的数据涨到几百万行一串问题冒了出来。第一种最先把我打懵用户翻到很靠后的页比如第 5000 页这一页查询慢得离谱可前面几页明明快得很同样是取 20 条凭什么靠后的页就慢。第二种最隐蔽用户在一页页往后翻翻页的间隔里有新数据插了进来结果他在第 2 页看到的某条记录翻到第 3 页又冒出来一次还有的记录他整个没看到被跳过了。第三种最拖时间有个定时任务要把整张表导出我用循环 OFFSET 一页页拉跑到后面每一页都慢得要死整个导出耗时长得没法接受。第四种最不起眼却也在拖后腿列表页要显示共多少页我每次翻页都 COUNT 算一遍总数可这个 COUNT 在几百万行的表上本身就是一个慢查询。我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为分页就是跳过前面 N 条取 size 条。这句话把 OFFSET 当成了一个免费的跳过动作。可它不是。OFFSET N 的真相根本不是跳过 N 条而是先把前 N 条一行行全部读出来再把它们整整齐齐地丢掉。数据库没有任何办法凭空闪现到第 N 加一条它必须从头开始数数够 N 行把这 N 行全部丢弃才开始取你真正要的那 size 行。所以 OFFSET N 的代价和 N 成正比翻第 1 页几乎不花钱翻第 5000 页数据库要先白白扫过再丢弃整整 10 万行只为给你最后那 20 行。OFFSET 分页不是慢是越往后越慢它的代价随页深线性增长。而且第几页这个模型本身是基于位置的位置会随数据增删而漂移这就是翻页时记录会重复会遗漏的根源。真正可扩展可靠的分页该用某条记录之后的下一批这种基于锚点的模型这就是游标分页。本文从头梳理为什么 LIMIT OFFSET 翻页是错的游标分页怎么按锚点取下一页游标怎么编码排序字段为什么必须唯一总数怎么处理以及索引配合深度导出页大小上限这些把分页真正做扎实要避开的坑。

2021 年我做一个后端服务,有一大堆列表页要分页——订单列表、操作日志、消息记录。分页这件事,我压根没多想。第一版我做得很省事:分页不就是 LIMITOFFSET?前端要第几页,我就 OFFSET (页码-1)*每页条数 LIMIT 每页条数,跳过前面那些、取这一页该取的。本地开发时——真不错:测试库里就几百条数据,我从第一页翻到最后一页,每一页都是瞬间出结果,顺畅得很。我心里很踏实:"分页嘛,不就是跳过 N 条、取 size 条?"可等这个服务真正上线、表里的数据涨到几百万行,一串问题冒了出来。第一种最先把我打懵:用户翻到很靠后的页——比如第 5000 页——这一页查询慢得离谱,可前面几页明明快得很;同样是取 20 条,凭什么靠后的页就慢?第二种最隐蔽:用户在一页页往后翻,翻页的间隔里有新数据插了进来——结果他在第 2 页看到的某条记录,翻到第 3 页又冒出来一次;还有的记录,他整个没看到、被跳过了。第三种最拖时间:有个定时任务要把整张表导出,我用循环 OFFSET 一页页拉,跑到后面每一页都慢得要死,整个导出耗时长得没法接受。第四种最不起眼却也在拖后腿:列表页要显示"共 1234 页",我每次翻页都 SELECT COUNT(*) 算一遍总数——可这个 COUNT几百万行的表上,本身就是一个慢查询。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"分页,就是跳过前面 N 条、取 size 条"。这句话OFFSET 当成了一个免费的"跳过"动作。可它不是OFFSET N 的真相,根本不是"跳过 N 条",而是"先把前 N 条一行行全部读出来,再把它们整整齐齐地丢掉"。数据库没有任何办法"凭空闪现到第 N+1 条"——它必须从头开始数,数够 N 行、把这 N 行全部丢弃,才开始取你真正要的那 size 行。所以 OFFSET N 的代价,和 N 成正比:翻第 1 页几乎不花钱,翻第 5000 页,数据库要先白白扫过、再丢弃整整 10 万行,只为给你最后那 20 行。OFFSET 分页不是"慢",是"越往后越慢"——它的代价随页深线性增长。而且,"第几页"这个模型本身是基于"位置"的,位置会随数据增删而漂移,这就是翻页时记录会重复、会遗漏的根源。真正可扩展、可靠的分页,不该用"第几页"这种基于位置的模型,而该用"某条记录之后的下一批"这种基于锚点的模型——这就是游标分页。真正做好分页,核心不是"LIMITOFFSET",而是看清 OFFSET 的代价随页深增长、改用基于锚点的游标分页、给排序键加唯一决胜字段、配好复合索引、别每页都 COUNT 全表。这篇文章就把分页性能优化梳理一遍:为什么"LIMIT OFFSET 翻页"是错的、游标分页怎么按锚点取下一页、游标怎么编码、排序字段为什么必须唯一、总数怎么处理,以及索引配合、深度导出、页大小上限这些把分页真正做扎实要避开的坑。

问题背景

先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。

现象:一套"LIMITOFFSET"的分页,在数据量涨上来后冒出一串问题:翻到靠后的页查询极慢,前面的页却很快;翻页过程中有数据增删,同一条记录重复出现、或被整个跳过;循环 OFFSET 导出整表,越往后每页越慢,总耗时长得离谱;为显示总页数每次都 COUNT(*),这个 COUNT 自己就是慢查询

我当时的错误认知:"分页,就是跳过前面 N 条、取 size 条。"

真相:这个认知错在它误解了 OFFSET 的工作方式。"跳过 N 条"这个说法,会让人以为数据库能像翻书一样,手指一点就跳到第 N 页。可数据库做不到这件事。一张表里的行,数据库没有办法不读它、就知道它是第几行——"第几行"这个信息不写在行里。所以当你写 OFFSET 100000,数据库唯一能做的,就是老老实实地从满足条件的第一行开始,一行一行地读、一行一行地数,数到第 100000 行,把这前 10 万行原封不动地丢弃,然后才开始取你要的 LIMIT 20。也就是说,翻第 5001 页,数据库实际处理的行数是 100020 行,而不是 20 行。这就解释了"为什么靠后的页慢":页越靠后,被白白读出又丢弃的行越多,代价随页深线性增长。它还解释了第二个问题:"第几页"是一个基于位置的坐标,而位置会随数据增删而漂移——你看第 2 页时它指向某 20 行,等你翻到第 3 页,前面要是插进了新数据,整个序列往后挪了一格,于是有的行被你重复看到、有的行从你眼皮底下溜过去了。一旦你看清 OFFSET 的这两个本质——代价随页深增长、坐标随数据漂移——就会明白:问题不在某个参数,在"用'第几页'来分页"这个模型本身。要换的,是一个不依赖位置、而依赖"锚点"的分页模型

要把分页性能做对,需要几块认知:

  • 为什么"LIMIT OFFSET 翻页"是错的——OFFSET 的代价随页深线性增长;
  • 游标分页——不问"第几页",问"某锚点之后的下一批";
  • 游标编码——把锚点封装成对客户端不透明的 cursor;
  • 排序键唯一性——游标分页要求排序键整体唯一;
  • 总数处理、索引配合、深度导出这些工程坑怎么处理。

一、为什么"LIMIT OFFSET 翻页"是错的

先把这件最根本的事钉死:OFFSET N 这个写法,字面上是"跳过 N 条",可它在数据库里的真实执行,是"读出 N 条,然后丢弃"。这两者天差地别。"跳过"暗示着零成本——仿佛数据库能直接定位到第 N+1 条;"读出再丢弃"则意味着,你为那 N 条根本不要的数据,付了完整的读取代价。数据库为什么不能直接跳?因为一行数据的"序号"不是它的属性——表里没有任何地方记着"这是第 88 行"。行的顺序是 ORDER BY 在查询时临时算出来的,数据库只能顺着这个顺序从头数。于是 OFFSET 分页有一个致命的性质:翻每一页的代价不是恒定的,而是随页码增长的。第 1 页,数据库处理 20 行;第 5000 页,数据库处理 100020 行。同样是给用户看 20 条,后者的工作量是前者的五千倍。这就是为什么 OFFSET 分页在小数据量、浅页码下毫无问题,却会在大数据量、深页码下轰然倒塌——它不是"有点慢",它是"代价随页深线性膨胀"。

下面这条 SQL,就是我那个"翻到深页就慢成蜗牛"的第一版:

-- 反面教材:基于"第几页"的 OFFSET 分页
SELECT id, title, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;   -- 用户翻到了第 5001 页
-- 破绽:OFFSET 100000 不是"跳过",是"读出 10 万行,再全部丢掉"。
-- 数据库被迫从头扫 100020 行排序,只为返回最后那 20 行。
-- 翻得越深,被白扫的行越多 —— 代价和页码成正比。

这条 SQL 在本地开发时表现不错,因为本地测试库里只有几百行数据:你就算翻到"最后一页",OFFSET 也不过几百,数据库扫几百行眨眼就完——OFFSET 那个"读出再丢弃"的代价,在几百行的盘子里小到根本称不出重量。它的问题不在某一行 SQL 上——LIMIT OFFSET 的语法完全正确——而在一个被忽略的前提:它默认"OFFSET 多大,代价都差不多"。可线上恰恰相反:表里几百万行,深页的 OFFSET 动辄十万、百万。于是那串问题就有了解释:深页查询慢,是因为数据库为那一页要先白扫掉前面十万行;循环导出越来越慢,是因为每多翻一页,OFFSET 就更大、白扫的行更多,总耗时是平方级的。问题的根子清楚了:做好分页的工程量,全在"承认 OFFSET 的代价随页深增长、且'第几页'这个坐标会漂移"之后——你用它分页,它就会在数据量涨上来时,让靠后的每一页都越来越慢、还在数据变动时给用户看重影。所以要换一个模型——一个不数位置、只认锚点的模型。

二、游标分页:按"锚点"取下一页

换的这个模型,叫游标分页(也叫 keyset 分页、键集分页)。它的核心,是换一个问法OFFSET 分页问的是:"把结果排好序,给我第 5001 页。"——这是基于位置的。游标分页问的是:"把结果排好序,给我'排在某条记录之后'的下一批 20 条。"——这是基于锚点的。这里的"某条记录",就是上一页最后那一条;它的排序字段的值,就是锚点。关键在于:"排在锚点之后"这个条件,可以直接写成一个 WHERE,而 WHERE 配上索引,数据库能一步跳到锚点的位置,根本不需要从头数:

-- 游标分页:不问"第几页",问"排在这个锚点之后的下一批"
SELECT id, title, created_at
FROM orders
WHERE (created_at, id) < (?, ?)   -- ? 是上一页最后一条的 created_at 与 id
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 关键:WHERE 用锚点直接定位,配合 (created_at, id) 复合索引,
--       数据库一步跳到锚点处,只读这 20 行 —— 和页深完全无关。

注意这条 SQL 里没有 OFFSET,取而代之的是 WHERE (created_at, id) < (?, ?)无论你翻到第几页,这个 WHERE 的形状都一样,数据库的工作量也一样——永远只是"定位到锚点、往后读 20 行"。下面是它在 ORM(以 SQLAlchemy 为例)里的写法:

from sqlalchemy import tuple_

def fetch_page(session, after_created_at=None, after_id=None, size=20):
    """游标分页:给定上一页末尾的锚点,取排在它之后的下一批 size 条。"""
    q = session.query(Order).order_by(Order.created_at.desc(),
                                      Order.id.desc())
    if after_created_at is not None:
        # 复合条件:created_at 更早,或 created_at 相同但 id 更小
        q = q.filter(
            tuple_(Order.created_at, Order.id) < (after_created_at, after_id)
        )
    return q.limit(size).all()
    # 第一页:不带锚点,直接取最新的 size 条;
    # 后续页:带上一页末条的锚点,取它之后的 size 条 —— 永远 O(size)。

下面这张图,把一次翻页请求在游标分页下是怎么被服务的画出来:

这里的认知要点是:OFFSET 分页和游标分页,差别的根子在"用什么来定位下一页"。OFFSET 用的是"序号"——第 5001 页,意味着"第 100001 到 100020 行"。序号这个东西,数据库不能直接用,只能从头数着确认,所以代价随序号增长;而且序号是相对位置,前面一插入数据,所有序号就全部错位。游标用的是"锚点值"——上一页最后那条记录的 created_at 和 id。锚点值这个东西,恰恰是数据库最擅长处理的:它能写进 WHERE,能走索引,数据库一步就能跳到那个值的位置。更妙的是,锚点是一个具体的值,不是一个相对位置——你翻页的间隔里,前面插了多少条新数据,都影响不到"created_at, id 小于某个具体值"这个判断,所以游标分页天然不会重复、不会遗漏。一句话:OFFSET 问"第几个",游标问"哪个之后";前者的代价和数据规模纠缠在一起,后者永远只是 O(size)。但锚点是上一页最后一条的排序值——这个值不能让客户端直接看见、直接拼,得包装一下。

三、游标编码:把锚点封装成不透明的 cursor

锚点是 created_atid 这两个内部字段的值。如果接口直接让客户端传这两个值,有两个坏处:一是暴露了内部实现(客户端从此知道你按这两个字段排序、分页);二是客户端可能自己瞎拼锚点,传进来奇奇怪怪的值。正确的做法是:把锚点编码成一个"不透明的字符串"——一个 cursor。对客户端来说,cursor 就是一个"下一页凭证",它不该去解析、也无从解析;它只负责原样带回来:

import base64, json

def encode_cursor(row):
    """把锚点封装成不透明 cursor —— 客户端只原样回传,不该解析它。"""
    payload = {"created_at": row.created_at.isoformat(), "id": row.id}
    raw = json.dumps(payload).encode("utf-8")
    return base64.urlsafe_b64encode(raw).decode("ascii")


def decode_cursor(cursor):
    """把 cursor 还原成锚点;非法 cursor 直接拒绝,绝不去猜。"""
    try:
        raw = base64.urlsafe_b64decode(cursor.encode("ascii"))
        payload = json.loads(raw)
        return payload["created_at"], payload["id"]
    except Exception:
        raise ValueError("非法的分页游标")

有了编解码,接口层就清爽了:吃进一个 cursor,吐出这一页的数据,外加"下一页的 cursor"。这里有个小技巧——多取一条,用来判断"后面还有没有数据":

@app.get("/orders")
def list_orders(cursor: str = None, size: int = 20):
    """列表接口:吃一个 cursor,吐这一页 + 下一页的 cursor。"""
    after = decode_cursor(cursor) if cursor else (None, None)
    rows = fetch_page(db, after_created_at=after[0],
                      after_id=after[1], size=size + 1)   # 故意多取一条
    has_more = len(rows) > size       # 多出来的那条若存在,说明还有下一页
    rows = rows[:size]                # 真正返回给用户的还是 size 条
    return {
        "items": [serialize(r) for r in rows],
        "next_cursor": encode_cursor(rows[-1]) if has_more and rows else None,
    }
    # next_cursor 为 None,前端就知道"到底了",不必再发翻页请求。

这里的认知要点是:cursor 对客户端必须是"不透明"的——这不只是为了好看,而是一种契约设计。一旦 cursor 是不透明的,客户端就只能做一件事:把它原样存下、原样回传。它不能解析、不能猜测、不能自己拼造。这就给你换来了宝贵的自由:今天你的锚点是 created_at 加 id,明天你想改成别的排序、加别的字段,只要 cursor 的编解码是你自己说了算的,客户端的代码一行都不用动——因为它从来就没依赖过 cursor 的内部结构。反过来,如果你让客户端直接传 created_at 和 id,你就等于把"我用这两个字段分页"这个实现细节焊进了客户端的代码,以后再想动就是一次破坏性变更。还有一点:decode_cursor 收到非法输入时,要明确地拒绝、报错,绝不能"猜一个默认值"糊弄过去——一个被篡改或损坏的 cursor,本就不该返回任何看似正常的结果。不过游标分页能成立,还压着一个容易被忽略的前提:排序键必须唯一。

四、排序键唯一性:游标分页的隐藏前提

游标分页的 WHERE 条件,本质是"排序键 小于 锚点"。这里藏着一个必须满足的前提:排序键的值,必须是整体唯一的。如果你只用一个 created_at 排序、也只用它当锚点,而 created_at 这个字段会重复(同一秒、甚至同一毫秒下了好几个订单,太常见了),那麻烦就来了:WHERE created_at < 锚点 这一刀切下去,那些 created_at 正好等于锚点的记录,会被要么全部漏掉、要么在边界上重复。解法是给排序键追加一个唯一的"决胜字段"——通常就是主键 id:

-- 反面:只用 created_at 排序和做游标 —— 而 created_at 会重复!
SELECT * FROM orders
WHERE created_at < ?          -- 多条记录 created_at 完全相同时,
ORDER BY created_at DESC      -- 这一刀切下去,边界上的记录要么漏、要么重
LIMIT 20;

-- 正解:排序键末尾追加唯一的 id 作"决胜字段"
SELECT * FROM orders
WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC   -- (created_at, id) 整体唯一,游标才稳
LIMIT 20;
-- created_at 相同时,由 id 来决出唯一的先后 —— 不再有边界上的歧义。

这里的认知要点是:游标分页要正确,排序键必须能在所有记录之间排出一个"严格的、无歧义的"顺序——也就是排序键的值整体唯一。道理在于:游标的 WHERE 条件是拿锚点做一刀切割,切割点必须落在两条记录"之间"的一个明确缝隙里。如果排序键会重复,那么在那些值相同的记录中间,就没有"缝隙"——你这一刀要么把它们全切到上一页(下一页漏掉它们),要么处理不当就重复。给排序键追加一个唯一的 id 当决胜字段,就是为这些"撞在一起"的记录之间,人为地凿出一条确定的缝隙:created_at 一样,那就比 id,id 是主键、绝不重复,于是任意两条记录之间,顺序都是唯一确定的。这条规则可以推广:你按什么字段排序做游标,就要确保"那个字段 + 一个唯一字段"的组合整体唯一。这是游标分页能不重不漏的地基,不是可选项。主干到这就齐了,最后是几个真正把分页用到生产里才会撞见的工程坑。

五、工程坑:索引配合、总数处理、深度导出

三块设计之外,还有几个工程坑,不处理就会让你的分页要么快不起来、要么在别处出岔子坑 1:游标分页强依赖一个匹配的复合索引。游标分页快,是因为 WHERE (created_at, id) < (?, ?)走索引一步定位。可要是排序键上压根没有对应的索引,数据库还是只能全表扫描再排序——游标分页就退化得和 OFFSET 一样慢。所以排序键上必须建一个顺序匹配的复合索引:

-- 游标分页的地基:排序键上必须有一个顺序匹配的复合索引
CREATE INDEX idx_orders_created_id ON orders (created_at, id);
-- 索引的列顺序,要和 ORDER BY 的列顺序一致。
-- 有了它,WHERE (created_at, id) 小于锚点 才能"一步跳到锚点处";
-- 没有它,游标分页一样会退化成全表扫描 —— 等于白改。

坑 2:别每次翻页都 COUNT(*) 全表。为了显示"共 N 页"而每次翻页都 COUNT 一遍,这个 COUNT 在大表上本身就是慢查询。务实的处理有三种,看场景选:

# 总数:别每次翻页都 COUNT 全表 —— 三种务实的处理,按场景选

# 方案 A:干脆不显示总页数,只给"下一页" —— 信息流、时间线都这么做
#         游标分页本来就只需要 next_cursor,根本不需要总数。

# 方案 B:用估算值 —— 从库表的统计信息里取近似行数,不精确但极快
def approx_count(session, table):
    row = session.execute(
        "SELECT table_rows FROM information_schema.tables "
        "WHERE table_name = :t", {"t": table}).first()
    return row[0] if row else 0

# 方案 C:要精确总数,就 COUNT 一次后缓存它,设一个较短的 TTL
def cached_count(session, cache):
    n = cache.get("orders:count")
    if n is None:
        n = session.query(Order).count()
        cache.set("orders:count", n, ttl=60)   # 60 秒内复用,不再压数据库
    return n

坑 3:全表导出要用游标遍历,不能用 OFFSET 循环。把整张表导出,如果用 OFFSET 一页页循环拉,每翻一页 OFFSET 就更大,总耗时是平方级。改用游标循环,每一页都是 O(size),整表导出就是线性的:

def export_all(session, size=1000):
    """全表导出:用游标一页页流式遍历,而不是 OFFSET 越翻越慢。"""
    after = (None, None)
    while True:
        rows = fetch_page(session, after_created_at=after[0],
                          after_id=after[1], size=size)
        if not rows:
            break                       # 取空了,遍历结束
        for r in rows:
            yield r
        last = rows[-1]
        after = (last.created_at, last.id)   # 锚点前移,下一轮从这里继续
    # 每页都是 O(size),整表导出是线性的 —— OFFSET 循环版是平方级。

坑 4:页大小必须设上限。接口的 size 参数是客户端传的,要是有人传个 size=1000000,你就一把把百万行全查出来、全序列化——内存、带宽、数据库都得遭殃。size 一定要夹到一个合理上限:size = max(1, min(传入值, 100))坑 5:游标里别塞敏感或可变信息。cursor 虽然对客户端"不透明",但它只是 base64,不是加密——任何人都能解开看。所以 cursor 里只放排序锚点,别放用户 ID、权限之类的东西;而且解出来的锚点要当作不可信输入,该做类型校验就做,别因为它来自"自己发出去的 cursor"就盲目信任。坑 6:游标分页天然适合"无限下拉",不适合"跳到第 100 页"。它的模型是"从这一页到下一页",没有"第几页"的概念。如果产品就是要让用户输入页码直接跳转,游标分页满足不了——这种场景要么接受 OFFSET 的代价(并把可跳页数限制在前若干页),要么用别的方案。选型前先看清:你的列表到底是"一直往下刷",还是"要精确跳页"。

关键概念速查

概念 / 手段 说明
OFFSET 的真相 OFFSET N 不是跳过,是先读出 N 行再全部丢弃
代价随页深增长 OFFSET 分页越往后越慢,代价与页码成正比
位置坐标会漂移 第几页是相对位置,数据增删后会重复或遗漏记录
游标分页 按"排在某锚点之后的下一批"取数,代价与页深无关
锚点 上一页最后一条记录的排序键值,下一页从它之后开始
不透明 cursor 把锚点编码成对客户端不透明的字符串,只原样回传
排序键唯一性 排序键须整体唯一,否则游标会在边界漏取或重取
决胜字段 排序键末尾追加唯一 id,保证组合整体唯一
复合索引 排序键上建顺序匹配的索引,游标才能一步定位锚点
总数处理 别每页 COUNT 全表,可不显示总数或估算或缓存

避坑清单

  1. 别用 LIMIT OFFSET 做深分页,OFFSET 越大越慢,代价随页深线性增长。
  2. 改用游标分页:按"上一页末条之后的下一批"取数,代价与页深无关。
  3. 游标分页的排序键必须整体唯一,否则边界上会漏取或重复取记录。
  4. 单一非唯一字段排序时,末尾追加唯一 id 作决胜字段。
  5. 锚点编码成不透明 cursor,客户端只原样回传,非法 cursor 直接拒绝。
  6. 排序键上必须建顺序匹配的复合索引,否则游标分页退化为全表扫描。
  7. 多取一条来判断"还有没有下一页",不必为此再查一次。
  8. 别每次翻页都 COUNT 全表;可不显示总数,或用估算,或缓存总数。
  9. 全表导出用游标循环遍历,不要用 OFFSET 循环,后者是平方级耗时。
  10. 页大小必须设上限,游标里别放敏感信息,解出的锚点要当不可信输入。

总结

回头看那串"深页查询慢、翻页看到重影、导出慢成平方级、COUNT 拖后腿"的问题,以及我后来在分页上接连踩的坑,最该记住的不是某一种 SQL 的写法,而是我动手前那个想当然的判断——"分页,就是跳过前面 N 条、取 size 条"。这句话错在它OFFSET 想成了一个免费的"跳过"。我以为数据库能像翻书一样,手指一点就跳到第 N 页。可我忽略了一件事:数据库里的行没有"序号"这个属性,"第几行"是排序时临时数出来的所以 OFFSET N 不是"跳过 N 行",而是"从头读出 N 行、再原封不动地丢弃"——你为那 N 行根本不要的数据,付了完整的读取代价。翻得越深,被白读白丢的行越多,代价就随页深一路线性膨胀。而"第几页"这个坐标本身又是相对的:前面一插入数据,所有页码就集体错位,于是翻页时你会看见重影、也会漏掉记录。OFFSET 分页那两个绕不开的毛病——越翻越慢、还不稳定——根子都在这里。

所以做好分页,真正的工程量不在"OFFSET 算对"那几下上。算 OFFSET,谁都会。真正的工程量,在于你要换掉"用第几页来分页"这个基于位置的模型,改用"取某锚点之后的下一批"这个基于锚点的模型:翻页不再问"第几个",而是带着上一页末条的锚点,问"排在它之后的下一批";这个锚点,你编码成一个对客户端不透明的 cursor;锚点要能不重不漏地切割,你就给排序键追加一个唯一的 id 当决胜字段;游标要快,你就在排序键上建一个顺序匹配的复合索引;总数别拖后腿,你就不显示它、或估算它、或缓存它。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"LIMIT OFFSET 翻页"为什么错,再讲游标分页怎么按锚点取数、游标怎么编码、排序键为什么必须唯一,最后是索引、总数、深度导出这几个把分页守扎实的工程细节。

你会发现,分页这件事,和现实里"在一本几千页的厚书里,接着上次的进度往下读"完全相通。一个不会读书的人会怎么做?他合上书就走,下次想接着读,只记得"我读到第 800 页了"——于是他从第 1 页开始,一页一页地翻,翻过 800 页,才找到上次的位置(这就是 OFFSET:每次都从头数过)。读到第 10 页时,这么翻还不算费劲;可等他读到第 3000 页,每次接着读之前,都要先白翻 3000 页——越往后,每一次"接着读"都越慢。而且,要是这本书是活页的、中途有人往前面插进了几十页,他记的"第 800 页"就对不上原来的内容了——他会重读一段,或者跳过一段(这就是位置坐标的漂移)。而一个会读书的人怎么做?他夹一个书签。书签夹的不是"第几页"这个数字,而是具体某一页的具体内容——下次他直接翻到书签处,往后接着读,无论这是书的第 10 页还是第 3000 页,找到位置的功夫都一样,翻一下就到(这就是游标:用锚点直接定位)。更妙的是,就算前面被插进了多少页,书签还死死夹在那段内容上,纹丝不动——他既不会重读、也不会漏读(这就是游标分页在数据变动下依然稳定的原因)。同样是"接着上次往下读",可不会读书的人被一本厚书活活拖垮,会读书的人翻到哪页都轻轻松松——差别不在"书厚不厚"这件事本身,只在他用的是"第几页"这个会漂移、且要从头数的坐标,还是"书签"这个一步到位、又稳如磐石的锚点

最后想说,分页做没做对,差距永远不会在"本地开发、测试库里只有几百行"时暴露——本地表小得可怜,你翻到"最后一页"OFFSET 也不过几百,数据库扫几百行眨眼就完,深分页那个"代价随页深膨胀"的毛病,在几百行的盘子里根本称不出重量,你会觉得"跳过 N 条、取 size 条"已经够用。它只在真实的、表里堆着几百万上千万行、用户真的会翻到很深的页、还有定时任务要把整表导出的时候才显形。那时候它会用最难堪的方式给你结账:做不好,你会因为一个深页的 OFFSET 让数据库白扫几十万行而慢成蜗牛,会因为位置坐标漂移让用户在翻页时看见重影、又漏掉记录,还会因为循环 OFFSET 把一个本该线性的导出拖成平方级;而做了,你的每一页查询不管翻到多深都是同样的 O(size),用户在数据不断增删时翻页也不重不漏,整表导出平稳地线性跑完。所以别等"一个深页查询把数据库拖垮"那一刻找上门,在你写下每一个分页接口的时候就该想清楚:这个分页是按位置还是按锚点、排序键唯一吗、复合索引建了吗、总数会不会每页都 COUNT、页大小有没有上限,这一道道工序,我是不是都替它想过了?这些问题有了答案,你交付的才不只是一个"本地能翻页"的列表接口,而是一套数据涨到千万行、用户翻到第几页都依然又快又稳的可靠分页。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

LLM 调用超时治理完全指南:从一次"一个慢请求挂住、整个服务被拖垮"看懂连接、总超时与取消传播

2026-5-22 14:23:24

技术教程

AI Agent 工具调用编排完全指南:从一次"一个工具异常炸掉整个循环"看懂失败隔离、终止闸与并发调用

2026-5-22 14:38:28

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索