ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

单据查询模块设计实战:分页、性能优化与踩坑总结

2026/9/16 2:30:54 拓冰建站 浏览量
单据查询模块设计实战:分页、性能优化与踩坑总结 做企业管理软件久了你会发现单据查询从来不是写个select列表那么简单。这个模块看起来不起眼但它是所有业务模块的“脸面”——采购、销售、库存、收付款每一类单据最终都要靠查询入口展示给用户。如果这个模块设计得顺手后续加需求、出报表、做权限控制都会轻松很多如果一开始草率了后面每个迭代都在还技术债。《看潮企业管理软件》项目开发进行到第17期这期的主题是单据查询模块的第四次拆解。前面几讲我们已经把基础的单据增删改查、单据审核流和单据打印过了一遍这次重点落在这个系列里最容易被人忽略却最考验功底的部分查询逻辑的抽象、分页与统计的性能控制以及在实际项目中踩出来的那些坑。如果你正在做进销存、ERP或者任何带“单据”概念的管理系统这篇文章应该能帮你少走不少弯路。很多新手拿到一个“查询功能”的需求第一反应是“不就是写个列表接口吗”。真做起来才会发现业务方会不断追加条件筛选、表格列自定义、数据导出、行级权限、合计行……需求清单越拉越长。这期我就从设计思路、数学原理、代码落地和排障经验四个维度把单据查询这个看似简单的模块掰开揉碎讲清楚。1. 单据查询模块的整体定位与设计取舍1.1 查询模块在整套系统里承担什么角色“看潮”这个项目我定位成一款给中小型贸易公司用的进销存财务一体化软件。它不是那种展示型的Demo而是真的要给业务人员天天用的工具。在这种系统里单据是业务流转的核心载体采购订单、采购入库单、销售订单、销售出库单、收款单、付款单、费用单……每张单据都承载着业务数据。单据查询模块承担的是“把分散在各业务模块的数据重新组织起来按用户指定的条件快速呈现”的任务。听起来很朴素但它在实际系统里有三层隐藏价值第一它是数据校验的入口。业务人员需要确认“这批货是不是已经到了”“这张单子客户付款了没有”查询列表就是第一道信息窗口。第二它是统计分析的基础。很多报表功能本质上是对查询结果做二次聚合查询条件的可复用性直接决定了报表开发的效率。第三它是权限控制的关键阵地。哪些人能看哪些单、能看到哪些字段、能不能看金额都要在查询链路里做拦截和过滤。所以我在设计“看潮”的查询模块时没有按“采购查询”“销售查询”“库存查询”各自独立开发一套而是先抽象出一套通用的查询机制再按业务场景做差异化配置。这样做的收益到项目后期非常明显新的单据类型接入查询模块只需要配置条件字段、列表字段、排序规则即可不需要重新写一遍查询逻辑。1.2 通用查询机制和独立查询接口的取舍通用查询接口是很多架构师会首先想到的方案一张配置表定义查询字段和显示字段前端动态渲染查询表单和表格列后端根据配置动态拼SQL。这种方案确实灵活但代价是把业务规则硬编码进了配置数据里排查问题的时候配置和代码来回跳调试成本很高。我在“看潮”项目里采用的是折中路线列表查询的Controller层单独写Service层做业务组装但底层的分页查询工具、条件对象基类、排序规则、统计合计逻辑是全局复用的。也就是说每个单据类型可以定制自己的查询参数和返回字段但分页、排序、权限过滤、性能保护这些公共机制是统一收口的。这么做最直接的好处是业务适配性和代码可维护性达到了平衡。新来一个开发同事接手某个单据查询的改动只需要打开对应的QueryDTO和Mapper XML改动范围清晰可控不会牵扯到其他模块。而公共的分页逻辑和权限过滤在底层已经处理好了不会出现“这个页面能查、那个页面不能查”这种规则不一致的问题。2. 藏在查询逻辑背后的数学思维2.1 分页的数学本质偏移量计算与页数边界分页查询是所有列表功能的基石但很多人只记住了公式没有理解背后的数学逻辑。分页的基本模型是数据集合总共有N条记录每页展示S条求第P页的数据是哪几条。这里涉及两个关键计算偏移量偏移量 offset (P - 1) × S这是数据库LIMIT子句中的起点总页数 totalPages ceil(N / S)即向上取整。如果你忽略掉C语言里整除是向下取整这个细节用N / S直接作为总页数那么当N是S的整数倍时结果正确但当N不是S的整数倍时最后一页的数据就被吞掉了。我早期就犯过这个错误——写代码时直接用了N % S 0 这样的判断分支去处理后来发现代码丑且容易漏边界改用数学上更严谨的表达式totalPages (N S - 1) / S。这个公式本质上是ceil的整数实现避免了浮点数转换的性能损耗和精度问题。还有一个小细节当页码、每页条数由前端传入时必须做合法性校验。pageNum小于1要重置为1pageSize超过上限要截断到最大值。否则恶意请求传一个pageSize999999能把数据库内存直接打爆。分页还有个容易被忽略的数学陷阱是重复数据和漏数据。如果ORDER BY子句指定的排序字段存在并列值且没有加入唯一字段如主键id作为次级排序条件那么同一批数据在做深分页时可能被上一次查询和下一次查询重复或者遗漏。这个问题的解决方案不是调整SQL而是在设计层就确定排序规则排序字段 id 兜底。2.2 日期区间查询为什么要用半开区间企业管理软件里日期筛选是最高频的条件。采购单“查询上个月的单据”销售单“查询本周的出库记录”几乎每个列表页都要做时间过滤。最容易出错的写法是startDate order_date endDate。表面上看起来没有问题但order_date如果是DATETIME类型业务人员选了“2025-03-20”作为结束日期潜意识里是希望包含3月20日全天。而order_date存储的是2025-03-20 14:30:00用 2025-03-20去比较14:30那条记录就被排除在外了用户就会反馈“数据少了”。解决思路是用左闭右开的半开区间 [start, end)也就是数学上说的包含左端点、不包含右端点。SQL写成order_date #{startDate} AND order_date DATE_ADD(#{endDate}, INTERVAL 1 DAY)这样day2025-03-20被转换为2025-03-21 00:00:003月20日一整天到23:59:59.999都落入区间内。这个思路的本质是数学上处理连续区间的标准方法把一个“闭区间”问题转换为“半开区间”问题既不会漏数据也不会在跨月、跨年时产生边界重叠。我还见过一种方案是日期字段统一存DATE类型只按“天”维度做业务统计这样就不存在时间边界问题。但对进销存系统来说单据的创建时间精确到秒是有实际意义的比如财务对账需要知道同一分钟内发生了什么所以设计上选择了DATETIME 半开区间过滤的组合。2.3 查询条件合法性集合的完备性与条件组合的克制从数学角度审视一个查询功能是否“规范”你可以用集合思想来检验查询动作本质是对全集做筛选返回满足所有条件的子集。多条件之间通常是AND关系也就是数学上的交集概念。同一字段的多值筛选比如查询多个状态本质是UNION对应IN操作。这些集合语义听起来基础但用它在心里校验SQL逻辑非常管用。比如一个查询条件里有“单据状态已审核”和“金额1000”两个条件你可以问自己返回结果是这两个集合的交集吗如果是OR关系那是什么样在对接业务方需求时我经常用这种思路和对方确认能提前消灭大量理解偏差。再说条件组合的克制。假设一个查询页面有10个筛选字段每个字段可填可不填那么理论上有2^101024种组合。这个数字看起来不大但每种组合的SQL执行计划都不同测试人员根本不可能把所有组合都覆盖到。所以设计上要主动做减法给查询条件分优先级高频字段做组合索引低频字段不参与复杂联动。具体到“看潮”的单据查询我把条件字段分成两类等值条件走索引精确匹配模糊条件用LIKE且禁止前导%。实际开发中还设定了一个硬性规则模糊匹配字段最多两个超过两个的查询需求需先确认是否要引入全文检索否则性能隐患会在数据量增长后集中爆发。3. 实操开发从建表到接口落地3.1 数据表结构与查询字段设计“看潮”项目里的单据主表取名business_order这是所有业务单据的统称。表结构里与查询直接相关的核心字段如下字段名类型说明idbigint主键自增order_novarchar(32)单据编号业务唯一order_typetinyint单据类型1采购2销售3收款4付款partner_idbigint往来单位客户/供应商IDorder_datedatetime单据日期业务日期而非创建时间total_amountdecimal(18,2)单据总金额单位元保留两位小数statustinyint单据状态0草稿1已提交2已审核3已作废create_bybigint制单人IDcreate_timedatetime创建时间系统自动填充remarkvarchar(500)备注列表页不展示详情页展示这里有几个设计细节值得展开。单据编号order_no为什么要单独建字段而不是直接用主键id因为业务人员习惯用“PO-20250301-001”这种可读编号直接暴露数据库主键既不利于辨识也可能被猜到业务量。所以order_no在业务上是唯一键查询时用等值匹配配合唯一索引查询性能很好。partner_id、status、order_type这几个字段是列表页最高频的筛选维度我建了一个联合索引(order_type, status, order_date)。为什么order_date放在最后因为查询条件里它一定是范围条件而联合索引的匹配规则是“等值条件优先范围条件靠后”。如果前面两个等值条件能过滤大部分数据后面范围扫描的压力就很小了。3.2 查询条件对象与统一分页模型的实现查询接口入参如果直接接收一堆散落的参数代码会非常难维护。我习惯的做法是封装一个查询DTO让每个单据类型继承公共基类。基类定义分页和排序参数业务子类定义自己的筛选字段public class BaseQueryDTO { private Integer pageNum 1; private Integer pageSize 20; private String orderBy; private String orderDir DESC; private String keyword; }然后是采购单据的查询参数对象public class PurchaseOrderQueryDTO extends BaseQueryDTO { private String orderNo; private Long partnerId; private LocalDate beginDate; private LocalDate endDate; private BigDecimal minAmount; private BigDecimal maxAmount; private Integer status; }用LocalDate接收日期而不是直接用String这样在Controller层做参数校验时就能避免“2025-02-30”这种非法日期进入SQL层。这里有个实操细节分页参数pageNum和pageSize的默认值放在DTO字段上初始化而不是等前端传。如果前端没传前端传的null会把Java字段的默认值覆盖掉吗不会因为Spring MVC在没有传参时不会调用setter方法而是直接忽略字段保持初始值。但如果前端显式传了pageNumnull那就要在Controller或全局处理器里做兜底了。我通常会在统一参数校验里加一步处理if (dto.getPageNum() null || dto.getPageNum() 1) { dto.setPageNum(1); } if (dto.getPageSize() null || dto.getPageSize() 100) { dto.setPageSize(20); }这层保护放在全局的查询拦截处理器里任何查询接口自动生效。3.3 动态SQL的关键写法与常见误区“看潮”项目用的是MyBatis Plus的底层能力但复杂查询我仍然选择手写XML因为可以精确控制SQL语句必要时还能做EXPLAIN分析。查询条件动态拼接的核心逻辑是每个条件用 判断非空然后用 标签自动处理首个AND和OR。这里有一个新手经常踩的坑 标签只能解决“第一个条件前面的AND”问题如果第一个条件是 里拼出来的且为null第二个条件前面仍然有AND 不会自动识别。所以在每个条件内部我都显式写了ANDselect idselectPurchaseOrderPage resultTypecom.kanchao.vo.PurchaseOrderVO SELECT o.id, o.order_no, o.order_date, o.total_amount, o.status, p.name AS partner_name, u.real_name AS creator_name FROM business_order o LEFT JOIN base_partner p ON o.partner_id p.id LEFT JOIN sys_user u ON o.create_by u.id where if testquery.orderType ! null AND o.order_type #{query.orderType} /if if testquery.orderNo ! null and query.orderNo ! AND o.order_no LIKE CONCAT(%, #{query.orderNo}, %) /if if testquery.partnerId ! null AND o.partner_id #{query.partnerId} /if if testquery.beginDate ! null AND o.order_date gt; #{query.beginDate} /if if testquery.endDate ! null AND o.order_date lt; DATE_ADD(#{query.endDate}, INTERVAL 1 DAY) /if /where ORDER BY o.order_date DESC, o.id DESC LIMIT #{query.offset}, #{query.pageSize} /select这里要特别注意几个细节。第一XML里的小于号必须转义成否则XML解析会报错。我在早期写动态SQL时经常因为忘了转义浪费大量时间排查。第二LIKE查询的写法我用了CONCAT(%, #{query.orderNo}, %)而不是直接在Java端拼好传进来这样可以避免SQL注入的隐患虽然MyBatis的#{}预编译已经处理了大部分风险但把拼接动作放在SQL层语义更清晰。第三LIMIT的offset计算我放在了Service层而不是XML里写死int offset (dto.getPageNum() - 1) * dto.getPageSize();这个offset本身就是2.1节里那个数学公式直接乘法算出来就行。可能有人会问动态查询结果字段里出现了下单人姓名u.real_name为什么不用子查询因为这里join关联的是用户表而用户表数据量很小企业内部就几十号人联合查询走索引代价极低完全没必要用标量子查询增加逻辑复杂度。这种“用小表join替代子查询”的习惯是为了让SQL的执行计划可预期排查性能问题时不至于两眼一抹黑。3.4 列表查询与统计合计的联动实现单据查询页面上除了表格数据用户往往还希望看到查出来的这些单据“合计金额是多少”。这个需求如果在前端做一次只能汇总当前页那几十行用户要的是所有符合条件的单据总和所以必须由后端处理。初版我把统计SQL和列表SQL写在同一个方法里先count再selectPage再selectSum。后来发现这三个查询的条件完全没有变化可以复用同一段动态SQL的公共片段。MyBatis的SQL片段解决这个问题非常顺手sql idorderQueryWhere where !-- 与上面查询条件的片段相同 -- /where /sql然后在三个查询语句里用 引用同一份条件。这样做有一个很实际的好处以后加了一个查询条件只需要改一处列表和统计逻辑同时生效不会出现“列表按新条件过滤了合计还是全量”这种诡异问题。选择完把列表、总条数、总金额一起封装到返回对象里public class PageResultT { private ListT list; private long total; private BigDecimal sumAmount; }这个对象是泛型结构后续任何单据查询都能复用。这里还要多提一句合计算法用的是SQL里的SUM聚合而不是在Java侧遍历list逐个累加。原因很简单列表查询因为分页只返回了一部分数据Java侧累加只能得到当页合计不符合业务语义。而且SQL的SUM是数据库引擎优化过的精度和性能都有保障。4. 前端配合与导出等扩展能力4.1 查询表单与表格联动的交互细节“看潮”的前端部分基于Vue全家桶实现。单据查询页的基本交互“套路”是查询表单区域 主表格区域 分页组件区。这一套交互看起来简单但联动时的细节不少。我的做法是查询表单的字段定义直接放在前端配置数组里每个字段用type标明类型input、date-picker、select等查询按钮触发时把表单值序列化成JSON传给后端。这样新增一个筛选条件时前后端各加一个配置项即可不需要改动查询请求的公共逻辑。联动时机上“看潮”选择的是“查询按钮手动触发筛选分页页码变化自动触发查询”组合。没有做成输入关键字后自动防抖查询因为单据查询对结果准确性要求高用户更希望自己控制查询时机而不是每敲一个字符就请求一次接口误操作还会导致页面闪烁。表格列的字段也是动态渲染的但每行后面的“操作”列是固定的。操作列一般包含“详情”“打印”“作废”三个入口。点击详情弹窗加载单据头单据明细这里我采用了“按需加载”策略弹窗打开时才去请求detail接口而不是在列表查询时一次性把所有明细都带出来。如果数据量大列表和明细分开查是性能底线。4.2 导出Excel的异步化处理查询模块还有一个很容易被忽略的扩展需求导出。业务人员看到符合条件的单据列表大概率会点“导出”按钮希望把结果存成Excel发给别人或留档。第17期项目开发到这里有个值得记录的教训最初我把导出做成了同步接口前端发起请求后后端当场把Excel生成出来给浏览器下载。数据量小的时候这没问题但当筛选条件是“最近一年的销售出库单”时一次性查出几万条记录再生成Excel接口响应时间飙到十几秒浏览器经常因为等不及而中断请求。后来我把导出逻辑改成了异步任务用户点击导出后端立刻返回“任务已提交”前端在页面上显示“正在导出…”的进度提示后台线程生成Excel并上传到文件服务器或本地临时目录完成后往消息表里插入一条记录前端轮询或者通过WebSocket收到“导出完成”事件然后下载文件。异步导出最关键的是控制内存和分页策略。生成大Excel不能一次性把所有数据load到内存里应该是流式查询每查出一批数据就写一批到ExcelWriter。这背后的数学/资源管理逻辑是“分治”——把大任务拆成小批次顺序执行控制内存峰值为固定的batchSize而不是数据总量。5. 常见问题排查与性能优化实录5.1 日期查询丢数据的坑这个坑在前面2.2节已经提过原理再补充一个实际排查过程的细节。上线后的某一天业务人员反馈“3月20号的销售单据在查询界面看不到”。我第一反应是查数据库里有没有那天的单子发现有说明问题出在过滤条件。排查时我先在Navicat里手动执行了SQL直接写 order_date 2025-03-20 AND order_date 2025-03-20 也查出来了数据心里更奇怪了。后来才发现接口日志里前端传的endDate字段格式是2025-03-20 23:59:59而后台的LocalDate类型在序列化解析时把2025-03-20 23:59:59转成了LocalDateTime再反序列化结果格式直接报错Spring返回了400前端却误以为查询结果为空。这个问题的根源是前端日期组件传值格式与后端接收类型不一致。后来我做了两层修复第一层DTO里的endDate改成String由自己写的解析工具统一转成LocalDate格式错误时明确提示“结束日期格式不正确”而不是返回通用400第二层前端封装了统一的日期组件只传yyyy-MM-dd格式。这之后日期查询再也没有出现过边界问题。5.2 大偏移量分页越查越慢的根因分析深分页问题在“看潮”的一次真实业务场景中暴露得非常明显。客户公司有5万多张历史单据业务人员习惯翻到第2000页去核对一个多月前的记录每次都等接近三十秒。排查时我用EXPLAIN看了执行计划发现LIMIT 39980, 20 这种写法数据库需要先扫描并丢弃前面39980行数据再取20行扫描行数非常大索引再好也扛不住。解决办法是改用游标分页Keyset Pagination。但它有个使用限制不能直接跳转到任意页数只能通过“上一页/下一页”的方式翻页。这对业务人员来说其实可以接受因为老用户找单据很少跳跃都是往后翻。具体实现是记录上一页最后一条记录的id和order_date下一页查询时带上条件WHERE (o.order_date #{lastOrderDate}) OR (o.order_date #{lastOrderDate} AND o.id #{lastId}) ORDER BY o.order_date DESC, o.id DESC LIMIT 20这套逻辑的数学基础是字典序以(order_date, id)为组合键下一页就是从上一页的最后一条记录的“下一个”位置开始取。每一页的扫描量被约束在20行左右性能稳定。对仍然想支持跳页的场景我给出的折中方案是限制最大翻页深度超过100页时提示用户使用筛选条件缩小范围而不是允许无限翻页。这个限制需要业务方理解不是为了偷懒是从根本上避免数据库资源浪费。5.3 金额字段的精度问题double、float与BigDecimal做财务相关功能的同学应该都能脱口而出金额计算不能用浮点型。这里我再做个补充说明为什么数学上很自然的0.1 0.2 0.3在计算机里变成了0.30000000000000004。根本原因是计算机用二进制表示小数而0.1的二进制是一个无限循环小数0.00011001100110011...浮点数存储时只能截断保留有限位误差就累积出来了。这个误差在单次计算里肉眼很难发现但在累加几千次之后金额差个几分钱都是可能的事。“看潮”项目里所有金额字段在数据库层统一用decimal(18,2)存储Java侧用BigDecimal接收和参与计算。前端展示时直接用后端返回的Decimal值不做加减乘除如果前端真的需要做一些合计计算也要求用decimal.js这类库禁止直接使用JavaScript浮点数运算。还有一个细节容易被忽略BigDecimal除法必须指定精度和舍入模式否则除不尽时会抛ArithmeticException。实际写代码时我统一在这个工具类里定义舍入策略public class MoneyUtil { public static BigDecimal divide(BigDecimal a, BigDecimal b) { return a.divide(b, 2, RoundingMode.HALF_UP); } }HALF_UP就是四舍五入这是财务上最常用的舍入模式。如果你做的是银行系统可能需要用HALF_EVEN银行家舍入这个要看业务规则来定。5.4 权限过滤与数据隔离的兜底策略企业管理软件里单据查询不能漏掉权限控制。如果所有登录用户都能查所有单据做实施的时候分分钟被客户怼回来销售A不想让销售B看到自己的客户和报价仓库管理员不该看到财务的收款金额。“看潮”的权限模型是用户属于角色角色绑定数据范围全部数据、本部门数据、本人数据、自定义部门数据。查询时通过AOP切面统一往Mapper注入数据权限条件。具体实现时我在查询基类里加了一个字段public class BaseQueryDTO { // 数据权限辅助字段Service层填充 private Long currentUserId; private Long currentDeptId; private Integer dataScope; }Service层根据当前登录用户的dataScope决定拼接什么SQL约束dataScope 3本人AND o.create_by #{currentUserId}dataScope 2本部门AND u.dept_id #{currentDeptId}dataScope 1全部不加约束这里有个必须注意的细节数据权限过滤条件属于系统性约束必须无条件拼接不能和用户勾选的业务筛选条件混在一个动态 里。否则就会出现用户把“制单人”筛选字段留空时权限过滤一并失效的漏洞。我的做法是权限条件放在 标签的第一行且不加 判断查询动作必然有当前登录用户。这个坑有过一次深刻的教训曾经因为权限条件加了 的校验而前端构造查询时没有传userId导致管理员账户看到的是全量数据而普通用户看不到任何数据。后来我改成从服务端上下文里取登录用户不依赖前端传值彻底杜绝了越权风险。5.5 列表页查询缓存与实时性的权衡单据查询的数据实时性要求比较高因为业务人员在审核单据之后立刻就会去查询列表确认状态有没有变。如果在这里加Redis缓存很容易出现“审核完成了列表里还是待审核”的脏读问题。所以我在“看潮”里对列表查询默认不加缓存每次请求直接查MySQL。那性能压力大的时候怎么办数据库读写分离主库写、从库读业务高峰期把查询流量引到从库上。不过因为“看潮”是单体应用读写分离就这么一点配置而已。等数据量真的大到需要引入搜索引擎阶段——比如要支持订单全文检索、按收货地址模糊搜索——那才需要引入Elasticsearch来做查询加速。对小规模管理软件来说MySQL用索引优化就够用了没必要为了“技术先进”而引入一套ES维护成本。写在最后的实操体会单据查询这个模块做到第4期我最深刻的感受是真正影响这个模块质量的地方不是CRUD能不能跑通而是你有没有把业务边界、数学规律、数据库原理这些东西串起来。一个典型的例子就是分页。表面上它只是LIMIT后两个数字的事但只有把它放到数学的“集合分块”视角下理解你才会主动思考排序稳定性、深分页性能、边界条件这些深水区的问题。编程到了一定阶段拼的就是这种“用底层规律去解释表层现象”的能力。如果你正在开发类似的企业管理软件我的建议是别急着堆功能先把查询模块的地基打好。把查询条件对象抽象清楚把分页和合计逻辑统一封装把权限过滤条件固化到公共链路上把日期处理和金额精度处理写成工具类……这些工作前期看起来像“浪费时间”但到第10个、第20个单据查询页面做出来之后你会发现自己已经领先那些每次都在页面上“复制粘贴改SQL”的同行一大截了。最后再分享一个小技巧开发完查询功能不要只测“正常查询”。每次改完代码都用几组极端数据自测一下——页码为0、每页条数为0、时间区间跨年、金额字段带负数、模糊条件输入%和_。这些边缘case才是bug最容易藏身的地方也是体现一个开发者是否靠谱的分水岭。