分页查询几乎是所有后台系统的标配功能,但很多线上事故恰恰就出在这个看似简单的地方。有一次排查慢查询日志,发现一条SQL的LIMIT值竟然是十万级别,追根溯源后发现是前端一处循环拼接请求的bug,把pageSize不断翻倍传了上来。数据库本身不会替你把关参数,它会老老实实地按你给的值去扫描、排序、截断,所以参数合法性校验必须在应用层完成,而且是越早越好。关系运算符——也就是大于、小于、大于等于、小于等于、不等于这些最基础的比较逻辑——正是构建这层防御的第一道工具。

为什么分页参数必须在进入SQL之前校验
先看一个没做校验的典型接口。假设前端传入page和pageSize两个参数,后端直接拼接进SQL:
SELECT id, name, price FROM product
ORDER BY create_time DESC
LIMIT #{offset}, #{pageSize}如果page被传成-1,offset会变成负数,MySQL直接抛出语法错误;如果pageSize传了0,某些数据库会返回空结果但有些会报错;如果pageSize传了100万,数据库可能要为这一条查询做大规模的排序和临时表操作,内存和CPU瞬间被打满。这些问题有一个共同点:错误发生的位置在数据库层,而不是应用层。
错误暴露得越晚,代价越大。在应用层用关系运算符拦截,成本几乎为零——就是几个整数比较,纳秒级完成;而放行到数据库层,可能是几秒甚至几分钟的全表扫描。更严重的是,恶意用户可以故意构造超大pageSize发起资源耗尽攻击,这在安全层面属于典型的DoS向量。所以校验不是可选项,而是分页接口的基本功。
还有一个容易被忽略的点:区间参数的交叉校验。比如价格筛选中的minPrice和maxPrice,单独看每个值都可能是合法数字,但minPrice=500而maxPrice=100时,这个查询逻辑上就是矛盾的,返回结果永远为空,白白消耗一次数据库往返。这类问题必须靠关系运算符组合判断才能发现。
用关系运算符构建基础校验规则
分页参数的核心约束可以用一组不等式清晰表达。以常见约定为例:page从1开始,pageSize介于1到100之间。翻译成关系运算符就是:page >= 1 && pageSize >= 1 && pageSize <= 100。下面是Java版本的实现:
public class PageParamValidator {
private static final int MAX_PAGE_SIZE = 100;
public static void validate(int page, int pageSize) {
// page 必须大于等于 1,禁止 0 和负数
if (page < 1) {
throw new IllegalArgumentException("page 不能小于 1,当前值: " + page);
}
// pageSize 必须落在 [1, 100] 区间内
if (pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
throw new IllegalArgumentException(
"pageSize 必须在 1 到 " + MAX_PAGE_SIZE + " 之间,当前值: " + pageSize);
}
}
}注意这里用了两个方向的关系运算符:pageSize < 1拦截过小值,pageSize > MAX_PAGE_SIZE拦截过大值。有人习惯只写pageSize < 1然后依赖数据库LIMIT的上限,这种做法在有ORM框架做二次加工时容易失效,双端检查更稳妥。另外,用不等号组合表达区间时,建议在代码里保留清晰的注释,说明每个比较的业务含义,半年后维护的人会感谢你。
再补充一个细节:如果参数类型是包装类Integer或Long,还要先做null判断再比较。直接对null调用比较运算符会抛出NullPointerException,这属于校验代码自身的健壮性问题。Go语言在这点上更优雅,它的零值机制让比较天然安全:
func ValidatePageParam(page, pageSize int) error {
const maxPageSize = 100
// page 必须大于等于 1
if page < 1 {
return fmt.Errorf("page 不能小于 1,当前值: %d", page)
}
// pageSize 使用双端关系运算符夹住合法区间
if pageSize < 1 || pageSize > maxPageSize {
return fmt.Errorf("pageSize 必须在 1 到 %d 之间,当前值: %d", maxPageSize, pageSize)
}
return nil
}区间参数的交叉校验与组合条件
分页参数只是入门,真正的进阶场景是多个筛选条件之间的关系校验。典型例子包括:起始时间不能晚于结束时间、最低价不能高于最高价、起始ID不能大于结束ID。这类校验的本质是比较两个同类型参数的大小关系,用大于运算符就能表达:
public static void validateRange(Long minPrice, Long maxPrice,
LocalDate startDate, LocalDate endDate) {
// 价格区间:下界不能超过上界
if (minPrice != null && maxPrice != null && minPrice > maxPrice) {
throw new IllegalArgumentException("minPrice 不能大于 maxPrice");
}
// 时间区间:开始日期必须早于或等于结束日期
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
throw new IllegalArgumentException("startDate 不能晚于 endDate");
}
}这里有个值得注意的写法技巧:先判断两个参数都非空,再做大小比较。因为可选参数允许只传一端,只传minPrice意味着价格不设上限,这是合法语义。如果把非空判断漏掉,等于强制要求用户成对传入参数,接口的灵活性就丢了。此外要留意等于关系的归属:minPrice == maxPrice通常应该放行,表示精确匹配某个价格,所以判断条件用的是严格大于而不是大于等于。时间区间同理,开始和结束是同一天往往代表查一整天的数据。
组合条件的校验顺序也有讲究。建议按照参数的独立性排列:先校验单个参数自身的取值范围,再做跨参数的关系比较,最后才涉及业务规则(比如VIP用户允许更大的pageSize)。这样一旦校验失败,报错信息能精确定位到具体是哪个参数、哪条规则不满足,排查问题时不需要在一堆嵌套条件里打转。
校验失败后的统一处理与工程化建议
关系运算符抛出的异常不应该裸露给前端。更工程化的做法是定义统一的错误码体系,把IllegalArgumentException这类异常转换成结构化的响应体:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleIllegalArg(IllegalArgumentException e) {
Map<String, Object> body = new HashMap<>();
body.put("code", 40001);
body.put("message", e.getMessage());
return ResponseEntity.badRequest().body(body);
}
}这样前端拿到的永远是code加message的JSON结构,而不是一串吓人的堆栈信息。对于Spring项目,还可以配合@Valid注解和自定义约束注解,把@Min(1)、@Max(100)声明式地挂在字段上,让框架自动完成关系运算判断。注解方式的好处是校验规则和字段定义放在一起,一眼就能看出约束;缺点是跨字段的比较(如minPrice对maxPrice)需要写自定义校验器,稍显繁琐,此时手写if判断反而更直观。
最后强调一个边界思维的要点:预校验通过不代表SQL绝对安全,它只是过滤了明显非法的输入。像注入攻击需要参数化查询来防,深分页性能问题需要游标方案来解,关系运算符校验解决的是值域和逻辑关系层面的合法性。把这几层防御叠加起来,分页接口才算真正经得起生产环境的考验。