一、90%的开发者都踩过的分页坑
做后端开发的人,几乎都被一句“共识”绑架过:做分页,就用cursor(游标),offset又慢又坑,迟早要被淘汰。
打开任意技术博客,清一色都是“offset的弊端”“cursor分页实战”,仿佛只要用了offset,就是技术落后、考虑不周。无数开发者跟风上车,花几天时间啃完cursor的编码、解码逻辑,费劲心力把offset改成cursor,最后却发现:自己的项目根本用不上这份“复杂”,反而多了一堆不必要的麻烦。
其实,cursor从不是万能神药,offset也绝非一无是处。有位资深后端工程师Viktor Ponamarev,结合自己多年.NET项目实战经验,打破了“cursor必赢”的刻板印象——他发现,在很多场景下,offset不仅够用,反而比cursor更高效、更省心。
到底什么时候该用offset?什么时候必须上cursor?这篇文章,帮你彻底理清分页选型的底层逻辑,避开跟风踩坑,写更简洁、更高效的代码。
二、核心拆解:先搞懂两种分页的底层逻辑(附.NET实战代码)
在纠结选型之前,我们必须先明确:offset和cursor到底是什么,各自的实现逻辑的是什么?结合具体的.NET代码,一看就懂,新手也能快速上手。
先明确两个关键概念
offset分页:核心是“跳过N行,取M行”,对应SQL的OFFSET和LIMIT(.NET中是Skip和Take),逻辑简单,上手即用。
cursor分页:核心是“从某个具体节点开始,取M行”,cursor是上一页最后一条数据的编码值,数据库通过索引直接定位,无需跳过前面的行。
1. offset分页(.NET实战代码)
适用场景:简单分页需求,无需复杂逻辑,前端需要显示页码和总页数。
// GET /api/articles?page=3&pageSize=20app.MapGet("/api/articles", async (int page, int pageSize, AppDbContext db) =>{ var articles = await db.Articles .OrderByDescending(a => a.PublishedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); var totalCount = await db.Articles.CountAsync(); return new { Data = articles, Page = page, PageSize = pageSize, TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) };});生成的SQL语句:
SELECT * FROM ArticlesORDER BY PublishedAt DESCOFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY;优势:代码简洁,前端能直接渲染“第3页/共47页”,用户可以自由跳转页码,开发效率极高。
2. cursor分页(.NET实战代码)
适用场景:大数据量、实时更新的场景(如实时feed、事件流),需要避免数据重复或遗漏。
// GET /api/articles?cursor=eyJpZCI6NDJ9&pageSize=20app.MapGet("/api/articles", async (string? cursor, int pageSize, AppDbContext db) =>{ var query = db.Articles.OrderByDescending(a => a.PublishedAt).AsQueryable(); if (cursor is not null) { var decodedCursor = DecodeCursor(cursor); // 解码后得到{ PublishedAt, Id } query = query.Where(a => a.PublishedAt< decodedCursor.PublishedAt || (a.PublishedAt == decodedCursor.PublishedAt && a.Id < decodedCursor.Id)); } var articles = await query.Take(pageSize + 1).ToListAsync(); var hasNext = articles.Count > pageSize; if (hasNext) articles = articles.Take(pageSize).ToList(); var nextCursor = hasNext ? EncodeCursor(articles.Last()) : null; return new { Data = articles, NextCursor = nextCursor, HasNextPage = hasNext };});生成的SQL语句:
SELECT * FROM ArticlesWHERE PublishedAt < @lastSeenDate OR (PublishedAt = @lastSeenDate AND Id < @lastSeenId)ORDER BY PublishedAt DESCFETCH NEXT 21 ROWS ONLY;优势:无需跳过前面的行,数据库直接通过索引定位,大数据量下分页速度更快,且能避免数据更新导致的重复或遗漏。
3. 关键技术补充
无论是offset还是cursor分页,核心依赖数据库索引和.NET的EF Core框架,两者均为开源免费工具:
EF Core:微软开源的ORM框架,用于.NET应用程序与数据库交互,GitHub星标12.8万+,完全免费,支持多种数据库(PostgreSQL、SQL Server等)。
数据库索引:分页效率的核心,无论是offset的OrderBy,还是cursor的条件查询,都需要依赖对应字段的索引(如PublishedAt、Id),否则会导致查询变慢,这也是两种分页的共同前提。
三、辩证分析:没有绝对的优劣,只有适配的场景
所有人都在说offset的缺点,却没人告诉你:那些缺点,在很多场景下根本无关紧要;而cursor的优点,也未必是你需要的。真正的技术选型,从来不是“选好的”,而是“选对的”。
先正视offset的“缺点”:什么时候它真的不行?
offset最被诟病的问题,是“数据偏移”:当分页过程中,有新数据插入或旧数据删除时,会导致用户看到重复数据或遗漏数据。
比如一个文章feed,用户正在看第2页,此时有一篇新文章发布(插入到第一页),那么原本第2页的第一篇文章,会变成第3页的第一篇,用户翻到第3页时,就会重复看到这篇文章;如果有文章被删除,用户则会跳过一篇文章,永远看不到。
这个问题,在实时feed、事件流、大数据量并发写入的场景下,是致命的——比如社交平台的动态、系统的实时日志,用户无法接受重复或遗漏,此时cursor分页是唯一选择。
再看offset的“隐藏优势”:这5种场景,它比cursor更香
很多开发者跟风用cursor,只是因为“别人都用”,却忽略了自己的项目场景。以下5种情况,offset分页不仅够用,还能节省大量开发时间,避免不必要的复杂度。
场景1:admin面板、后台管理工具
比如运营团队用来浏览订单、用户、发票的后台,用户的核心需求是“搜索特定记录、筛选条件、自由跳转页码”——比如直接跳转到第14页查看某批订单,需要看到“共327条结果”。
这种场景下,数据变化带来的重复的问题,几乎可以忽略(运营人员发现重复,刷新一下即可);而cursor分页无法实现“跳转页码”“显示总页数”,反而会增加运营人员的操作成本。
场景2:小型参考数据(分类、标签等)
比如系统中的分类、标签、国家列表、权限角色,这类数据通常只有几十到几百条,很少更新。有人为了“规范”,给这类接口做了cursor分页,写了一堆编码、解码逻辑,纯属多此一举。
对于45条数据的分类表,offset 20条数据的成本几乎为0,甚至可以直接把所有数据加载到前端,客户端分页更高效。
场景3:搜索结果分页
用户搜索“.NET分布式缓存”时,需要看到“共128条结果”,可以自由跳转页码,回到第1页重新筛选。搜索结果是“快照式”的,用户不会期待分页过程中出现新的搜索结果——如果想要新鲜结果,用户会重新搜索。
此时,offset的“数据偏移”问题完全无关紧要,反而cursor分页无法提供总结果数,会降低用户的搜索体验。

场景4:报表、时间窗口数据
比如月度营收报表、上周活跃用户、某时间段的错误日志,这类数据被时间窗口固定,不会有新数据插入(比如上个月的错误日志,不会再新增)。
数据本身是稳定的,offset分页不会出现偏移问题,而且能提供总页数和总条数,方便用户查看和导出报表,比cursor更实用。
场景5:缓存的静态结果集
比如用户的个性化推荐、批量处理结果,这类数据会被缓存一段时间(比如1小时),用户分页时,本质是在浏览一个静态的内存列表。
此时cursor分页的“稳定性”毫无意义,offset分页代码更简单,无需额外处理cursor的编码和解码。
关键提醒:offset的“慢”,是有前提的
很多人说“offset慢”,其实是误解。offset的慢,只出现在“大数据量+深度分页”的场景下——比如100万条数据,分页到第1000页,此时数据库需要跳过99900条数据,才会返回后面的20条,速度会明显变慢。
但实际开发中,多数场景都不会用到深度分页:admin面板很少有人翻到100页以后,搜索结果用户通常看前10页就会放弃,小型参考数据甚至不需要分页。
Viktor Ponamarev做过测试:在100万条数据的PostgreSQL表中,分页大小20,前100页的offset和cursor速度几乎没有差别;只有分页到1000页以后,cursor的优势才会显现。
四、现实意义:拒绝过度工程,才是成熟的开发思维
这篇文章的核心,从来不是“offset比cursor好”,而是“拒绝跟风,按需选型”——很多开发者的误区,是把“复杂”等同于“专业”,把“跟风”等同于“规范”,却忽略了开发的本质:解决问题,而不是制造问题。
用offset还是cursor,核心看3个问题:
1. 数据集有多大?是否会出现深度分页(超过100页)?
2. 数据是否会在用户分页过程中实时更新?
3. 用户是否需要跳转页码、查看总页数?
如果答案都是否定的,用offset就够了——写更简单的代码,快速上线,解决实际问题,这才是高效的开发方式。
退一步说,即使未来数据集增长,需要从offset迁移到cursor,这个过程也很简单,无需一开始就为“可能出现的问题”付出额外的开发成本。
这里给.NET开发者分享一个可复用的offset分页工具类,15行代码,适配所有实体,无需重复开发:
public record PagedResult( IReadOnlyList Items, int Page, int PageSize, int TotalCount){ public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); public bool HasPreviousPage => Page > 1; public bool HasNextPage => Page < TotalPages;}public static class QueryableExtensions{ public static async Task> ToPagedResultAsync( this IQueryable query, int page, int pageSize, CancellationToken ct = default) { var totalCount = await query.CountAsync(ct); var items = await query .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(ct); return new PagedResult(items, page, pageSize, totalCount); }}// 用法:一行代码实现分页var result = await db.Orders .Where(o => o.Status == OrderStatus.Shipped) .OrderByDescending(o => o.CreatedAt) .ToPagedResultAsync(page: 3, pageSize: 20); 五、互动话题:你踩过分页选型的坑吗?
看到这里,相信很多开发者都会有共鸣:曾经跟风把offset改成cursor,最后发现根本用不上;或者明明用offset更简单,却被“规范”绑架,写了一堆复杂代码。
评论区聊聊你的经历:你做分页时,优先选offset还是cursor?踩过哪些选型的坑?有没有更实用的分页技巧?
关注我,每天分享.NET实战技巧,拒绝过度工程,写更高效、更简洁的代码!