我们直接聊点实在的。数据库设计里最常被拿来练手、也最容易被忽视的表是什么?答案是 user 表。我见过太多项目,user 表从第一版上线到系统重构,翻来覆去就是 id、username、password 三个字段,后来业务复杂了,只能不断 ALTER TABLE,改得面目全非。这篇文章我就用一张 user 表结构贯穿到底,从字段设计、类型选择、索引优化到 SQL 练习题,把数据库设计里那些“书上不会明说、踩坑后才知道”的点全部过一遍。适合刚入门的后端开发、准备数据库面试的候选人、以及想把手头用户体系整理得更规范的同学。文章里的每一段 SQL 我都会解释为什么这么写,而不是给你丢一段能跑就行的代码。
1. 一张 user 表的进化史:从三字段到生产级设计
很多教学项目里的 user 表长这样:
sql复制CREATE TABLE user (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50),
password VARCHAR(50)
);
如果你只是本地写个 demo,这表确实能用。但注意,这里的 password 是明文存储,username 没有唯一约束,id 用了有符号 INT。这三个问题放到真实环境里,任何一个都能让你出事——密码泄露、用户名重复、ID 用完后溢出。生产级的 user 表至少要考虑到这几个维度:账号体系、安全字段、状态控制、审计字段、扩展预留。
1.1 账号体系:唯一性约束与登录标识
用户名当然是需要的,但光有用户名不够。绝大多数系统允许用户用手机号或邮箱登录,这个设计直接决定了 user 表的字段形态。我在实际项目里最常见的做法是三个字段各司其职:
username:用户自定义的昵称或登录名,允许修改,但要求唯一。phone:手机号,做登录凭证时用来接收验证码,唯一。email:邮箱,用于找回密码、接收通知,也做唯一约束。
这里有个很容易踩的坑:手机号和邮箱如果允许为空,唯一约束在 MySQL 里不会对多行 NULL 生效。也就是说,100 个用户都不填邮箱,数据库并不会报错,但你在代码里通过邮箱查用户时,可能会查到不符合预期的数据。所以我的方案是:要么把空字符串统一处理成默认值(比如 ''),要么在建表时给默认空串并加唯一索引。这个细节面试时经常被问,实际开发里也常踩。
1.2 安全字段:密码、盐值与登录状态
密码字段不要用 VARCHAR(50),更不要用明文。哪怕你用的是 MD5 加盐,字段长度也得按哈希结果来算。MD5 是 32 位十六进制字符,SHA-1 是 40 位,SHA-256 是 64 位。如果你用 BCrypt,那结果长度可变,一般在 60 字符左右。我的建议是直接给 VARCHAR(128),留足未来算法升级空间,多几个字节的存储成本换一次可能的架构迁移,非常划算。
另外加两个安全相关的字段:
password_salt:盐值,用于密码加密前的加盐操作。不过如果你用 BCrypt,盐已经混在哈希结果里了,这个字段可以不要。last_login_at/last_login_ip:记录登录时间和 IP。这不仅是审计需要,还是你判断账号异常登录的重要依据。
1.3 状态控制:为什么必须有 status 字段
凡是业务系统,用户一定会有禁用、正常、待激活、注销等状态。我见过没有 status 字段的项目,结果是代码里用 is_deleted = 1 来模拟禁用,后来又冒出一个 is_active,两个字段互相打架。所以正规设计里:
sql复制status TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常 2-禁用 3-待激活 4-注销'
用数字常量代替字符串 'active'、'disabled',存储上更省,查询上更快,也方便代码里定义枚举。这里不要用 ENUM 类型,原因是 ALTER TABLE 修改枚举值列表的成本很高,而且客户端驱动对 ENUM 的兼容性参差不齐。用 TINYINT + 注释 是最稳的组合。
1.4 审计字段与软删除
教科书上很少讲审计字段,但真实项目里几乎每张核心表都要有。created_at、updated_at 是底线,created_by、updated_by 也建议加上——当你知道某条记录是谁改的时候,排查问题会轻松很多。软删除字段 deleted_at 是另一个争议点。我的建议是:不是所有表都需要软删除,但 user 表强烈建议加。原因很简单,用户注销账号后,如果他再注册,系统需要能识别出这个手机号/邮箱的旧记录,而不是直接用唯一索引把注册请求打回去。DELETED_AT 取值为 NULL 代表未删除,取值时间代表已删除。配合唯一索引时,MySQL 8.0.16 以上支持函数索引,可以把 (phone, deleted_at) 做联合索引,实现对“未删除用户手机号唯一”的约束。
sql复制CREATE UNIQUE INDEX uk_phone_active ON user ((IF(deleted_at IS NULL, phone, NULL)));
当然,这个语法在不同版本上有兼容性问题,理解它的含义比死记命令更重要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 字段类型选择的底层逻辑:别再用错了
很多初学者建表时对类型选择很随意,反正存得进去就行。但字段类型选错,轻则多占存储,重则全表扫描、索引失效、程序报错。下面这些结论,都是我在真实项目中反复验证过的。
2.1 整数类型:INT 还是 BIGINT?
主键 id 到底用 INT 还是 BIGINT,取决于你预期的数据量。INT 是无符号上限约 42 亿,看起来多,但如果你用有符号 INT,上限打对折变成 21 亿,而且在高并发插入的情况下,ID 耗尽的速度可能比你想象得快。我经手过一个老系统的用户表,上线五年 ID 就到了 9 位数,如果当初用了有符号 INT 而不是 BIGINT 或至少无符号 INT,后续迁移会是一场噩梦。所以新项目我直接无脑用 BIGINT UNSIGNED AUTO_INCREMENT,代价几乎为零,收益是永不用操心 ID 溢出。
至于状态字段和年龄这类小范围数值,用 TINYINT(范围 -128~127)就够了。之前我在 code review 里看到有人把 gender 定义成 INT(10),一个字节能解决的事非要占四个字节,一张百万行的表光这一列就多出 300MB 的存储空间。
2.2 字符类型:VARCHAR 的长度不是随便写的
VARCHAR 的最大长度是 65535 字节,但这个上限是整行所有 VARCHAR 字段共享的,而且受字符集影响。如果使用 utf8mb4(推荐,因为它支持 emoji),每个字符最多占 4 字节,所以 VARCHAR(100) 理论最大能存 400 字节,要算清楚不是不可能,而是很容易越界。
我常用的经验值是:
- 用户名:VARCHAR(32),足够绝大多数场景。
- 手机号:VARCHAR(20),别用 CHAR(11),因为用户可能输入
+86前缀。 - 邮箱:VARCHAR(128),标准 RFC 定义的最长邮箱地址是 254 字符,但实际业务里 128 已经够用,太长反而浪费索引空间。
- 密码哈希:VARCHAR(128),理由前面说过,给算法升级留余地。
- 昵称/头像 URL:VARCHAR(255),这已经是 MySQL 索引长度的临界点(utf8mb4 下 255×4=1020 字节,接近 767 字节的旧版索引限制),如果要对这个字段建索引,建议缩短到 128 以内。
2.3 时间字段:DATETIME 还是 TIMESTAMP?
这两个类型最容易混。TIMESTAMP 的存储范围是 1970-2038 年,而且有时区概念,写入时会把当前时区转成 UTC 存储。DATETIME 的存储范围是 1000-9999 年,没有时区概念。对于绝大多数业务系统,使用 DATETIME 更安全,因为你不会希望业务跑到 2038 年突然因为时间字段溢出而崩溃。注意 MySQL 8.0.19 之后,DATETIME 也支持了时区偏移,但那是另一个话题了。
这里还要强调一个细节:时间字段一定要在数据库层做默认值管理。
sql复制created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
我之前见过一个项目,created_at 由应用层代码传入,结果因为服务器时区配置错误,所有用户注册时间都差了 8 小时,排查了很久才发现问题根源。把默认值交给数据库,至少能保证统一。
2.4 性别字段的两种方案各有什么利弊
性别字段有几种做法。第一种是 TINYINT(1),存 0、1、2 表示未知/男/女;第二种是 VARCHAR(10),直接存字符串,比如 'male'、'female';第三种是单独建字典表。我的看法是,一个字段如果枚举值不超过 10 个,而且短期内不会频繁增加,用 TINYINT + 代码枚举常量 就够了。但如果你做的是面向海外的平台,性别选项可能不只是男女,可能有更多自定义选项,这时候不要用字段枚举,而是把性别扩展成 user_profile 表,一行用户一行资料,这样灵活性最高。记住一个原则:字段的枚举值增加,本质上是业务的维度在增加,考虑拆分表而不是加字段。
3. 练习题设计与参考答案:把这些 SQL 跑一遍才算学会
下面这套练习题,我在面试候选人和培养新人时都实际用过。题目从简单到复杂,涵盖了 SQL 日常开发里 90% 的场景。注意,我不只给你正确答案,还会告诉你为什么其他答案是错的,或者为什么不同写法之间性能差距很大。
3.1 题目总览:你要掌握的能力清单
建议你在本地建一张表,插入至少 10000 条测试数据再开始练习。数据量太小,你根本感受不到索引和写法的性能差异。测试数据的生成可以用存储过程,也可以直接用现成的数据生成工具。我把练习题分成了四级,从基础到高级:
| 级别 | 能力点 | 对应题目 |
|---|---|---|
| L1 基础 | 增删改查、WHERE、ORDER BY | 1-5 |
| L2 进阶 | 聚合、分组、HAVING、LIMIT | 6-10 |
| L3 高级 | JOIN、子查询、窗口函数 | 11-14 |
| L4 优化 | 索引优化、执行计划分析 | 15 |
3.2 基础题:CRUD 与排序(题目 1-5)
题目 1:查询所有状态正常(status=1)的用户,按注册时间倒序排列,取最新的 20 个。
sql复制SELECT id, username, phone, created_at
FROM user
WHERE status = 1
ORDER BY created_at DESC
LIMIT 20;
这里要解释的点是:不要写 SELECT *,因为线上表的字段会越来越多,你看着只有 5 个字段的表,三个月后可能变成 20 个字段。SELECT * 会把无用的字段也传输到应用层,浪费带宽和内存。另外,如果这个查询是高频接口,建议在 status 和 created_at 上建立联合索引:
sql复制ALTER TABLE user ADD INDEX idx_status_created (status, created_at);
注意联合索引的顺序,等值条件 status 放前面,排序字段 created_at 放后面,这才是最优解。
题目 2:新增一个用户,用户名 zhangsan,手机号 13800138000,密码使用 BCrypt 加密后的哈希值(这里用占位符)。
sql复制INSERT INTO user (username, phone, password_hash, status)
VALUES ('zhangsan', '13800138000', '$2a$10$abcdefghijklmnopqrstuv', 1);
这里我要强调一点,只给 username 加唯一索引是不够的,手机号也必须加唯一索引。很多系统的注册漏洞就是只校验了用户名,没校验手机号,导致一个手机号反复注册多个账号,后续营销、登录都出问题。
题目 3:将用户 ID 为 10086 的昵称修改为 new_nickname,并且更新时间自动更新。
sql复制UPDATE user
SET nickname = 'new_nickname'
WHERE id = 10086;
注意,updated_at 字段因为有 ON UPDATE CURRENT_TIMESTAMP,所以不需要手动赋值。另外一个容易犯的错是 UPDATE 不带 WHERE,如果你在测试库还好,在生产库执行,那全表数据就都改了,这是数据库运维里最惨烈的事故之一。我的习惯是写 UPDATE 和 DELETE 前,先写一条 SELECT 确认影响范围。
题目 4:删除 ID 为 99999 的用户记录,使用软删除。
sql复制UPDATE user
SET deleted_at = NOW()
WHERE id = 99999;
真实系统里 DELETE 语句要慎用。一旦你执行 DELETE FROM user WHERE id = 99999,这条记录连同它的审计信息就彻底消失了。如果后面需要追溯、恢复或者做数据分析,你就抓瞎了。所以 uesr 表我强烈建议做软删除。但注意,每次查询都需要记得带 WHERE deleted_at IS NULL,否则你会把已注销的用户也查出来。
题目 5:查询手机号以 138 开头的所有用户,按手机号升序排列。
sql复制SELECT id, phone, username, status
FROM user
WHERE phone LIKE '138%'
ORDER BY phone ASC;
这个 SQL 能不能走索引,取决于 phone 字段上有没有索引,以及 LIKE 的模式。'138%' 是前缀匹配,能走普通 B+ 树索引。但如果是 '%138' 或 '%138%',就无法走索引了,只能全表扫描。数据量小无所谓,数据量到千万级时,这两条 SQL 的性能差距会是天壤之别。
3.3 进阶题:聚合与分组(题目 6-10)
题目 6:统计不同状态下的用户数量。
sql复制SELECT status, COUNT(*) AS user_count
FROM user
WHERE deleted_at IS NULL
GROUP BY status;
这里要注意:COUNT(*) 和 COUNT(1) 在 MySQL 里效率几乎一样,但 COUNT(字段名) 会忽略该字段为 NULL 的行,可能会造成统计数据偏差。所以统计行数统一用 COUNT(*),别想太多。
题目 7:找出注册时间在最近 7 天的用户中,每天新增用户数的变化。
sql复制SELECT DATE(created_at) AS register_date, COUNT(*) AS cnt
FROM user
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
AND deleted_at IS NULL
GROUP BY DATE(created_at)
ORDER BY register_date;
这道题考察的是日期函数和分组能力的结合。注意 DATE_SUB(CURDATE(), INTERVAL 7 DAY) 这个写法,而不是先算好时间再拼字符串,前者是标准的 SQL 写法,可读性更好。
题目 8:找出用户名重复的用户及其重复次数。
sql复制SELECT username, COUNT(*) AS cnt
FROM user
WHERE deleted_at IS NULL
GROUP BY username
HAVING COUNT(*) > 1
ORDER BY cnt DESC;
这道题考察了 HAVING 的用法。很多初学者分不清 WHERE 和 HAVING 的区别:WHERE 在分组前过滤行,HAVING 在分组后过滤组。如果你试图用 WHERE COUNT(*) > 1,SQL 会直接报错,因为聚合函数不能出现在 WHERE 子句里。
题目 9:分页查询第 101 到 120 条用户记录(共 20 条)。
sql复制SELECT id, username, phone, created_at
FROM user
WHERE deleted_at IS NULL
ORDER BY id
LIMIT 100, 20;
这个写法在数据量小的时候没问题,但数据量大了之后,LIMIT 1000000, 20 会扫描 1000020 行然后丢弃前 100 万行,性能很差。更优的写法是记录上一页最后一条的 ID:
sql复制SELECT id, username, phone, created_at
FROM user
WHERE deleted_at IS NULL AND id > 1000000
ORDER BY id
LIMIT 20;
后者只需要扫描 20 行。这也是面试里高频的性能优化考点,建议重点掌握。
题目 10:统计当前所有用户中,有手机号但没有邮箱的用户占比。
sql复制SELECT
SUM(CASE WHEN phone IS NOT NULL AND phone != '' AND (email IS NULL OR email = '') THEN 1 ELSE 0 END) AS phone_only,
COUNT(*) AS total,
(SUM(CASE WHEN phone IS NOT NULL AND phone != '' AND (email IS NULL OR email = '') THEN 1 ELSE 0 END) / COUNT(*)) * 100 AS percent
FROM user
WHERE deleted_at IS NULL;
这道题考察 CASE WHEN 和 COUNT 的配合。如果你在 MySQL 8.0 上,也可以直接用 COUNT(DISTINCT...) 之类的方法二次统计,但 CASE WHEN 的思路更通用,在任何关系型数据库上都能跑。
3.4 高级题:JOIN 与窗口函数(题目 11-14)
真实业务里 user 表很少孤立存在,它总会搭配 order(订单)、user_login_log(登录日志)、user_profile(扩展资料)等表。为了练 JOIN,你需要额外建一张 order 表。
sql复制CREATE TABLE `order` (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id)
);
题目 11:查询每个用户的所有订单总金额,并显示用户名。
sql复制SELECT u.id, u.username, IFNULL(SUM(o.amount), 0) AS total_amount
FROM user u
LEFT JOIN `order` o ON u.id = o.user_id
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.username;
注意这里用的是 LEFT JOIN 而不是 JOIN。因为有些用户没有下过订单,JOIN 会把他们过滤掉,但业务需求是显示所有用户,所以必须用 LEFT JOIN。IFNULL 用来处理没有订单时 SUM 结果为 NULL 的情况。
题目 12:查询最近 30 天内有订单记录的用户名单。
sql复制SELECT DISTINCT u.id, u.username
FROM user u
INNER JOIN `order` o ON u.id = o.user_id
WHERE o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
AND u.deleted_at IS NULL;
这道题里用 INNER JOIN 是合理的,因为我们只关心有订单的用户。DISTINCT 用来去重,防止一个用户下多单时重复出现在结果集里。
题目 13:使用窗口函数,查看每个用户的订单金额在全体用户中的排名。
sql复制SELECT
u.id,
u.username,
IFNULL(SUM(o.amount), 0) AS total_amount,
RANK() OVER (ORDER BY IFNULL(SUM(o.amount), 0) DESC) AS amount_rank
FROM user u
LEFT JOIN `order` o ON u.id = o.user_id
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.username;
窗口函数的引入是 MySQL 8.0 的重大升级,上面的 SQL 在 5.7 及之前版本会直接报错。如果你在维护老项目,暂时只能用临时表或者变量来模拟。这道题在实际数据分析场景里很常用,比如运营要看消费 Top 100 的用户,这种写法比在应用层处理高效得多。
题目 14:找出每个用户最近一笔订单的金额(使用子查询)。
sql复制SELECT u.id, u.username, o.amount, o.created_at
FROM user u
LEFT JOIN `order` o ON o.id = (
SELECT o2.id
FROM `order` o2
WHERE o2.user_id = u.id
ORDER BY o2.created_at DESC, o2.id DESC
LIMIT 1
)
WHERE u.deleted_at IS NULL;
这个 SQL 用关联子查询查每个用户最新的订单。注意,子查询中不能只 ORDER BY created_at DESC,因为同一秒内可能有多笔订单,所以要用 (created_at, id) 联合排序,用 id 做二次排序保证唯一性。这个细节不注意,就可能查询出同一个用户的多条记录。
3.5 优化题:执行计划与索引(题目 15)
题目 15:有一条慢查询,条件为 WHERE phone = '13800138000' AND status = 1,该表数据量为 500 万行,请问你怎么优化?
参考答案分三步:
首先,用 EXPLAIN 查看执行计划:
sql复制EXPLAIN SELECT * FROM user WHERE phone = '13800138000' AND status = 1;
其次,根据 type 字段判断是 ALL(全表扫描)、ref(非唯一索引等值匹配)还是 const(唯一索引匹配)。如果 phone 字段上有唯一索引,那么 type 应该至少是 const,这条查询就非常快。如果 phone 上没有索引,type 是 ALL,那就需要加索引。
最后,添加联合索引:
sql复制ALTER TABLE user ADD UNIQUE INDEX uk_phone_status (phone, status);
大部分情况下,phone 单独一个唯一索引就够了,因为等值查询 phone 能过滤掉绝大多数数据,status 的过滤效果已经不显著。只有当数据分布极度不均匀(比如 99% 的用户都是 status=1)时,联合索引才有明显收益。这其实也解释了:索引不是越多越好,而是要结合数据分布和查询模式。
4. 真实开发中高频出现的 user 相关故障与排查
聊完设计题,再来看线上开发中经常撞见的问题。这里我整理了四类和 user 强相关的经典故障,每一类我都踩过,解决方案也是验证过的。
4.1 MySQL 1045 错误:Access denied for user
如果应用报 ERROR 1045 (28000): Access denied for user 'root'@'localhost',这代表认证失败。常见原因有三个:
- 密码错误或为空。2. 账号被限制了 host,例如只允许
localhost登录,但程序从远程连接,就会失败。3.mysql.user表里这个账号记录的插件类型不匹配,比如 MySQL 8.0 默认用caching_sha2_password,但老客户端用的是mysql_native_password。
解决方法很简单:用管理员身份登录 MySQL,改掉对应账号的 host 或插件类型,然后刷新权限。但核心教训是,不要在代码里用 root 账号连接业务数据库,你的应用应该有一个最小权限的专用账号,让它只能操作业务库的数据。这个建议我从职业生涯第一天讲到现在,真正听进去的项目没几个,然后每年都有人因为 root 权限被误操作坑一把。
4.2 Datagrip 同步数据库表结构失败
用 Datagrip 做表结构对比和同步时,最常见的问题是连接账号权限不足。让你同步表结构,首先要保证连接用户有 SHOW VIEW、SELECT、ALTER 等权限,否则 Datagrip 读不到完整的表定义。另外,如果两张库的 SQL_MODE 或字符集不一致,同步时会生成一堆没必要的 ALTER 语句,看起来非常吓人。我的做法是先只比较结构不自动执行,仔细确认生成的变更脚本,把 ENGINE、CHARSET、COLLATE 这类环境相关差异忽略掉,再执行真正的字段变更。
4.3 容器化部署时数据库访问被拒绝
docker access denied for user 'root'@'172.18.0.3' 这个报错很经典。容器网络模式下,应用容器的 IP 是 Docker 分配的网段地址,如果 MySQL 用户只授权了 localhost 或 127.0.0.1,容器里的应用自然连不上。解决方案是在创建用户时明确指定网段:
sql复制CREATE USER 'appuser'@'172.18.0.%' IDENTIFIED BY 'password';
GRANT SELECT, INSERT, UPDATE, DELETE ON mydb.* TO 'appuser'@'172.18.0.%';
如果嫌容器 IP 变化太麻烦,也可以直接把授权改成 'appuser'@'%',但在生产环境这么做要谨慎,毕竟意味着任何 IP 只要能猜到密码就能连。
4.4 Windows 下 user profile service 登录失败
开发机偶尔也会出问题,比如 Windows 登录报 “User Profile Service 登录失败”,本质上是当前用户的配置文件损坏或注册表里的 ProfileList 路径异常。这类问题虽然不是数据库层面的,但只要发生,会直接影响你本地开发环境的启动。常规思路是进安全模式,打开注册表,找到 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList,把损坏用户对应的项删掉或改名,然后重启重新登录。但注意,如果用户文件夹里还有重要文件,操作前一定要备份,否则恢复起来非常痛苦。
4.5 user 相关服务的通用排查清单
当你在程序里遇到各种 user 相关服务启动失败或登录失败时,不要慌,按这个顺序排查大概率有结果:
- 先看日志,不要猜。系统日志和应用日志能告诉你真实的报错码。
- 确认网络/连接配置,包括 IP、端口、Host、账号权限。
- 确认该服务的进程用户是否有对应文件系统的读写权限。
- 确认代码中引用的用户字段是否和数据库表结构一致,尤其是缓存了旧表结构的时候。
- 如果是配置文件损坏类的用户服务问题,备份当前配置后重置。
5. 从建表 SQL 到项目落地的完整示范
说了这么多理论和练习题,最后放一个可以直接用于中小型项目的、相对完整的 user 表建表 SQL,并加上注释。你可以根据自己的业务做删减,但核心字段我已经都列出来了。
sql复制CREATE TABLE `user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`username` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '用户名/登录名',
`nickname` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '昵称',
`phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号',
`email` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱',
`password_hash` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '密码哈希',
`avatar_url` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '头像URL',
`gender` TINYINT NOT NULL DEFAULT 0 COMMENT '性别 0-未知 1-男 2-女 3-其他',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1-正常 2-禁用 3-待激活 4-注销',
`last_login_at` DATETIME DEFAULT NULL COMMENT '最后登录时间',
`last_login_ip` VARCHAR(45) DEFAULT NULL COMMENT '最后登录IP,支持IPv6所以用45',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间,NULL代表未删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_phone` (`phone`),
UNIQUE KEY `uk_email` (`email`),
KEY `idx_status_created` (`status`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
这里有几个地方我特别解释一下。last_login_ip 用了 VARCHAR(45)。很多人以为 IPv6 最长是 39 字符,但加上 IPv4-mapped 形式和地址段前缀后,45 是一个业界比较常用且稳妥的长度。另外,建表用 InnoDB,支持事务和行级锁;开发环境常用 utf8mb4_unicode_ci 排序规则,虽然比 utf8mb4_general_ci 略慢一点点,但排序结果更准确。
注意,这个表设计默认密码哈希不会为空。如果用户在注册时用第三方 OAuth 登录,有些系统会插入一条没有密码的记录。这种情况下,你可以把 password_hash 默认值改为 '' 并在业务层处理,或者用 NULL。我倾向于 '',因为 NULL 在唯一索引、比较操作里更容易出边界状况。
对了,如果你的业务用到读写分离,这类 user 表更要注意主从延迟问题。用户注册后马上登录,从库还没同步到记录,就会出现“账号不存在”的报错。处理方案一般是“写后读”强制走主库,或者在前端做短暂轮询等待。这个点虽然和表结构无关,但和 user 表的线上使用体验紧密相关。
6. 练习完之后:把这些错误习惯改掉
最后,我不想做那种“总结全文”的假把式。我只想说一个我面试候选人、带新人时反复强调的真理:SQL 水平不是在 LeetCode 上刷几百道题刷出来的,而是你真正理解了一张表从无到有、从简单到复杂的设计过程之后,那些字段类型、索引、约束、JOIN 的取舍自然就会刻在脑子里。
我亲手带过一个新人,他的 SQL 基础弱到连 LEFT JOIN 和 JOIN 的区别都说不清。我让他做的第一件事,不是看 SQL 教程,而是在本地把 user 表和 order 表建出来,灌进去 10 万条测试数据,然后用我上面这套练习题一道道地刷、一道道地看执行计划。两周后,他已经能独立排查一条线上慢查询了。
所以,如果你手头正好在学数据库,或者准备面试,别从复杂的三范式理论看起。找一张 user 表,往深了做,就够了。等你把索引、窗口函数、执行计划这些都玩顺了,再回头看那些高深的数据库理论,你会发现它们每一个都能在这张表上找到落地的影子。
最后再分享一个小技巧:练习 SQL 时,把所有 UPDATE 和 DELETE 的 SQL 都写成事务包裹,先 BEGIN,执行后不要马上 COMMIT,先 SELECT 验证一下影响到的行数是不是预期的,确认无误再提交。我在本地方案里用这个习惯避免了好几次误删全表的惨剧,养成这个肌肉记忆后上线操作也会变得特别稳妥。
