架构设计 AnyLine

关于分页数据结构的设计

设计误区与恶习

发布于 2022-01-09
更新于 2026-05-18
1

问题根源:双轨制的结构性困局

在传统的后端分页设计中,开发者面临着一个看似简单却深藏隐患的二选一问题:

管理列表

需要分页,返回 Page<List>

下拉列表

不需要分页,返回 List

传统方案用 Page<List> 应对分页场景,用 List 应对非分页场景。这是典型的"结构性裂缝"——从 Controller 到 Service、DAO、Mapper、Entity,每一层都要维护两套代码。

⚡ 核心矛盾

"是否分页"本应是查询参数层面的配置选择,传统方案却将其提升为类型层面的架构决策。

这意味着一个简单的需求变化——"这个下拉列表改成支持分页"——就会引发全链路的类型修改。问题不在于分页本身有多复杂,而在于我们选择的表达方式让简单的事情变得复杂。

2

设计哲学:信息归属的重定义

2.1 分页信息是查询结果的元数据,而非外部标注

行数据和"总共多少行"在同一次数据库交互中同时产生,具有同源性。分页信息属于结果集本身的固有元属性,而非事后从外部添加的标注。

2.2 哲学选择引发的连锁效应

类型归一

只需一种类型,分页属性为可选字段

信息完整性

元数据与数据同体,不可分割

消费者简化

下游无需根据返回类型选择不同解析策略

💡 设计思考

把分页信息放在外面,隐含"与数据无关";放在里面,隐含"固有属性"。前者导向分裂,后者导向统一。

3

接口归一:从类型分裂到类型统一

2.1 双轨制的本质:将运行时状态提升为编译时类型

"是否分页"本质上是运行时状态——用户在页面上选择"显示全部"还是"分页显示",是一个运行时的交互决策。传统方案却将其提升到编译时类型层面,用两种不同的返回类型来表达一个运行时选择。

2.2 降级的深层意义:从类型决策到参数配置

A

编译时约束的解除

接口不再被绑定在特定返回类型上,同一方法可灵活响应分页或非分页请求

B

代码路径的收敛

Service 层、DAO 层不再需要两条分支,逻辑合二为一

C

扩展性的解放

新增分页策略只需增加参数值,而非新建类型

D

前端解析的统一

前端消费者面对统一的响应结构,无需条件分支

结构对比

传统双结构
public class Page<T> {
  private List<T> records;
  private int pageNum;
  private int pageSize;
  private long total;
}

// 非分页场景:类型完全不同
public class UserController {
  // 路径 A:分页
  Page<User> listUsers(PageParam p);
  // 路径 B:不分页
  List<User> allUsers();
}
DataSet 单结构
public class DataSet {
  private List<DataRow> rows;
  private Integer pageSize;  // null 或 -1 表示不分页
  private Integer pageNum;
  private long totalRow;
}

// 统一结构:一种类型应对所有场景
public class UserController {
  // 唯一路径:分页与否由参数决定
  DataSet listUsers(QueryParam q);
}

🔑 关键思考

让类型描述"这是什么",参数描述"怎么用"。

4

信息完整性:消除"丢失"的可能性

3.1 信息单向坍缩:传统方案的不可逆损失

当传统方案选择返回 List 而非 Page 时,totalRowpageSize 等分页元数据被彻底丢弃。这不是"暂时不用",而是不可逆的信息损失——即便数据库已经计算出 total,这些信息在类型转换的瞬间灰飞烟灭。

3.2 DataSet 的信息守恒

DataSet 始终保留全部信息。当不需要分页时,pageSize = -1 表示"无限制",而非"不存在"。totalRow 始终有效,随时可以切换为分页模式。

3.3 对下游消费者的实际影响

前端组件

统一的响应结构意味着前端无需条件判断,一个通用的 Table 组件即可处理所有列表场景。

缓存策略

元数据的保留使得缓存可以精确管理:知道 total 就能判断缓存是否过期,而非盲目依赖 TTL。

审计与监控

每次查询的 totalRow 可用于性能基线分析,数据量的变化趋势一目了然。

API 消费者

第三方调用者无需查阅文档中的"哪些接口有分页、哪些没有",统一的结构自解释。

💡 设计思考

信息丢失的真正危险在于:下游系统被迫在信息残缺的前提下做决策。

5

跨数据库透明:分页方言的终结

4.1 组合爆炸:传统 DAO 层的噩梦

传统方案中,每支持一种新数据库,DAO 层就需要新增对应的分页 SQL 方言。当存在 N 种数据库和 2 种查询模式(分页 / 非分页)时,路径数为 N × 2。MySQL 的 LIMIT、Oracle 的 ROWNUM、SQL Server 的 OFFSET FETCH、达梦的 LIMIT……每一种都需要手写、测试和维护。

4.2 方言引擎:内置 200+ SQL 转换规则

DataSet 方案内置方言引擎,包含 200+ SQL 转换规则,自动适配各类数据库的分页语法。开发者只需写一次查询,方言引擎在运行时自动将其转换为目标数据库的等效 SQL。

4.3 信创迁移与多数据源场景的实际价值

信创迁移

从 Oracle 迁移到国产数据库,无需逐条改写分页 SQL,仅需修改连接配置。

多数据源

同一应用连接 MySQL + PostgreSQL + 达梦,上层查询代码完全一致。

测试保障

开发环境用 H2,生产环境用 MySQL,查询代码零差异,测试结果高度可信。

6

能力内聚:数据容器的自治性

5.1 不只是"带分页的 List"

DataSet 的设计目标远不止"一个带分页信息的列表"。它是一个具备自治能力的数据容器——数据加载后,容器自身就能完成查询、过滤、聚合、格式转换等操作,无需将数据倒腾到外部工具中处理。

5.2 内聚设计的三个维度

1

类 SQL 查询与过滤

在内存中对 DataSet 执行 WHERE 条件过滤、ORDER BY 排序,语法接近 SQL,无需引入额外查询框架。

2

聚合与格式转换

内置 SUM、AVG、COUNT、GROUP BY 等聚合操作,支持 JSON、XML、CSV 等格式互转。

3

缓存支持

DataSet 天然适配缓存机制,可作为缓存单元直接存储和恢复,元数据辅助实现精确失效。

5.3 自治性带来的架构收益

当数据容器具备自治能力时,数据与其处理逻辑之间的隐性耦合被消除。传统方案中,"拿到 List 后再用 Stream 过滤"意味着数据处理逻辑散落在各个 Service 方法中;DataSet 方案将这些能力内聚到容器自身,处理逻辑统一、可复用、可测试。

💡 设计思考

消除"数据与其处理逻辑间"的隐性耦合,让数据容器成为自治的计算单元,而非被动的数据搬运工。

7

对比总览

对比维度 传统 Page 包装方案 DataSet 一体化方案
返回类型 Page<T> 或 List<T>(两种类型) 始终 DataSet(一种类型)
Controller 接口数 每个查询场景需 2 个路径 每个查询场景仅需 1 个路径
前端解析逻辑 需判断分页/非分页,走两套解析分支 统一解析,无需条件判断
分页元数据位置 外层包装对象(Page),与数据分离 结果集内部,与数据同行
分页信息丢失风险 非分页时 totalRow 等信息彻底丢失 永不丢失,pageSize=-1 表示无限制
实体类依赖 强依赖预定义 Entity,字段变更需全链路修改 弱类型动态结构,字段增删零代码修改
跨数据库适配 每种数据库需手写分页 SQL,N x 2 路径 方言引擎自动转换,上层代码零改动
内存计算能力 无,需依赖外部工具类或 Stream API 内建类 SQL 查询、聚合、格式转换
缓存策略 Page 与 List 需两套缓存逻辑 统一缓存单元,元数据辅助精确失效
代码维护成本 每层双轨并行,维护量随场景数线性倍增 单轨统一,维护量与场景数成正比
信创迁移成本 逐条改写分页 SQL,耗时以月计 仅改连接配置,迁移周期从月到天
扩展新分页模式 需新增返回类型,代码再次裂变 参数层面扩展,类型不变

总结

DataSet 的优势并非凭空而来,它根植于一个核心决策:让分页信息成为结果集的内部属性,而非外部包装。这一决策的连锁效应贯穿了类型统一、信息守恒、跨库透明、能力内聚等多个维度,最终汇聚为架构层面的根本性简化。

"把分页信息放在结果集里面,不是一个'放哪更方便'的工程选择,而是一个'分页信息到底属于谁'的回答。"