必须在数据库层面加唯一索引才能真正防重复,unique验证器仅做查询校验,无法拦截并发插入、SQL直连或迁移脚本绕过;复合索引需注意字段顺序与NULL处理,验证器需手动拼条件并确保tenant_id已赋值。

ThinkPHP 模型里加唯一索引,unique 验证器不等于数据库唯一约束
验证器 unique 只做查询校验,不建数据库索引。它拦得住表单提交,拦不住并发插入、SQL 直连或迁移脚本绕过模型写入。真要防重复,必须在数据库层面加唯一索引。
实操建议:
- 先用
php think migrate:make create_users_table生成迁移文件,在up()方法里调用$table->unique(['email', 'tenant_id']) - 已有表用原生 SQL:执行
ALTER TABLE `users` ADD UNIQUE INDEX `uniq_email_tenant` (`email`, `tenant_id`) - 复合字段顺序影响索引生效范围——
['status', 'email']的索引无法加速只查email的 WHERE 条件
验证器 unique 复合字段怎么写才不漏查
默认 unique 只支持单字段,复合场景必须手动拼条件,否则会忽略其他字段限制。
常见错误现象:用户改资料时,验证器没排除自己那条记录,导致“不能改回原值”;或没带上租户 ID,跨租户误判重复。
立即学习“PHP免费学习笔记(深入)”;
实操建议:
- 用闭包写法:
['unique:users,email,0,id,tenant_id,' . $this->tenant_id]—— 第三个参数是排除的主键值(0 表示不排除),第四个是主键字段名,后面是额外 WHERE 条件键值对 - 更清晰的写法是用数组:
['unique' => ['users', 'email', null, 'id', ['tenant_id' => $this->tenant_id]]] - 注意:验证器里的
tenant_id必须已赋值,不能依赖未解析的请求参数(比如在save()前没 set 进模型)
MySQL 唯一索引遇到 NULL 怎么办
MySQL 对 NULL 的处理是:多个 NULL 值不视为重复,所以 (email, tenant_id) 索引下,('test@x.com', NULL) 插入两次不会报错。
这在多租户系统里很危险——如果 tenant_id 允许为空,就等于放行了全局重复。
实操建议:
- 把字段设为
NOT NULL,并给默认值(如tenant_id INT NOT NULL DEFAULT 1) - 如果业务上真要允许无租户,改用函数索引(MySQL 8.0+):
CREATE UNIQUE INDEX uniq_email_tenant ON users ((COALESCE(tenant_id, 0)), email) - 旧版本只能靠应用层兜底:在保存前显式查一遍
WHERE email = ? AND (tenant_id = ? OR tenant_id IS NULL)
模型 save() 报 Integrity constraint violation 怎么友好提示
数据库抛出异常时,默认显示 SQL 错误,而不是“邮箱已被占用”。直接 try-catch PDOException 不够精准,因为同一张表可能有多个唯一索引。
实操建议:
- 捕获异常后检查
$e->getCode()是否为23000(SQLSTATE 唯一约束违规),再用正则匹配索引名:preg_match('/uniq_email_tenant/', $e->getMessage()) - 更稳的方式是提前查:在
save()前调用$model->where($data)->count(),但要注意并发窗口期 - TP6.1+ 可以用
Db::transaction()包裹查 + 写,配合FOR UPDATE行锁(仅 InnoDB),不过会拖慢高并发写入
真正难的不是加索引或写验证规则,而是想清楚「哪些字段组合在什么业务上下文里必须唯一」——比如是否允许不同状态的记录共存、NULL 值语义是否明确、租户隔离粒度是否一致。这些一旦定错,后期改索引+清数据+补迁移,比从头写还累。



















