1. 两张“用户表”背后的认知错位
很多刚接触系统设计的朋友,第一次看到“系统用户表”和“客户端用户表”这两个概念时,第一反应往往是:不都是存用户信息的吗,为什么要拆成两张表?我干脆合并成一张,加个类型字段区分不就行了?这个想法听起来很合理,但我见过太多项目在合并之后,半年内就被迫做数据迁移和代码重构,代价远超当初省下的那点设计时间。
先把结论摆在前面:系统用户表服务的是“谁在操作这个系统”,客户端用户表服务的是“谁在使用这个产品”。这两句话看着像文字游戏,实际上决定了表结构、字段设计、权限模型、生命周期管理乃至数据安全策略的全部走向。系统用户表通常面向内部运营、管理员、客服、审核员这类角色,他们的账号由组织统一分配、统一回收,数量可控,权限分级明确;客户端用户表面向的是外部终端用户,可能是消费者、会员、租户员工,数量级从几千到几千万不等,注册入口开放,行为数据丰富,生命周期由用户自己决定。
这个标题之所以值得单独拿出来讲,是因为它是权限系统设计和多租户架构里最容易被低估的一个分水岭。你把它理解透了,后面做RBAC、做数据隔离、做审计日志、做账号体系对接,都会顺很多;理解偏了,就会陷入“一个字段到处打补丁”的泥潭。这篇文章适合三类人看:正在设计第一版用户体系的初中级后端,接手遗留系统需要理清账号模型的维护者,以及做产品架构时需要判断“这个需求该落在哪张表”的技术负责人。下面我会从设计动机、字段拆解、实操建表、常见坑位几个层面,把这件事讲透。
2. 为什么必须拆开:两类用户的本质差异
2.1 从“身份来源”看归属逻辑
系统用户表的身份来源是组织授予。一个运营人员能登录后台,不是因为他自己注册了,而是因为组织给他开了账号、分配了角色。这意味着这条记录的创建者、修改者、销毁者都是组织内部的管理流程,账号本身是“被管理对象”。你在设计时就要考虑:账号如何批量导入、离职如何快速禁用、角色变更如何留痕、是否存在超级管理员这种特殊存在。
客户端用户表的身份来源是自我注册或第三方授权。用户自己填手机号、邮箱,或者通过社交账号授权登录,系统只是提供一个入口。这条记录的“主人”是用户本人,他有权修改昵称、注销账号、导出数据。设计时你要考虑的是:注册防刷、手机号唯一性、注销后的数据留存策略、多端登录的会话管理。
这两种归属逻辑直接决定了主键策略的不同。系统用户表我通常建议用自增ID或者带业务前缀的短ID,因为数量可控、内部使用、不需要防枚举;客户端用户表则强烈建议用分布式ID或者UUID变体,因为对外暴露、数量庞大、要防止被遍历爬取。这个细节很多教程不讲,但它是实打实的安全考量。
2.2 从“生命周期”看管理粒度
系统用户的生命周期是组织驱动的。入职开通、调岗改权限、离职禁用,整个流程跟着人事变动走。所以系统用户表里往往需要status字段配合disabled_at、created_by这类审计字段,甚至要和内部的组织架构表做关联。我见过一个反面案例:某团队把系统用户和客户端用户合并,结果一个运营离职后,他的账号在客户端侧还留着登录态,因为合并表里的状态字段被客户端逻辑覆盖了,最后靠人工排查才堵上。
客户端用户的生命周期是行为驱动的。注册、活跃、沉默、流失、注销,每个阶段对应不同的运营策略。所以客户端用户表里通常会有last_login_at、register_channel、member_level这类行为与分层字段。这些字段对系统用户来说毫无意义——你不会去关心一个审核员“最近一次登录距今天数”来做召回运营。
注意:生命周期差异带来的一个隐性成本是数据归档策略。客户端用户表因为量大,往往需要冷热分离、历史归档;系统用户表量小,通常全量保留即可。合并之后,归档逻辑会变得非常别扭。
2.3 从“权限模型”看复杂度分层
这是两者差异最剧烈的地方。系统用户的权限模型通常是功能级+数据级的双层结构:功能级决定他能进哪些菜单、调哪些接口,数据级决定他能看哪些部门、哪些租户的数据。这套模型往往和RBAC(基于角色的访问控制)深度绑定,角色、权限、资源三张表配合,系统用户表只是其中一环。
客户端用户的权限模型则简单得多,多数场景下就是身份级的:普通用户、会员、VIP,或者按租户隔离的普通成员、管理员。它很少需要细到“能调哪个接口”的粒度,更多是“能看到哪些内容”“能享受哪些权益”。把这两套模型塞进一张表,角色字段就会变成一个大杂烩,取值域混乱,查询和维护都痛苦。
我个人的经验是:权限复杂度差异超过一个数量级时,拆表就是必然选择。系统用户的权限配置可能涉及几十张关联表,客户端用户可能就一个等级字段搞定,硬合并只会让简单的那一方被复杂的那一方拖累。
3. 字段设计拆解:每一列都有它的理由
3.1 系统用户表的核心字段清单
下面这张表是我在多个项目中沉淀下来的系统用户表基础结构,字段不算多,但每个都有明确用途。这里用通用的关系型数据库语法示意,具体类型可按所用数据库调整。
| 字段名 | 类型建议 | 用途说明 | 设计理由 |
|---|---|---|---|
| id | BIGINT 自增 | 主键 | 内部使用,无需防枚举 |
| username | VARCHAR(64) 唯一 | 登录名 | 组织内唯一,便于人工识别 |
| password_hash | VARCHAR(255) | 密码哈希 | 必须加盐慢哈希,禁止明文 |
| real_name | VARCHAR(64) | 真实姓名 | 审计与责任追溯需要 |
| org_id | BIGINT | 所属组织/部门 | 数据权限的锚点 |
| role_ids | VARCHAR(512) | 角色集合 | 小规模可直接存,大规模走关联表 |
| status | TINYINT | 账号状态 | 启用/禁用/锁定 |
| last_login_at | DATETIME | 最近登录 | 安全审计与闲置清理 |
| created_by | BIGINT | 创建人 | 追责与批量导入溯源 |
| created_at | DATETIME | 创建时间 | 标准审计字段 |
| updated_at | DATETIME | 更新时间 | 标准审计字段 |
这里重点说三个容易被忽略的点。第一,real_name字段看似冗余,但系统用户涉及操作审计,出了问题要能定位到具体的人,昵称是不够的。第二,org_id是数据权限的根基,没有它,后续做“只能看本部门数据”会非常被动。第三,created_by在批量导入场景下尤其重要,出问题时能快速定位是哪次导入、谁操作的。
3.2 客户端用户表的核心字段清单
客户端用户表的字段设计思路完全不同,它更关注触达、分层和合规。
| 字段名 | 类型建议 | 用途说明 | 设计理由 |
|---|---|---|---|
| id | BIGINT/UUID | 主键 | 对外暴露,需防遍历 |
| mobile | VARCHAR(20) 唯一 | 手机号 | 主要登录凭证 |
| nickname | VARCHAR(64) | 昵称 | 展示用,允许重复 |
| avatar_url | VARCHAR(512) | 头像 | 展示用 |
| register_channel | VARCHAR(32) | 注册渠道 | 渠道效果分析 |
| member_level | TINYINT | 会员等级 | 权益分层 |
| status | TINYINT | 账号状态 | 正常/冻结/注销中 |
| last_login_at | DATETIME | 最近登录 | 活跃度与召回 |
| created_at | DATETIME | 注册时间 | 标准字段 |
| deleted_at | DATETIME | 软删除时间 | 注销留痕,合规需要 |
客户端表里我特别想强调register_channel和deleted_at。前者是运营分析的命脉,没有它你根本不知道用户从哪来;后者是合规底线,用户注销后数据不能立刻物理删除,往往需要保留一段时间以应对纠纷或监管要求,软删除是更稳妥的做法。
3.3 那些“看起来能共用”却绝不能共用的字段
合并表的诱惑往往来自几个“看起来通用”的字段:手机号、邮箱、密码、状态、创建时间。但仔细想,它们的语义在两张表里并不一致。
手机号在客户端表里是主登录凭证,必须唯一且强校验;在系统用户表里可能只是联系方式,甚至允许为空。密码策略也不同:系统用户往往要求定期更换、复杂度更高;客户端用户更看重登录便捷,可能还支持验证码登录、第三方授权。状态字段更是重灾区,系统用户的“禁用”意味着立即失去所有后台权限,客户端用户的“冻结”可能只是限制某些功能,语义完全不同。
提示:判断一个字段能否共用,不要看字段名,要看它的约束条件、取值域和变更流程。三者任一不同,就应该考虑拆表。
4. 实操建表:从零落地两张表
4.1 建表语句与索引规划
先看系统用户表的建表语句,这里以常见的MySQL语法为例,其他数据库按方言微调即可。
CREATE TABLE sys_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', username VARCHAR(64) NOT NULL COMMENT '登录名', password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希', real_name VARCHAR(64) DEFAULT NULL COMMENT '真实姓名', org_id BIGINT UNSIGNED DEFAULT NULL COMMENT '所属组织', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0禁用 2锁定', last_login_at DATETIME DEFAULT NULL COMMENT '最近登录时间', created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username), KEY idx_org_status (org_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';索引规划上,uk_username保证登录名唯一,idx_org_status支撑“按组织查启用用户”这类高频查询。系统用户表数据量小,索引不必过度设计,两三个组合索引足够。
客户端用户表的建表语句则要更谨慎地考虑唯一约束和软删除的配合。
CREATE TABLE client_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', mobile VARCHAR(20) NOT NULL COMMENT '手机号', nickname VARCHAR(64) DEFAULT NULL COMMENT '昵称', avatar_url VARCHAR(512) DEFAULT NULL COMMENT '头像地址', register_channel VARCHAR(32) DEFAULT NULL COMMENT '注册渠道', member_level TINYINT NOT NULL DEFAULT 0 COMMENT '会员等级', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0冻结 2注销中', last_login_at DATETIME DEFAULT NULL COMMENT '最近登录时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间', PRIMARY KEY (id), UNIQUE KEY uk_mobile (mobile), KEY idx_channel_created (register_channel, created_at), KEY idx_last_login (last_login_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端用户表';这里有个细节值得展开:uk_mobile和deleted_at的配合。如果用户注销后手机号仍被唯一索引占用,他就无法用同一手机号重新注册,体验很差。常见做法是把唯一索引改成(mobile, deleted_at)的组合唯一,或者注销时把手机号迁移到归档表。我倾向于前者,改动小、逻辑清晰,但要注意deleted_at为NULL时组合唯一的行为在不同数据库里表现不同,上线前务必实测。
4.2 关联关系的处理方式
两张表之间是否需要关联?答案是:大多数情况下不需要直接外键关联。系统用户和客户端用户是两个平行的身份体系,它们之间没有天然的从属关系。如果业务上确实存在“某个系统用户负责管理某批客户端用户”的场景,正确做法是建一张管理关系表,而不是在任一方表里加对方的外键。
CREATE TABLE sys_user_client_scope ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, sys_user_id BIGINT UNSIGNED NOT NULL COMMENT '系统用户', client_user_id BIGINT UNSIGNED NOT NULL COMMENT '客户端用户', scope_type TINYINT NOT NULL DEFAULT 1 COMMENT '管理类型', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_pair (sys_user_id, client_user_id), KEY idx_client (client_user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户管理范围表';这样设计的好处是关系可扩展、可审计,而且不会污染两张主表的职责边界。我见过在客户端用户表里加manager_id指向系统用户的做法,短期省事,长期一旦管理关系变成多对多就得推倒重来。
4.3 参数选择背后的计算逻辑
建表时几个参数值得算一算。以客户端用户表为例,假设预期三年内达到5000万用户,单条记录平均占用约300字节(含索引开销),那么数据部分约15GB,加上索引和冗余,单表控制在30GB以内是合理的。这个量级下,单表查询配合合适索引完全扛得住,不必过早分库分表。
但如果预期是5亿用户,单表就会逼近存储和查询的瓶颈,这时候就要提前规划分库分表策略,比如按用户ID哈希分片,或者按注册时间做冷热分离。分片键的选择要结合查询模式:如果高频查询是按手机号登录,那分片键最好能和手机号相关,否则每次登录都要扫全部分片。
系统用户表则几乎不用考虑这些,几千到几万的量级,单表加索引绰绰有余。这也是拆表的一个隐性收益:让简单的表保持简单,把复杂度留给真正需要的表。
5. 常见问题与排查技巧实录
5.1 合并表后遗症:状态字段打架
最常见的坑就是合并表后状态字段语义冲突。系统用户需要“锁定”状态(比如密码错误次数过多),客户端用户需要“注销中”状态,两者取值域一混,查询条件就写不清楚。排查这类问题的思路是:先梳理所有读写该字段的代码路径,列出每个路径期望的取值含义,你会发现冲突点往往集中在少数几个接口上。解决办法没有捷径,只能拆表或加类型前缀字段做隔离,后者是治标不治本。
5.2 登录态串号:会话管理没隔离
另一个高频问题是会话管理没做隔离。系统用户和客户端用户如果共用一套token签发逻辑,就可能出现客户端token能访问后台接口的情况。根因通常是鉴权中间件没有区分身份类型。修复方式是在token里带上身份类型声明,网关层按类型路由到不同的鉴权逻辑,两张表的登录接口也彻底分开。
5.3 数据迁移时的字段映射陷阱
从合并表拆回两张表时,字段映射是最容易出错的地方。我整理了一个速查表,覆盖常见映射决策。
| 原合并字段 | 拆分后归属 | 处理建议 |
|---|---|---|
| 手机号 | 客户端表必留,系统表可选 | 系统表侧允许为空 |
| 密码哈希 | 两边都留但策略不同 | 系统侧强制复杂度,客户端侧放宽 |
| 状态 | 两边都留但取值域重定义 | 迁移时按原类型字段做映射 |
| 昵称 | 仅客户端表 | 系统表用真实姓名替代 |
| 最近登录 | 两边都留 | 语义一致,可保留 |
迁移时务必先做双写验证:新旧两套表同时写入,跑一段时间对比数据一致性,确认无误再切读。这个步骤不能省,我见过直接切流导致部分用户登录失败的案例。
5.4 独家避坑清单
- 不要在客户端用户表里存密码明文或可逆加密,慢哈希是底线。
- 系统用户表的
created_by在批量导入时一定要填,否则出问题无法溯源。 - 客户端表的软删除要和唯一索引一起设计,别等上线后才发现注销用户无法重新注册。
- 两张表的登录接口从第一天就分开,别想着“以后再加类型判断”。
- 权限校验统一走一个入口,但内部按身份类型分流,避免逻辑散落各处。
6. 我在实际项目中的几点体会
踩过几次坑之后,我越来越倾向于一个原则:用户体系的拆分要趁早,越晚成本越高。项目初期数据量小、逻辑简单,合并表看起来毫无问题,但用户体系是会生长的,权限会变复杂、运营需求会变多、合规要求会变严,等到这些压力同时到来时再拆,涉及的面就太广了。
另一个体会是,拆表不等于完全隔离。两张表在审计日志、风控、通知这些横切面上是可以共用基础设施的,比如统一的日志表用user_type字段区分来源,统一的短信通道按类型路由。关键是把“身份定义”和“身份能力”分开:定义各自独立,能力按需共享。
最后分享一个小技巧:设计阶段画一张身份-场景矩阵,横轴是身份类型,纵轴是业务场景(登录、鉴权、审计、运营、注销),每个交叉格标注该场景下两张表是否需要交互。这张图能帮你在动手建表前就把边界想清楚,比事后改代码划算得多。