资讯详情

数据校验实战:前端、后端、数据库三层防线如何防住脏数据

📅 2026/10/9 12:40:33 | 华诺云谱 👁 阅读
数据校验实战:前端、后端、数据库三层防线如何防住脏数据
1. 为什么看起来简单的数据校验最容易翻车先说说我自己的经历。之前负责一个偏内部的订单管理系统上线前自测各种功能都跑得通数据校验这一步我更是没太当回事——无非就是必填没填格式不对超长截断嘛表单上已经被框架拦了一部分后端再补个判断能出什么问题结果真有那么一次上游系统调用我们的接口时把一个金额字段传成了12abc前端页面倒是没报错因为是从接口直接取数渲染的我们后端因为只做了非空校验没做类型校验这个脏数据就这么一路落到了数据库里。后续对账的时候发现对不上查了两天最后定位到是入库那一行数据把整条链路拖住了。从那之后我就意识到一个问题数据校验看起来是编码里最没技术含量的一环但它恰恰是系统出问题最多、排查成本最高的环节之一。它不像是高并发、分布式这类听着就有挑战的话题但脏数据一旦漏过去产生的连锁反应往往比接口报错更隐蔽、更难以收拾。这篇东西我想把自己在项目中反复用过、也反复踩过的一些数据校验方法按防线层次梳理一遍。适合谁看主要是刚工作不久、正在写接口或表单的新人也包括那些想系统性梳理校验思路的开发者。内容会覆盖前端表单怎么拦、后端参数怎么验、数据库约束怎么兜底以及我在真实项目里遇到的几个容易忽略的盲区。每一条都是我实际写过的代码或者实实在在掉进去过的坑不是教科书目录。校验本质上是在划定信任边界。你信任用户到什么程度用户提交的数据哪些可以相信、哪些必须重新验证上游系统返回的数据是否默认可信一旦想清楚这些问题你就会发现校验不是一个单纯的技术动作而是一套防御策略。2. 前端校验用户说你没提示我的责任区2.1 HTML5内建校验够用但别指望它扛事现在做Web前端很多校验其实已经从HTML5标签里白捡了一大半。required、maxlength、typeemail、typenumber、pattern这些原生的属性摆在那里浏览器会自动帮你拦截一部分非法输入还能给出系统级的提示气泡。我自己写表单时这些属性仍然会加原因很简单几乎零成本而且能拦截一部分纯手误。比如一个手机号输入框我加了typetel和maxlength11用户从输入法里试出一堆字母的可能性就小很多。但这玩意儿抗不了什么真正的脏数据尤其是两种场景用户绕过界面直接请求接口Postman、脚本、爬虫都干得出来浏览器兼容性差异某些环境下pattern的提示文案太生硬用户根本看不懂。所以我的定位是HTML5内建校验是体验层的引导不是安全层的防线。它帮用户少犯错但系统不能靠它防错误。2.2 正则实战手机号、邮箱、身份证的常见误区真正到写代码的时候正则表达式是前端校验的主角。但正则是越写越容易出问题的东西我见过不少同事花半小时调一个邮箱正则最后还是在网上抄了一段万能的结果把合法地址给拦了。举几个实际踩过的例子。手机号校验国内用/^1[3-9]\d{9}$/这几年基本够用但千万别在这上面搞未来兼容去匹配所有号段运营商号段会更新你写死一个超长列表过两年就要改。用上文这个通用写法后面校验交给后端前端做的是格式层面的第一次拦截。邮箱校验我见过有人写一套完整RFC5322标准的正则长度超过100个字符看着很吓人实际用户根本遇不到那么刁钻的邮箱。更合理的做法是只检查基本结构/^[^\s][^\s]\.[^\s]$/它的意思是符号前后不能有空格且必须有点。这样拦下了90%的明显错误也放行了几乎所有合法邮箱。身份证校验这里有个容易掉进去的坑很多人只写了/^\d{17}[\dXx]$/这只管了长度和字符集完全没管日期合不合法、校验位对不对。真实项目中校验身份证至少要把出生日期部分提取出来做一次日期合法性判断然后按GB 11643的规则算校验位。否则像1990131这种不存在的日期照样能通过格式校验。我写过一次之后就把这段逻辑放进了公共函数前端后端共用避免两套规则打架。2.3 框架中的校验体验设计错误时机比错误文案更重要用了Vue或React之后很多人喜欢引入表单校验库。但我观察到一个现象团队的校验逻辑写了一大堆但用户真正操作起来还是觉得怎么报错这么晚为什么我输到一半就红一片。这本质上不是校验规则的问题而是校验触发时机的问题。我常用的策略是三种时机混用失焦blur时校验适合单个字段格式校验比如邮箱、手机号用户离开输入框再提示不会打扰输入过程提交时全量校验适合那些关联多个字段的规则比如开始时间不能晚于结束时间你没法在输入第一个字段的时候判断整体关系输入过程中轻量反馈只对格式错误做弱提示比如密码强度输入时实时更新但不上红字报错否则用户会很焦虑。这个设计逻辑其实和产品交互强相关不是纯技术问题。我经常说校验文案可以朴素但时机和位置必须让用户觉得这个系统懂我。你看很多用户抱怨系统不好用大半不是功能缺了而是校验提示方得太晚或者太莫名其妙。2.4 前端校验的三个原则聊了这么多前端校验这边我最后建议大家记住三条原则。原则一前端校验是为了用户体验不是为了安全。把安全寄托在前端校验上绕一个接口就穿了。原则二前端规则和后端规则必须同源。最好抽成同一份schema或者同一套正则常量两边引用。不然前端允许、后端拒绝或者反过来都会变成线上投诉。原则三不要在前端做业务规则校验的全部实现。比如用户当前积分是否足够抵扣这类校验依赖服务端实时数据前端只能做能不能输入这个数字的弱提示真正的判断必须留给后端。前端这边的代码我就不大段贴了因为不同框架写法差太多。核心思路是用户界面上的每一次报错都是为了让用户早点修正而不是为了拦截恶意数据。3. 后端校验真正说了算的那道门3.1 注解式校验最懒也最不容易漏的写法到了后端校验才算是真正进入正题。我给团队定的规矩是所有对外接口的入参第一道关卡必须过参数校验否则不要碰业务逻辑。这里我最常用的是注解式校验。拿Java生态举例Spring Boot Hibernate ValidatorJakarta Validation的组合是我用下来最顺手的。它最大的价值不是省代码而是把校验规则声明在字段旁边读代码的时候一眼就能看出这个字段的约束不太会漏。常见的有NotNull、NotBlank、Size、Pattern、Min、Max、Email还有自定义的Valid嵌套。一个典型的请求对象长这样public class CreateOrderRequest { NotBlank(message 订单号不能为空) Size(max 32, message 订单号长度不能超过32) private String orderNo; NotNull(message 下单用户ID不能为空) private Long userId; NotNull(message 金额不能为空) DecimalMin(value 0.01, message 金额必须大于0) DecimalMax(value 9999999.99, message 金额超出允许范围) private BigDecimal amount; NotBlank(message 收货地址不能为空) Size(max 200, message 收货地址长度不能超过200) private String address; // getter / setter 省略 }然后在Controller里加一个ValidPostMapping(/order/create) public Result createOrder(Valid RequestBody CreateOrderRequest request) { // 走到这里说明request里的字段已经通过了基础约束 // 剩下的才是真正的业务逻辑 }这样写的好处是非法参数根本进不了方法体你不需要在业务代码里写一堆if判断也不需要担心今天漏了个校验。3.2 手动校验当注解解决不了的时候怎么办注解式校验确实爽但它只能做无状态的单字段约束。遇到跨字段、依赖外部数据的校验它就有点力不从心了。举个例子创建订单时endTime必须晚于startTime这是两个字段之间的关系靠单个字段注解没办法表达。再比如优惠券ID是否可用、用户名是否已被注册这些需要查数据库注解也做不了。我的做法是在Service层建立一个显式的校验阶段在这个阶段里把所有需要看别人脸色的规则集中处理。public void validateCreateOrder(CreateOrderCommand command) { // 先做基础非空校验 if (command.getStartTime() null || command.getEndTime() null) { throw new BizException(ErrorCode.PARAM_ERROR, 开始时间和结束时间不能为空); } // 再做时间关系校验 if (command.getEndTime().isBefore(command.getStartTime())) { throw new BizException(ErrorCode.PARAM_ERROR, 结束时间不能早于开始时间); } // 再做外部依赖校验 if (!userService.isUserExists(command.getUserId())) { throw new BizException(ErrorCode.USER_NOT_EXIST, 用户不存在); } // 查重校验 if (orderMapper.countByOrderNo(command.getOrderNo()) 0) { throw new BizException(ErrorCode.ORDER_NO_EXISTS, 订单号已存在); } }有人可能问为什么不把这些也放进注解里可以Spring的Constraint支持自定义注解但复杂校验放进自定义注解后逻辑藏在注解处理器里调试和维护的体验并不好而且有些校验还需要注入Service搞不好还会踩循环依赖的坑。我个人的原则是注解处理90%的静态格式约束手动校验处理剩下的动态、跨字段和外部依赖约束两种手段共存而不是互相替代。3.3 校验失败时的返回体设计校验规则的实现方式解决了拦不拦的问题但还有一个经常被忽略的问题被拦下来之后接口返回给调用方什么我看过太多项目校验失败返回一个笼统的参数错误调用方根本不知道哪里错。联调的时候只好一个个字段试浪费大量时间。我的做法是返回一个字段级别的错误列表而不是单个错误字符串。一个比较通用的返回结构长这样{ code: 40000, message: 参数校验失败, data: { fieldErrors: [ { field: orderNo, message: 订单号不能为空 }, { field: amount, message: 金额必须大于0 } ] } }前端拿到这个结构可以精准地把错误渲染到每个表单字段下面用户体验会好很多。后端定义这个结构时我建议把fieldErrors设计成一个集合因为一次提交同时错两三个字段是很正常的事只返回第一个错误会逼着用户改完一个再提交一次很浪费时间。3.4 防御性编程参数到了Controller之前做了什么还有一点想重点提醒你以为你在Controller上加了Valid其实你只能保证Controller方法接收到的是过了注解的参数但不能保证Controller之前没有别的东西把参数污染了。比如过滤器、拦截器、AOP切面任何一个环节都有可能在逻辑里修改参数尤其是那种顺手把入参里某个字段置个默认值的操作一不留神就改出一个格式非法的值。所以我在项目里坚持一个习惯在Controller入口和Service入口各做一次校验。Controller入口用注解校验防外部传参Service入口做业务校验防内部调用错误。有人觉得这是重复代码但我的真实经验是Service层的校验才是最后保命的因为Service经常会被别的Service调用而不是只被Controller调用。如果一个Service函数假设入参已经校验过了但调用方忘了校验脏数据就可能溜进去。4. 数据库约束最后一道防线也得管4.1 非空、唯一、外键的取舍服务端校验做得再完善我依然建议在数据库层面把该有的约束加上。原因很朴素数据库表的存活周期往往比服务代码长得多。你今天这个接口校验得严严实实明天换个团队接手新写了一个接口忘了加校验直接把空值insert进来等到数据分析的时候才发现表里躺着一堆垃圾。所以我会给每张核心表加上这些基础约束NOT NULL凡是业务上必须有值的字段建表时就必须NOT NULL。别想着先用着反正代码会拦代码会换表结构不会经常换。UNIQUE业务上要求不重复的字段比如订单号、用户手机号如果手机号做唯一标识加上唯一索引。这一招能挡掉并发场景下的重复提交——两个请求同时进来后端查重都没查出来数据库唯一索引直接拦一个。FOREIGN KEY如果团队规范允许我会加外键来保证引用完整性。但不得不承认很多团队为了分库分表或者性能考虑会刻意不加外键。不加外键的话必须在代码层面做好存在性校验并且接受删了一条父记录导致子记录悬空的风险。4.2 数据类型与长度别在存储层省钱数据库字段的类型和长度选择本身就是一种校验。很多开发者建表时不够上心用varchar(255)一把梭所有的字符串字段都这么建。结果就是该短的长了该长的又不够。我自己建表时的一些习惯手机号、订单号这类固定短字符串用varchar(20)~varchar(32)不要给太多余量描述、备注、富文本才用text或varchar(2000)以上金额一律用decimal禁止用float或double否则精度问题迟早让你吃大亏状态字段能用tinyint就不要用varchar存success/failed节省空间也方便索引日期时间用datetime如果一个系统要跨时区请让服务和数据库都统一使用UTC存储。这些看起来和校验方法没关系但它们本质上是在存储层定义了一个字段合法取值的范围越短、越严格的数据类型越不容易存进脏数据。4.3 触发器能用但尽量别滥用数据库触发器也能做校验比如插入前检查某个字段不能为空否则直接抛异常。我见过老项目这么干过它能保证绕过所有应用层的写入都过不了触发器这一关。但我的态度是触发器做校验是双刃剑能不用尽量不用。原因有三个触发器里的逻辑对应用层是隐形的。你排查问题时看到一条insert语句怎么写都对但死活插入失败最后发现是触发器在作怪排查成本极高。触发器难以测试、难以版本管理。应用代码在Git里好好管着触发器散落在数据库脚本里时间一长根本没人敢动。高并发写入时触发器还会增加隐性的锁开销。我的建议是数据库约束非空、唯一、类型、长度能做的就用约束触发器只留着做审计日志类需求别拿去当业务校验的主力。5. 校验中的认知盲区我踩过的坑和绕过的弯5.1 盲区一把校验当业务逻辑开始写代码的头两年我经常把业务规则和校验混在一起。比如订单金额不能超过用户当前余额我当时把它写在了参数校验里用了一个自定义注解还备注参数校验余额不足时返回参数错误。后来某位同事提醒我这本质上是个业务规则不是参数校验。因为余额是动态的订单金额本身可能完全合法只是用户当前没有这么多钱。后来我把这类逻辑统一挪到了Service层用业务异常来响应才把格式不对和业务不满足彻底分清了。这给我们一个很重要的启示校验方法的选择取决于你校验的对象是什么。静态格式、长度、类型归参数校验动态状态、额度、权限归业务校验。混在一起代码短期能跑长期维护起来非常痛苦。5.2 盲区二错误信息太技术有一次我负责的模块接了个内部系统的报警说某个接口大量返回SQLIntegrityConstraintViolationException。我查了下日志发现是我们后端校验漏了让一条null的userId走到了数据库层数据库直接报了一个异常然后这个异常被全局异常处理器原样返回给了前端。问题在于这个报错信息对调用方来说完全是天书。调用方是一个不太懂技术的合作方的系统他们看到这种报错根本不知道该怎么办。后来我做了两件事一是把漏掉的NotBlank补上二是把全局异常处理器改了数据库约束类异常一律转成参数不合法请检查必填字段这种人类能看懂的话同时把详细异常记到日志里。对外友好对内详情这才是异常处理的正确姿势。5.3 盲区三不同端校验规则不同步前端写一套正则后端写一套正则时间一长肯定出现不一致的情况。我遇到过一个典型场景前端限制手机号11位后端只校验非空结果用户在APP端输入了一个12位手机号前端报错请输入11位手机号但调用方直接调API传了个12位的号码后端竟然接受了存进了表里后续短信发送失败才暴露出来。后来我们团队把校验规则抽到了一个公共的规则配置里至少正则表达式是同一个来源。前端的纯展示校验归前端但格式正则、长度上限这些硬规则前后端必须保持一致最好由技术负责人统一维护一份规则字典两边生成各自的代码或配置。5.4 盲区四只校验必填项不校验语义还有一种常见的偷懒做法只校验非空和格式不校验语义合理性。比如年龄字段用户填了200格式上合法数字、在int范围内但明显不合理商品库存字段用户传了99999999接口也接受了。这些数据虽然不违法但会让业务报表变得很诡异。我的建议是对每个关键字段都要问一句这个字段的合理取值范围是多少比如年龄限到120库存限到一个业务可接受的上限金额设一个最大阈值。这些约束不复杂但能挡掉大量格式合法但脑子不正常的数据。6. 校验框架怎么选别为了校验引入一辆坦克6.1 从零手写与引入框架的边界聊到框架就会有人问我能不能不引入校验框架自己写一堆工具方法答案是可以但你要先想清楚边界。我见过一个老系统全项目都是手写校验工具类工具类里堆了几百个方法checkMobile、checkEmail、checkIdCard、checkDateRange……新来的员工每次都要翻这个工具类找对了还好找错了或者漏传参数照样产出脏数据。这个阶段其实就到了该引入框架的点了。引入通用校验框架的好处是约束声明在数据模型上校验逻辑可复用错误信息统一社区一直在维护。但坏处是如果你的项目只有两三个接口、十几个字段引框架反而是一种过度设计还要额外处理框架版本、架构适配的问题。我的判断依据是如果项目里需要校验的字段超过20个或者接口数量超过10个就值得引入框架否则手写几个工具函数更快。6.2 主流校验工具的使用习惯对比这里我给你一张我实际对比过不同语言/生态习惯的表格都是我在不同项目里用过的方案不是说哪个一定最好大家按场景挑技术栈常用方案适合场景我的使用感受Java后端Hibernate Validator Spring Boot注解式校验、Bean规范成熟最顺手声明式规则很清晰配合全局异常处理效果很好JavaScript/Node后端Joi / Yup / Zod动态schema校验、前后端共享校验规则Zod的类型推导体验好Yup和Joi偏老牌选一个就别换了Python后端Pydantic FastAPI/Flask请求模型定义与校验一体Pydantic的模型即校验写起来很自然推荐优先用前端表单Formik Yup / React Hook Form Zod表单状态与校验联动重点是控制好校验触发时机别让校验规则全在组件里堆着Go后端go-playground/validator结构体标签式校验习惯和Java注解挺像但生态里更依赖手写判断的也不少看过不少项目之后我发现一个规律团队最熟的框架就是最好的框架比纠结哪个更强大重要得多。因为校验框架的迁移成本高而且一旦用了不熟的框架很容易写出绕过框架的手写逻辑反而带来更多混乱。6.3 一个完整的校验实现例子顺着上面的思路我找一个常用场景把校验方法串起来用户注册接口要校验用户名、手机号、密码和邮箱。前端我会这样做用户名失焦时检查长度手机号实时过滤非数字密码输入时弱提示强度提交时全量检查。这部分核心是体验// React Hook Form Zod 的注册表单示例简化 import { useForm } from react-hook-form; import { zodResolver } from hookform/resolvers/zod; import { z } from zod; const schema z.object({ username: z.string().min(2, 用户名至少2个字符).max(20, 用户名不能超过20个字符), mobile: z.string().regex(/^1[3-9]\d{9}$/, 请输入正确的手机号), password: z.string().min(8, 密码至少8位).max(32, 密码不能超过32位), email: z.string().email(请输入正确的邮箱地址).optional().or(z.literal()) }); function RegisterForm() { const { register, handleSubmit, formState: { errors } } useForm({ resolver: zodResolver(schema) }); // 省略渲染代码 }后端我用Java写入口Controller层用注解挡格式Service层做业务校验比如用户名查重public class RegisterRequest { NotBlank(message 用户名不能为空) Size(min 2, max 20, message 用户名长度需在2到20之间) private String username; NotBlank(message 手机号不能为空) Pattern(regexp ^1[3-9]\\d{9}$, message 手机号格式不正确) private String mobile; NotBlank(message 密码不能为空) Size(min 8, max 32, message 密码长度需在8到32之间) private String password; Email(message 邮箱格式不正确) private String email; }然后在Service里做业务规则校验public void register(RegisterRequest request) { // 业务校验用户名唯一 if (userDao.existsByUsername(request.getUsername())) { throw new BizException(用户名已被注册); } // 业务校验手机号唯一 if (userDao.existsByMobile(request.getMobile())) { throw new BizException(手机号已被注册); } // 再走密码加密与入库逻辑 }数据库这一层我给username和mobile都加了唯一索引。三层各做各的事谁都不越权但谁都不缺席。这样的组合我用了很多年很少再遇到脏数据静默入库的问题。最后再分享一个小技巧如果你们团队经常被字段到底怎么校验问来问去建议写一页内部Wiki把常见的字段标准定下来——手机号用哪个正则、密码长度多少、金额上限多少、时间格式统一成什么。这个Wiki会变成团队沟通的校准锚点省下大量在评审会上反复拉扯的时间。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑