数据验证
概述
数据验证是保证系统数据正确性、一致性与安全性的关键环节。有效的验证应分层实施:从客户端、API 入参、应用服务、ORM 到数据库约束,并结合事务保证一致性。
验证分层与职责
- 客户端验证:提升体验,减少无效请求;不可作为安全边界。
- API 入参验证:拦截格式与基本规则错误(必填、类型、长度、枚举、正则)。
- 应用/领域校验:跨字段、跨资源、复杂业务规则(如“开始时间 < 结束时间”)。
- ORM 层验证:模型级规则、钩子、序列化/反序列化、默认值。
- 数据库约束:最终一致性与强约束(NOT NULL、CHECK、UNIQUE、FK、触发器)。
建议:所有层都要做,但以数据库约束作为最后的“地板”,防止绕过。
关系型数据库层验证(MySQL/PostgreSQL)
字段与表级约束
sql
-- PostgreSQL 示例:用户表的强约束
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
password_hash TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- 唯一性
CONSTRAINT uq_users_email UNIQUE (email),
-- 值域校验
CONSTRAINT ck_users_status CHECK (status IN ('active','inactive','blocked'))
);
-- 复合唯一约束(同一商店下 SKU 唯一)
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
store_id BIGINT NOT NULL,
sku VARCHAR(64) NOT NULL,
name VARCHAR(255) NOT NULL,
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
CONSTRAINT uq_store_sku UNIQUE (store_id, sku),
CONSTRAINT fk_products_store FOREIGN KEY (store_id) REFERENCES stores(id) ON DELETE CASCADE
);
-- 可延迟约束(事务提交时统一检查,仅 PostgreSQL)
ALTER TABLE products
ADD CONSTRAINT ck_price_non_negative CHECK (price_cents >= 0)
DEFERRABLE INITIALLY DEFERRED;
要点:
- NOT NULL/DEFAULT:基础完整性;
- CHECK:数值范围、字符串模式、跨列简单表达式;
- UNIQUE/PK:强制唯一;
- FK:参照完整性与级联行为(CASCADE/RESTRICT/SET NULL)。
触发器与生成列(谨慎)
sql
-- 生成列:标准化邮箱的小写影子列,配合唯一索引
ALTER TABLE users
ADD COLUMN email_norm TEXT GENERATED ALWAYS AS (lower(email)) STORED;
CREATE UNIQUE INDEX uq_users_email_norm ON users(email_norm);
-- 触发器:更新时间戳
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END; $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
注意:触发器可强化一致性,但复杂触发器可能影响可维护性与性能。
MongoDB/NoSQL 层验证
集合级 JSON Schema 校验
javascript
// Mongo Shell / Driver:创建集合并启用 JSON Schema 验证
db.createCollection('users', {
validator: {
$jsonSchema: {
bsonType: 'object',
required: ['email', 'passwordHash', 'status'],
properties: {
email: { bsonType: 'string', pattern: "^.+@.+\\..+$" },
passwordHash: { bsonType: 'string', minLength: 20 },
status: { enum: ['active','inactive','blocked'] },
createdAt: { bsonType: 'date' }
}
}
},
validationLevel: 'strict', // off | moderate | strict
validationAction: 'error' // warn | error
});
配合唯一索引确保唯一性:
javascript
db.users.createIndex({ email: 1 }, { unique: true });
Mongoose 模型验证(应用侧 + 索引)
javascript
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
lowercase: true,
trim: true,
match: /.+@.+\..+/
},
passwordHash: { type: String, required: true, minlength: 20 },
status: { type: String, enum: ['active','inactive','blocked'], default: 'active' }
}, { timestamps: true });
userSchema.index({ email: 1 }, { unique: true });
// 自定义/异步校验
userSchema.path('email').validate(async function(value) {
const count = await this.constructor.countDocuments({ email: value, _id: { $ne: this._id } });
return count === 0;
}, 'Email 已存在');
module.exports = mongoose.model('User', userSchema);
ORM 层验证
Prisma + Zod(推荐组合)
ts
// schema.prisma(唯一约束 + 枚举)
model User {
id BigInt @id @default(autoincrement())
email String @unique
displayName String
passwordHash String
status Status @default(active)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum Status { active inactive blocked }
ts
// zod 校验入参
import { z } from 'zod';
export const createUserDto = z.object({
email: z.string().email().max(255),
displayName: z.string().min(1).max(100),
password: z.string().min(8),
});
Sequelize 内置验证
js
const { DataTypes, Model } = require('sequelize');
class User extends Model {}
User.init({
email: {
type: DataTypes.STRING(255),
allowNull: false,
unique: true,
validate: { isEmail: true, len: [1, 255] }
},
displayName: { type: DataTypes.STRING(100), allowNull: false },
passwordHash: { type: DataTypes.TEXT, allowNull: false, validate: { len: [20, 9999] } },
status: { type: DataTypes.ENUM('active','inactive','blocked'), defaultValue: 'active' }
}, { sequelize, modelName: 'User', timestamps: true });
TypeORM + class-validator
ts
import { Entity, PrimaryGeneratedColumn, Column, Unique } from 'typeorm';
import { IsEmail, Length, IsIn } from 'class-validator';
@Entity('users')
@Unique(['email'])
export class User {
@PrimaryGeneratedColumn('increment') id: number;
@Column({ length: 255 })
@IsEmail()
email: string;
@Column({ length: 100 })
@Length(1, 100)
displayName: string;
@Column('text')
@Length(20)
passwordHash: string;
@Column({ type: 'varchar', length: 20, default: 'active' })
@IsIn(['active','inactive','blocked'])
status: string;
}
应用层入参验证(Joi 示例)
js
const Joi = require('joi');
const createUserSchema = Joi.object({
email: Joi.string().email().max(255).required(),
displayName: Joi.string().min(1).max(100).required(),
password: Joi.string().min(8).required()
});
function validate(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false, stripUnknown: true });
if (error) {
return res.status(400).json({
error: 'VALIDATION_FAILED',
details: error.details.map(d => ({ field: d.path.join('.'), message: d.message }))
});
}
req.validated = value;
next();
};
}
业务规则、交叉字段与唯一性
- 交叉字段:开始时间 < 结束时间;折扣价 < 原价;库存 >= 0。
- 存在性:引用的资源必须存在;删除时检查被引用计数。
- 唯一性:不要仅依赖“先查再插”,要以数据库唯一索引兜底,结合事务/幂等键。
ts
// 以唯一约束落实最终一致性(伪代码)
try {
await prisma.user.create({ data: { email, displayName, passwordHash } });
} catch (e) {
if (e.code === 'P2002' /* Prisma Unique constraint failed */) {
throw httpError(409, 'Email 已存在');
}
throw e;
}
数据清洗与规范化
- 字符串:trim、去重空格、归一化大小写(email 小写)。
- 电话/地址:E.164 规范化,统一区域码。
- 时间:统一使用 UTC 存储,界面按时区渲染。
- 金额:以最小货币单位整型存储(如分)。
- 文本安全:对富文本做白名单清洗,避免存储 XSS 载荷。
事务与并发一致性
- 将“验证 + 写入”置于同一事务中;
- 利用数据库唯一约束作为并发竞争的最终裁决;
- 乐观锁:版本号字段 version,每次更新 WHERE version = oldVersion;
- 可延迟约束(PostgreSQL):跨步骤写入后在提交时统一检查。
性能考量
- 约束与索引会带来写入开销:为高基数、高频写列建立必要而非过度的索引;
- 批量导入:可分批、关闭/延迟二级索引构建,导入后再建索引;
- 校验成本前移:能在应用层提前失败的尽早失败,减少数据库往返;
- 大表新增非空列:先加可空列 -> 回填数据 -> 加默认 -> 改为 NOT NULL。
迁移与兼容策略
- 向前兼容:先在代码支持新旧字段,再变更数据库;
- 分步迁移:
- 添加可空列/新枚举值;
- 回填与双写;
- 上线强约束/切换读路径;
- 移除旧字段;
- 验证:灰度与回滚预案,监控唯一冲突与约束错误率。
测试策略
- 单元测试:DTO/Schema 校验边界与错误消息;
- 集成测试:真实数据库的唯一约束、外键与事务回滚;
- 性能测试:批量写入时的约束开销与锁竞争;
- 模糊/属性测试:随机数据覆盖极端输入。
常见错误映射
- 400 Bad Request:入参格式/业务校验失败;
- 409 Conflict:唯一约束冲突(重复键);
- 422 Unprocessable Entity:语义正确但业务不可达(如状态流转非法);
- 500 Internal Server Error:未知数据库错误(记录详细日志,隐藏内部细节)。
最佳实践清单
- API 层永远做入参校验,数据库永远加最终约束;
- 所有“唯一性”由唯一索引落实;
- 使用事务包裹多步写入,失败要么全部回滚;
- 数据写入前做规范化(大小写、空白、单位、时区);
- 对外返回稳定的错误码与可读信息,内部记录详细上下文;
- 迁移采用小步快跑 + 回滚预案;
- 在 CI 中加入 Schema 变更与迁移脚本的自动检查与回归测试。