做后端开发这些年我见过不少“前端校验做得漂漂亮亮后端却像没装门锁”的项目。拿一个典型的订单系统举例前端把手机号、金额、地址都校验得滴水不漏提示文案写得比产品文档还贴心可一旦有人跳过页面直接用接口提交数据垃圾数据照样大摇大摆地进了库。问题不在于前端校验写得不够好而在于整个系统对数据校验的理解出了问题——好像前端校验过了数据就是安全的。数据校验这件事我越来越倾向于把它看成一个多层防御问题。从用户在页面上输入到数据真正落库至少应该有三道独立的防线前端校验、后端校验、数据库约束。这篇内容就把这三道防线逐个拆开重点回答“为什么只做前端校验很危险”以及每一层到底该承担什么职责。适合刚接触后端开发、正在写全栈项目或者在做接口时拿不准校验该放哪一层的开发者。1. 前端校验只是用户体验设计不是安全防线很多人把前端校验当作“防错机制”来用觉得页面拦住了非法输入后端就没危险了。这个认知在项目早期特别容易形成因为演示的时候数据确实看起来都很干净。但前端校验的真实定位从来都不是安全边界。1.1 前端校验的本质是“提示”而不是“拦截”前端校验解决的是“用户填写效率”问题。手机号少一位页面马上标红密码两次不一致提交按钮直接置灰邮箱格式不对光标还没离开输入框就弹提示。这些都是在帮助用户第一时间修正错误减少等待、降低无效请求对服务器的消耗。换句话说它是体验优化不是安全机制。危险的地方在于前端校验代码运行在用户的浏览器里用户不仅能看见还能改、能跳过。你写的正则表达式、必填判断、长度限制对任何愿意花两分钟打开开发者工具的人来说都只是一段可以随时绕过的脚本。我用一个生活化的类比来解释这件事前端校验就像餐厅门口的迎宾员看到穿拖鞋的客人可以礼貌地提示一句“我们这边有着装要求”但客人真想进去或者直接从后厨绕进来迎宾员一点办法都没有。数据也一样页面只是入口之一不是唯一入口。1.2 浏览器里绕过前端校验的四种常见姿势前端校验被绕过的路径远比你想象的简单常见的不外乎这四种禁用 JavaScript。浏览器设置或 DevTools 里可以一键停用脚本关掉之后所有onsubmit、oninput、onchange里写的校验逻辑全部失效。用开发者工具改页面。删掉input上的required属性或者在 console 里把validatePhone之类的函数直接替换成永远返回 true 的空函数。抓包重放。手机或浏览器发出请求时用抓包工具把请求拦下来篡改参数后再放行。前端的校验在这条路径里根本没有参与。命令行直接调接口。用 curl、Postman、脚本等工具向后端接口发送请求。表单页面压根没被加载前端代码一行都不会执行。你可能觉得“普通用户不会这样做”攻击者也不会。但对接口设计来说不能假设调用方是友好的人。接口一旦对外公开它就会被 APP、第三方系统、内部定时任务、爬虫脚本同时调用这些调用方统统不走你的前端页面。2. 第二道防线后端接口校验到底该验什么后端校验才是真正意义上的“门卫”。它拿到的是用户提交的原始数据不依赖任何前端页面逻辑。凡是属于后端接口的职责就不能因为“前端已经做过了”而省略。后端校验不是简单地加几个if而是分三个层次来设计和实现。2.1 参数格式校验先验证数据的“形状”后端接收参数之后第一件事是校验参数的类型、长度、格式和边界值。比如订单号不能包含非法字符手机号必须匹配正则金额必须是正数且小数位不能超过两位分页参数不能为负数这些是最基础的表层校验。有框架的情况下这部分应该尽量用框架能力去覆盖。Java 生态里的 Bean Validation 配合 Spring Boot可以用注解方式在 DTO 字段上声明规则框架在进入 Controller 方法之前自动完成校验public class RegisterDTO { NotBlank(message 手机号不能为空) Pattern(regexp ^1[3-9]\\d{9}$, message 手机号格式不正确) private String phone; NotBlank(message 密码不能为空) Size(min 8, max 32, message 密码长度需在8到32位之间) private String password; } PostMapping(/register) public Result register(Validated RequestBody RegisterDTO dto) { return userService.register(dto); }校验不通过时框架会抛出MethodArgumentNotValidException配合一个全局异常处理器就能统一返回 400 和对应的错误信息不用在每个方法里堆if...else。不用 Java 也没关系核心思想是一致的接口的入口处必须先做一次完整的参数校验。我在一些老项目里见过更随意的做法把校验逻辑散落在 Service 层里同一个 DTO 被多个接口复用A 接口校验了B 接口忘了数据就从 B 接口漏进去了。所以我的建议很朴素校验必须放在每个对外接口都能覆盖到的地方最好通过框架注解在 Controller 层统一完成而不是靠程序员记住。2.2 业务规则校验验证数据“合不合理”格式正确不代表业务上能接受。订单金额必须大于 0库存不能为负用户余额够不够支付优惠券是否已经使用当前订单状态是否允许取消这些规则需要结合数据库里的实际数据才能判断。这类校验通常放在 Service 层因为需要查询上下文而不是简单的正则能覆盖。举个例子我写过一个创建订单的逻辑def create_order(product_id, quantity, user_id): if quantity 0: raise ValidationError(购买数量必须大于0) product get_product(product_id) if product is None: raise ValidationError(商品不存在) if product.stock quantity: raise ValidationError(库存不足) # 继续走创建订单的逻辑业务校验里有一个容易被忽略的坑校验顺序。正确的顺序应该是先鉴权、再参数校验、再业务校验。如果一个未登录用户直接调接口不应该让他通过“商品不存在”“库存不足”这类业务报错来反推系统里有什么数据。业务校验失败时对外返回的信息也不应该暴露太多内部细节统一提示“当前操作无法完成”具体原因记录到服务端日志里就好。2.3 权限与归属校验防止数据越权权限校验是后端独有的职责前端根本无法承担。垂直越权是普通用户调了管理员的接口水平越权是 A 用户通过修改 URL 里的 ID 去操作 B 用户的资源。这两种情况都不是“前端隐藏入口”能解决的因为隐藏入口只是增加了一点使用成本接口本身依然是开放的。后端必须在进入业务逻辑之前校验身份和资源的归属关系。比如订单详情接口除了校验订单 ID 格式还必须校验“这个订单的 owner_id 等于当前登录用户的 ID”否则直接返回 403。有些团队会把权限校验做成注解例如RequiresPermissions、PreAuthorize但也有大量项目是手写一个checkOwnership(resource_id, current_user_id)方法在 Service 层第一行调用。不管哪种实现方式原则都一样权限判断要成为每个涉及资源操作的接口的必经步骤而不是想起来才加。3. 数据库约束被忽视的最后一道实体锁应用层做再多校验也挡不住两种情况代码本身的 bug以及并发场景下的时间差。数据库约束是最后一道防线它不属于任何业务代码表结构只要建对了它就无条件执行。3.1 数据库层能兜住哪些问题数据库层能兜住的问题归纳下来就四类字段缺失、数据重复、引用完整性、基本取值范围。实现手段就是 NOT NULL、UNIQUE 唯一索引、FOREIGN KEY 外键、CHECK 约束再加上字段类型和长度的严格控制。建表时就应该把这些约束写清楚而不是等出问题再补CREATE TABLE users ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, email VARCHAR(100) NULL, age INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone), CONSTRAINT ck_age CHECK (age 0 AND age 150) );这里我重点讲一个真实场景手机号唯一性。注册接口无论前端怎么校验后端怎么“先查后插”都存在一个时间窗口——两个请求同时查到“手机号不存在”然后同时插入。如果数据库没有唯一约束两条数据都会成功落库如果有唯一索引第二条插入就会抛冲突异常由后端捕获后转成“该手机号已注册”的提示。很多“查了还是重复”的问题本质不是查询逻辑写得差而是应用层的校验和数据库约束没有配合好。应用层负责提示数据库负责兜底这层配合到位了重复数据才能被真的挡住。3.2 数据库层兜不住哪些问题数据库不是万能的有三类问题它管不了。第一复杂的业务规则。比如“订单金额超过 5000 需要主管审批”“优惠券不能和满减叠加使用”这类逻辑强行用触发器或约束写在数据库里会非常痛苦维护成本极高也不便于后续调整规则。这类状态机逻辑应该留在业务层。第二数据库版本和功能本身的坑。有个很典型的例子MySQL 8.0.16 之前CHECK 约束只是被解析但并不会真正执行。很多老项目建表时写了 CHECK开发环境测试时也没发现异常换到生产环境后这个约束形同虚设。所以不要以为“写了 CHECK 就一定有约束”要确认数据库版本是否支持最好通过SHOW CREATE TABLE看一遍实际生效的建表语句。第三数据库报错信息是给程序员看的不是给用户看的。直接把“Duplicate entry 12345678901 for key uk_phone”抛给用户既不安全也不友好。所以数据库约束承担的是拦截脏数据的职责翻译报错、提示用户的活还要后端来做。数据库层不是用来替代业务校验的它的定位是“默认不信任任何人包括自己的业务代码”。应用层漏了一个校验数据库能拦住数据库也没拦住那数据就是真的脏了后面所有下游依赖都会被污染。4. 三道防线如何配合以及一次典型的“穿透”把三道防线拆开看都不难理解实际开发中容易出问题的是它们之间的配合。边界不清楚就会出现“觉得别人做了结果大家都漏了”的情况。4.1 从输入到落库的完整校验链路一次正常的数据写入经历的过程大致是前端做格式提示HTTP 请求到达后端后网关或框架做身份认证Controller 解析参数并做参数格式校验Service 做权限校验和业务规则校验DAO 把数据写入数据库数据库约束做最终检查最后把结果返回给调用方。这里有一个原则叫“快速失败”越早发现错误浪费的资源越少。前端能拦住的低级错误不要发给后端后端能拦住的格式错误不要拖到数据库层。但反过来不能因为“前面已经有人拦了”就彻底放松——每一层都要默认上一层可能漏掉然后把自己的那部分校验老老实实做完。防线核心职责典型手段失败后果前端校验提升填写体验拦截明显低级错误必填、正则、长度限制可被轻松绕过等同于无防线后端校验独立的格式校验、业务校验、权限校验参数校验框架、Service 层判断非法数据进入数据库或越权操作数据库约束兜底数据完整性与唯一性冲突NOT NULL、UNIQUE、FOREIGN KEY、CHECK脏数据长期存在污染所有下游4.2 为什么只做前端校验很危险一条故障传导链假设一个系统只有前端校验没有后端校验也没有数据库约束会发生什么攻击者或普通用户不需要打开页面直接用命令行向注册接口提交phoneabcpassword123。前端校验代码完全没有机会执行。后端接口收到后因为没有任何校验逻辑直接进入业务方法把phoneabc写进数据库的 varchar 字段返回“注册成功”。第二天运营发现库里有几千条非法手机号导出报表时格式错乱用户在登录页用手机号匹配不到任何记录。这时候想修复已经晚了。虽然可以先写脚本清理脏数据再加后端校验和数据库约束但脏数据可能已经被其他系统同步、被报表统计引用、甚至被写入历史快照。数据修正的成本往往比一开始多写两层校验要高得多。所以“只做前端校验”的危险在于数据入口从来不止页面一个。任何直接对接后端的调用方都在绕开前端校验的情况下给系统喂数据。这样的数据一旦落库排查和修正成本是最高的。5. 踩坑案例复盘以及一套可以照抄的落地清单理论说完说几个我在项目和排查案例中实际遇到过的坑。细节做了脱敏但故障链路是完全真实的。5.1 并发注册同一手机号应用层“查了不存在”也会重复某注册系统前端校验、后端校验都写了“手机号是否已存在”的判断逻辑是先查库不存在再插入。某天运营发现 users 表里出现两条相同手机号的记录。排查后发现是并发注册A 请求查库发现不存在B 请求也查库发现不存在A 插入成功B 插入也成功。修复方案是两步走先给表加上UNIQUE KEY uk_phone(phone)让数据库从根上保证唯一再把插入时抛出的重复键异常捕获转成“该手机号已注册”的提示返回给用户。这个案例是最典型的“三层校验缺一不可”的证据。后端查重做得再细也防不住时间窗口数据库约束只要存在这一整类问题就不存在。5.2 导入功能绕过前端Excel 和 CSV 直接调用后端口另一个项目里有批量导入用户数据的功能导入文件里存在空行、超长用户名、非法手机号。前端只对上传动作做了文件类型校验导入接口本身几乎没有逐行数据校验。结果数据入库之后报表统计错位用户信息显示异常。排查后的补丁方案是在导入接口里对文件的每一行做完整校验并且把错误行号和原因回传给用户比如“第 12 行手机号格式错误”。这样运营可以快速修正文件而不是面对一整片脏数据无从下手。这条经验告诉我们凡是存在“非页面入口”的接口比如定时任务、批量导入、开放平台回调都是校验空档的高发区。这些接口的数据来源不是用户页面不能默认“数据源可信”必须单独过一遍完整校验。5.3 一套可复用的校验落地清单最后给出一份我现在做项目时基本会照抄的校验清单每个对外接口都必须在入口完成参数格式校验优先用框架注解或统一校验工具。涉及唯一性的字段数据库表中必须建 UNIQUE 约束应用层查重只是辅助。金额、库存、状态等关键字段必须在 Service 层做业务校验并且先权限校验再业务校验。数据库字段类型、长度、NOT NULL、CHECK 要按最终业务口径来设计不能图省事统一用 varchar。所有校验失败统一返回结构化错误码错误信息区分“用户可看”和“日志可看”两套。测试用例里专门准备一批“恶意参数”比如超长字符串、负数、非法枚举、跳过鉴权确保每一层真的会报错。日志要记录校验失败的请求来源和参数但密码、手机号等敏感字段必须先脱敏再记录。我个人现在的习惯是新建表时先把 NOT NULL、UNIQUE、CHECK 这些约束当“需求的一部分”写进 DDL 评审里而不是等出问题了再补。写接口时先看有哪些入口会调用它把所有入口都覆盖之后再看需不需要额外加前端提示。这种习惯的养成也不需要多高的技术只是在每次写代码前多问自己一句如果这一次调用完全绕过了页面我的系统还能拦住坏数据吗