whereOr 传空值,唯一性校验全军覆没
某后台系统上线「手机号选填」功能后,第二天早上就收到反馈:所有新用户注册都报「用户名或手机号已存在」,但数据库里根本没有这些用户名。把手机号填上又能注册成功,留空就失败。看起来像并发问题或缓存问题,实际是一个极其反直觉的 SQL 查询陷阱。
现象
- 不填手机号直接注册 → 报「用户名或手机号已存在」
- 填写手机号后注册 → 正常通过
- 数据库里大量历史用户的
mobile字段为空字符串''(手机号本是可选项,老数据未强制要求) - 翻 SQL 日志,唯一性校验查询竟然返回了第一条随机老用户记录
根因
唯一性校验的代码大致如下:
$query = User::where('username', $params['username']);
$query->whereOr('mobile', $params['mobile'] ?? ''); // 坑在这里
$existUser = $query->find();当用户不填手机号时,$params['mobile'] 为 null,经过 ?? '' 兜底后变成空字符串 ''。
ThinkPHP 把这段代码编译成:
SELECT * FROM users
WHERE username = 'newuser' OR mobile = ''
LIMIT 1;数据库里有几百条 mobile = '' 的历史记录,OR mobile = '' 直接命中第一条。find() 返回了一个用户对象,校验函数判断「已存在」,注册被拒。
whereOr('col', '') 不是「不查这个条件」,而是「查 col = ''」。若列中存有大量空字符串历史数据,这条 OR 分支会稳定匹配,让唯一性校验永远返回「存在」。
MySQL 的 UNIQUE 约束对 NULL 有豁免(多个 NULL 不冲突),但对 '' 没有豁免(只允许一条 '' ),因此历史数据积累后问题会延迟爆发,等「选填」功能上线后集中出现。
正解
OR 分支只有在值非空时才加入查询:
$query = User::where('username', $params['username']);
if (!empty($params['mobile'])) {
$query->whereOr('mobile', $params['mobile']);
}
$existUser = $query->find();同时,若要从根上修复历史数据隐患,可以把「没有手机号」统一改为 NULL 而非 '',并加 UNIQUE 约束(MySQL 的 UNIQUE 索引允许多个 NULL 共存):
-- 迁移历史空字符串为 NULL
ALTER TABLE users MODIFY mobile VARCHAR(20) NULL;
UPDATE users SET mobile = NULL WHERE mobile = '';
-- 加唯一索引,NULL 不互相冲突
ALTER TABLE users ADD UNIQUE KEY uk_mobile (mobile);凡是「可选字段 + OR 唯一性校验」的组合,都要先 !empty() 守卫,再追加 whereOr。不要依赖 ?? '' 兜底,空字符串和 null 对 SQL 的行为截然不同。
快速排查同类坑
用下面的 grep 扫描项目里类似模式:
grep -rn "whereOr.*\$params\[" app/ | grep -v "if.*empty\|!empty"凡是没有被 !empty 保护的 whereOr + 用户输入绑定,都要逐一确认该列是否允许空字符串历史数据。whereOr('email', ''), whereOr('openid', $emptyStr) 都是同款陷阱。
一句话外卖
可选字段做唯一性 OR 校验时,空值不等于「跳过条件」,必须在代码层显式
!empty守卫,否则空字符串会把整张有历史空值的表当作「已存在」依据。