返回博客

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 绑定空字符串会匹配所有空值行

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

凡是「可选字段 + 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 守卫,否则空字符串会把整张有历史空值的表当作「已存在」依据。