分页设计指南

分页无处不在,查看邮件、刷朋友圈、浏览新闻等,我们无不是在一页一页地获取信息。对于服务端来说,针对不同场景,分页有不同的设计方案,本文主要以MySQL为例(数据样例见文末)。 页码分页 最简单的是通过页码来分页: 不要嗤之以鼻,如果只是系统初期的小小的后台管理系统,这样的分页已经足够了,没必要整得花里胡哨的,够用就好。 随着业务增长,系统越来越大了(大部分系统没有这样的福气),上面的分页方法逐渐显得有点捉襟见肘了。过早的优化是万恶之源,因此咱们一开始只是简单粗暴地分页也无可厚非,现在我们要逢山开路遇水架桥。 假设我们的用户量单表去到了1000万(这里不考虑分库分表的情况),瓶颈出现了: 这个查询需要好几秒,因为user表用的是InnoDB存储引擎,它需要检查所有记录才能得出总行数,这是为了支持MVCC而付出的代价。那用索引字段代替星号会不会有性能提升?很遗憾,几乎没有。其实使用星号MySQL会自动帮你优化选择最小的索引。那怎么办?第一,你可以选用MyIASM来避免这个问题(它是直接从元数据中读取总数),如果你可以放弃使用事务特性。第二,使用缓存,弊端是当查询条件复杂的时候,维护缓存也是一个麻烦,而且构建缓存时依然很慢。第三,无解,那干脆不用select count(*)了,那用什么呢?这个下面会谈到。 游标分页 我们还发现有些用户喜欢从最后一页倒回来看,弄出类似下面这样的语句: 即使查询条件走了索引,而且也只是查了仅仅10条数据,但还是卡得要死,为什么呢? 用explain查看一下执行计划,你会发现这条语句几乎是全表扫描!稍微优化一下: 哇,几乎瞬间结果就出来了。你可以脑补,它在一颗B-Tree上,只需走几个分叉,就找到了目标,而不是无脑地扫描。我们可以把这个分隔条件值称作游标(cursor),例如上面的9999990。现在可以不用页码来分页了,而是直接使用cursor来分页,因此也不需要提供总页数这个信息。客户端可以根据游标查询下一页或上一页的数据,游标为空时默认是第一页,每一页数据都会返回下一页或上一页的游标,这样也就可以一页地翻上翻下,直至没有数据返回。Yeah,可以跟前面提到的select count(*)说拜拜了。 可以把这个想象为一个双向列表,下拉时告诉服务端当前列表最后一条数据的标识,上拉时告诉服务端最前一条数据的标识。注意这个cursor不一定就是数字,它应该是一个payload,可以包含很多有用的信息,例如cursor=abc.123.855.bbb。前端不需要了解cursor值是什么东西,他只知道把cursor传给服务端就能拿到下一页,然后又获得一个新的cursor。后端可以随意修改这个cursor的实现方式,只要保证排序向后兼容,前端是无感知的。 使用id作为游标,确实好用,有时候,如果你需要对created_at进行排序,不必为它创建索引,直接使用自增id排序即可,如果你不想用自增id,也可以使用Twitter所创的snowflake,它直接把时间信息嵌入到一个64位的整型当中。 但还有一个问题,如果是根据用户性别排序,即出现重复数据时这个游标应该怎么设计?给性别字段gender创建索引,它的基数太小(只有3),得不偿失。但我们可以增加一个基数大的字段gender_order,专门用于排序,这个字段的排序结果就是gender字段的排序结果。因此只要满足: 这时候就可以拿gender_order当做游标了,一般来说取int64的高位保存原值,低位保存一个唯一值例如id,也可以取少一些的整数例如1000亿,当然要保证不会溢出哦。上面公式的id可以是当前用户的id,也可以是使用其它办法生成的唯一id,公式还可以是其它的组合形式,保证gender_order是唯一值即可。这里只是提供了这种思路,需求千变万化,但万变不离其宗。如果你还想了解更为复杂的游标设计,可以参考Redis是如何通过游标来遍历非常大的集合的。 另外建议,不要在原表做分页操作,要把分页独立为一个组件或接口,方便将来扩展。例如上面的gender排序可以单独设计一个索引表添加user_id,gender_order字段来实现,保持原user表的清晰简洁。你甚至可以把gender_order这个索引保存在Redis的有序集合中,以获得更快的速度。我不关心你是如何实现分页的,你只需给我那一页的id列表即可。你也许几经周折走遍千山万水才搞出下一页的id列表,但最终数据是通过一条简单的查询读出来: 总的来说,游标分页有以下几个好处: […]