1. 一个看似多余的设计,为什么值得单独拆开讲
做数据库设计的人,第一次看到“系统用户表”和“客户端用户表”这两个概念并列出现时,十有八九会冒出一个念头:这不是重复建表吗?都是人,都有账号密码,都有昵称头像,凭什么要拆成两张表?我最早接触这类设计时也这么想,甚至一度觉得这是过度设计,是某些团队为了显得架构复杂而人为制造的概念。直到后来接手了一个需要同时支撑内部运营后台和外部客户端的系统,才真正理解这两张表分开的必然性——它们看起来都是“用户”,但本质上服务的是两套完全不同的业务逻辑,承载的是两类完全不同的生命周期。
先把结论摆在前面:系统用户表(后台用户表)和客户端用户表(前台用户表)不是同一类实体的两种叫法,而是两个不同的业务实体。前者代表的是“系统的操作者”,后者代表的是“系统的服务对象”。这个区别听起来像文字游戏,但它直接决定了字段设计、权限模型、关联关系、数据治理策略乃至整个系统的安全边界。搞混这两者,轻则表结构臃肿、查询性能下降,重则权限越界、数据泄露。
这篇文章适合三类人看:正在做系统架构设计、需要决定用户体系怎么拆的开发者;已经有两张表但设计得一团糟、想回头重构的工程师;以及刚入行、对“为什么不能一张表搞定”心存疑惑的新人。我会从业务本质讲到字段设计,从关联关系讲到踩坑经验,尽量把这件事说透。全文基于常见的工程实践展开,涉及具体方案时会说明取舍逻辑,你可以直接对照自己的项目做判断。
2. 两张表的业务本质差异:操作者与服务对象
2.1 系统用户表服务的是“谁在管系统”
系统用户表,通常叫sys_user、admin_user或staff_user,它记录的是能够登录管理后台、执行管理操作的人。这类用户的典型特征是:数量有限、身份明确、权限分级、生命周期与组织关系绑定。一个中等规模的系统,系统用户可能就几十到几百个,每一个都对应着真实的岗位职责——运营、客服、财务、审核、超管。他们的账号不是自己注册的,而是由更高权限的管理员创建并分配角色。
这就决定了系统用户表的核心字段围绕“身份”和“权限”展开。用户名、密码哈希、真实姓名、所属部门、角色标识、状态(启用/禁用)、最后登录时间、创建人,这些是标配。注意“创建人”这个字段——系统用户是被创建的,不是自注册的,这个字段本身就是业务逻辑的体现。另外,系统用户往往需要操作审计,所以登录日志、操作日志的关联主体也是它。
我见过一些团队把系统用户和客户端用户混在一张表里,用user_type字段区分。短期看省事,长期看是灾难。原因很简单:系统用户的查询几乎总是带权限过滤和状态过滤,而客户端用户的查询几乎总是带业务属性过滤,两类查询的索引策略、数据量级、访问频率完全不在一个量级。混在一起后,索引建也不是、不建也不是,最后只能靠加各种复合索引硬撑,写性能被拖垮。
2.2 客户端用户表服务的是“谁来用产品”
客户端用户表,通常叫app_user、customer、member或client_user,它记录的是产品的终端使用者。这类用户的特征是:数量大(可能百万级甚至更多)、自注册为主、身份验证相对轻量、生命周期由用户自己掌控(注册、注销、沉默、召回)。他们不需要知道系统内部有哪些角色,只需要知道自己能用什么功能。
客户端用户表的核心字段围绕“业务属性”和“触达方式”展开。手机号或邮箱(作为登录凭证)、密码哈希或第三方登录标识、昵称、头像、注册来源渠道、会员等级、积分余额、最后活跃时间、设备标识、推送令牌。注意“注册来源渠道”和“设备标识”这两个字段——它们的存在本身就说明这张表要服务于增长分析和精细化运营,这是系统用户表完全不需要考虑的事情。
还有一个容易被忽略的差异:客户端用户表往往需要承载大量“弱身份”信息。比如游客态、试用态、第三方授权态,这些状态在系统用户表里几乎不存在。系统用户要么是启用要么是禁用,边界清晰;客户端用户的状态机要复杂得多,沉默用户、流失用户、召回用户,每一种状态都对应不同的运营策略和字段标记。
2.3 用一张对照表把差异钉死
光说概念容易飘,直接上对照表更直观。下面这张表是我在实际项目中总结的,覆盖了设计时最需要关注的维度:
| 维度 | 系统用户表 | 客户端用户表 |
|---|---|---|
| 数据量级 | 几十到几千 | 万级到千万级 |
| 创建方式 | 管理员创建、邀请 | 自主注册、第三方授权 |
| 身份验证 | 强验证,常配双因素 | 轻验证,短信/邮箱为主 |
| 权限模型 | RBAC,角色+菜单+按钮 | 功能开关、会员等级 |
| 生命周期 | 与组织关系绑定 | 自主注册注销,可沉默召回 |
| 审计要求 | 高,操作全留痕 | 中低,侧重行为分析 |
| 关联主体 | 角色、部门、权限、日志 | 订单、内容、设备、渠道 |
| 查询特征 | 低频、带权限过滤 | 高频、带业务属性过滤 |
| 安全等级 | 最高,涉及系统控制权 | 较高,涉及个人隐私 |
这张表不是让你照抄,而是让你在设计时逐项问自己:我这个字段到底服务于哪一类用户?如果一张表里同时出现“所属部门”和“会员等级”,那基本可以判定设计出了问题,因为这两个字段服务的是完全不同的业务场景,硬塞在一起只会让表结构失去内聚性。
3. 字段设计上的分水岭:哪些字段绝不能共用
3.1 认证字段的差异比想象中大
两张表都有密码字段,但密码策略完全不同。系统用户的密码通常要求更高强度、定期更换、失败锁定、双因素认证,密码哈希算法也可能用更重的方案(比如 bcrypt 配合更高的 cost 因子)。客户端用户的密码策略要兼顾体验,强度要求相对宽松,更多依赖短信验证码、第三方授权来降低密码依赖。
更关键的是登录凭证的构成。系统用户往往用“用户名+密码”或“工号+密码”,用户名是内部约定的、稳定的;客户端用户用手机号、邮箱或第三方 openid,这些凭证可能变更(换手机号是常事),所以客户端用户表通常需要一张单独的凭证表来支持多凭证绑定,而系统用户表往往一个用户对应一个固定账号即可。
我在一个项目里踩过坑:早期把系统用户的登录凭证也设计成可多绑定的,结果权限审计时根本说不清“这次操作到底是哪个账号发起的”,因为一个系统用户可能绑了三个登录方式。后来改成系统用户单一固定账号、客户端用户多凭证绑定,审计链路才清晰。这个教训说明:认证字段的设计要服务于审计需求,而不是单纯追求灵活。
3.2 权限字段:RBAC 与功能开关是两套语言
系统用户表的权限设计几乎必然走 RBAC 路线:用户表关联角色表,角色表关联权限表,权限表关联菜单和按钮。用户表本身只存一个角色标识或通过中间表关联,不直接存权限明细。这样做的好处是权限变更集中管理,加一个角色就批量生效。
客户端用户表则完全不同。客户端用户不需要“角色”这个概念,他们需要的是“功能是否可用”。比如是否开通了高级功能、是否是会员、是否有某个业务的准入资格。这些通常用功能开关、会员等级、权益标识来表达,存在用户表或单独的权益表里。把 RBAC 那套搬到客户端用户表上,是典型的过度设计——客户端用户根本理解不了“角色”是什么,运营配置起来也一头雾水。
这里有个实操建议:系统用户表的权限字段设计要预留“数据权限”的扩展位。很多系统初期只做功能权限(能不能点这个按钮),后期必然要做数据权限(能看哪些部门的数据)。如果用户表里没有部门或数据范围的关联字段,后期改造会非常痛苦。而客户端用户表不需要考虑这个,它的“数据范围”就是用户自己的数据,天然隔离。
3.3 状态字段:一个管“能不能登录”,一个管“愿不愿意来”
系统用户表的状态字段通常很简单:启用、禁用、锁定。语义是“这个操作者还能不能操作系统”,是管理动作的结果。禁用往往伴随离职、违规、安全事件,需要记录禁用原因和操作人。
客户端用户表的状态字段要复杂得多,而且经常需要多个字段配合表达。账号状态(正常/封禁/注销)是一个维度,活跃状态(新注册/活跃/沉默/流失)是另一个维度,会员状态(普通/付费/过期)又是第三个维度。这三个维度不能揉成一个字段,否则查询和运营都会抓狂。我见过把“封禁”和“沉默”塞进同一个 status 字段的设计,结果运营想查“沉默但未封禁的用户”时,SQL 写得像解谜游戏。
提示:客户端用户表建议把“账号状态”和“运营状态”拆成两个字段。账号状态由风控和客服维护,运营状态由增长和运营维护,职责分离,互不干扰。
3.4 扩展信息:JSON 字段该给谁用
两张表都可能需要存一些非结构化的扩展信息,但用法不同。系统用户表的扩展信息通常是配置类的,比如个性化偏好、界面设置,量小且稳定,用 JSON 字段存没问题。客户端用户表的扩展信息则可能是业务属性的大杂烩,比如不同渠道带来的差异化标签、第三方授权的原始信息,量大且结构多变。
我的经验是:客户端用户表的 JSON 扩展字段要慎用,因为一旦业务方发现“什么都能往里塞”,这张表很快就会变成一个无法维护的黑洞。更稳妥的做法是把高频查询的扩展属性抽成独立字段或独立表,JSON 只存真正低频、无需索引的杂项。系统用户表反而可以宽松一些,因为它的数据量小,即使 JSON 里藏了脏数据,排查成本也可控。
4. 关联关系设计:外键指向谁,决定了系统的骨架
4.1 业务表该关联哪张用户表
这是设计中最容易出错的地方。一个订单表、一条评论、一份操作记录,它们的user_id到底指向系统用户表还是客户端用户表?答案取决于这条数据的产生者是谁。订单是客户端用户下的,评论是客户端用户发的,这些业务表的user_id指向客户端用户表。而操作日志、审核记录、配置变更,产生者是系统用户,指向系统用户表。
问题在于,有些业务表可能同时涉及两类用户。比如一个客服工单,创建者是客户端用户,处理者是系统用户,那这张表就需要两个外键字段,分别指向两张表,字段名要明确区分,比如customer_id和operator_id。我见过用user_id和admin_id命名的,也见过用created_by和handled_by的,命名风格可以商量,但语义必须清晰,绝不能用一个user_id含糊带过。
还有一种情况是“代操作”:系统用户以客户端用户的名义执行某个动作。这时候业务表里既要记录实际受益人(客户端用户),也要记录实际操作者(系统用户),两个字段缺一不可。审计追溯时,缺了任何一个都会导致责任说不清。
4.2 跨表查询的边界与性能考量
两张表分开后,跨表查询不可避免。比如后台需要展示“某个客户端用户的订单列表,并显示处理该订单的客服”。这就涉及客户端用户表、订单表、系统用户表三表关联。数据量小的时候无所谓,数据量大时要注意:客户端用户表是千万级,系统用户表是百级,关联时务必让驱动表是小表,避免大表全扫描。
一个实用的优化思路是:在业务表里冗余少量用户展示字段。比如订单表里冗余客户端用户的昵称和系统用户的姓名,查询列表时不用回表关联,只有进详情页才查完整信息。冗余字段的更新通过事件或定时任务同步,牺牲一点一致性换取查询性能。这个取舍在客户端用户量大的系统里几乎是必选项,但要注意冗余字段只放展示用的、变更不频繁的,别把权限相关的字段也冗余进去。
4.3 单点登录与账号体系的隔离
有些系统会做统一登录,让系统用户和客户端用户走同一个认证入口。这时候要特别小心:认证入口可以统一,但账号体系必须隔离。统一入口只是说“从这里验证身份”,验证完之后要能区分“这是哪类用户”,然后路由到不同的权限体系。如果认证层不区分用户类型,直接把 token 发给前端,前端再自己判断,那等于把安全边界交给了客户端,是典型的危险设计。
我的做法是在 token 的 payload 里明确带上用户类型标识,服务端每次鉴权都校验这个标识,确保系统用户的 token 不能访问客户端接口,反之亦然。这个校验看起来多余,但它是防止越权访问的最后一道闸门。曾经有项目因为漏了这个校验,导致客户端用户拿着自己的 token 调到了后台接口,虽然接口内部还有权限判断没造成实际损失,但已经是一次严重的安全隐患。
5. 踩坑实录:那些混用两张表带来的真实麻烦
5.1 坑一:用 user_type 字段硬撑,索引彻底失控
早期项目为了图快,把两类用户塞进一张user表,加了个user_type字段(1 是系统用户,2 是客户端用户)。刚开始数据量小,查询都走全表扫描也没觉得慢。等到客户端用户涨到几十万,问题来了:后台查系统用户列表时,WHERE user_type = 1只能过滤出几百条,但优化器有时候不走索引,因为user_type的区分度太低(99% 都是 2),走索引还不如全表扫。结果一个简单的后台用户列表页,响应时间从几十毫秒涨到两秒多。
排查过程是这样的:先看慢查询日志,发现这条 SQL 扫描行数异常;再用EXPLAIN看执行计划,确认优化器放弃了user_type索引;最后分析数据分布,才明白区分度太低是根因。修复方案是拆表,把系统用户单独拎出来。拆完之后,系统用户表的查询再也不用带user_type条件,索引干净,响应回到毫秒级。这个坑的教训是:用类型字段区分两类生命周期完全不同的实体,等于给索引埋雷。
5.2 坑二:权限字段污染,客户端用户被误授权
另一个项目在混用表的基础上,权限字段也混着用。系统用户有role_id,客户端用户这个字段是空的。有一次做批量数据迁移,脚本没写严谨,把一批客户端用户的role_id误填成了默认角色。结果这批客户端用户登录后,前端根据role_id判断“有管理权限”,显示了后台入口。虽然服务端接口还有一层校验挡住了实际操作,但前端暴露入口本身就是事故。
这个坑的根因是字段语义不纯:role_id在系统用户语境下是权限角色,在客户端用户语境下毫无意义,但表结构允许它存在,就给了误操作空间。拆表之后,客户端用户表根本没有role_id这个字段,从结构上杜绝了这类错误。这让我深刻体会到:好的表结构不只是为了查询快,更是为了从物理层面消除错误操作的可能性。
5.3 坑三:注销逻辑互相干扰
客户端用户支持自主注销,注销后数据要保留一段时间做审计,账号标记为已注销。系统用户不支持自主注销,只能被禁用。混用表的时候,注销逻辑写在一个服务里,判断条件写得不够严谨,导致某次系统用户被误标记为“已注销”,登录时提示账号不存在,排查了半天才发现是注销服务的过滤条件把系统用户也扫进去了。
拆表之后,注销服务只操作客户端用户表,系统用户的禁用走另一套流程,两条链路彻底隔离,再没出过类似问题。这件事说明:两类用户的生命周期操作必须物理隔离,靠代码里的 if 判断来区分,迟早会漏。
5.4 坑四:统计口径打架
运营要统计“平台总用户数”,技术给的口径是user表总行数。结果这个数字里混进了系统用户,对外报数时被质疑“你们平台才几百个真实用户?”。后来拆表,统计口径明确为客户端用户表行数,系统用户单独统计,两边各说各话,再没混淆过。这个坑看似是统计问题,本质还是实体没分清。
6. 拆表之后的落地细节:迁移、兼容与长期维护
6.1 存量数据迁移的稳妥步骤
如果系统已经混用了一段时间,拆表不是改个 DDL 就完事,要按步骤来。第一步,建新表,结构按前面的设计原则定好。第二步,写迁移脚本,把原表中user_type = 1的数据导入系统用户表,user_type = 2的导入客户端用户表,迁移时保留原 ID,避免业务表外键失效。第三步,双写过渡,新产生的数据同时写旧表和新表,跑一段时间验证一致性。第四步,切读,把查询逐步切到新表。第五步,停写旧表,观察一段时间后下线。
每一步都要有回滚预案。我一般会在迁移前对旧表做全量备份,迁移脚本先在测试环境跑三遍,核对行数和关键字段的校验和。迁移过程中最怕的是 ID 冲突和自增序列没对齐,所以新表的自增起始值要设成旧表最大值加一,或者干脆用独立的 ID 生成策略。
6.2 接口层的兼容处理
拆表对上层接口是透明的,但要注意接口返回的用户信息结构。如果原来接口返回的user对象里混着两类字段,拆表后要按用户类型返回不同的字段集。系统用户接口返回角色、部门,客户端用户接口返回会员等级、积分。前端如果做了字段兼容,要同步调整,避免出现“字段突然消失”的问题。
一个稳妥的做法是在网关层做用户类型路由,系统用户的请求走后台服务,客户端用户的请求走前台服务,两边各自组装自己的用户信息,互不感知。这样即使后续两张表继续演化,也不会互相牵连。
6.3 长期维护:两张表的演化节奏不同
拆开之后,两张表的演化节奏会明显分化。系统用户表可能一年加两三个字段,都是权限和审计相关的;客户端用户表可能一个季度就加一批字段,都是运营和增长相关的。这种分化是健康的,说明两张表各自服务于自己的业务,没有被强行捆绑。维护时要守住一条线:任何新字段在加之前,先问它服务于哪类用户,如果两类都沾边,大概率是设计没想清楚,需要再拆。
另外,两张表的备份策略、归档策略也可以不同。系统用户表数据量小,全量备份成本低,可以保留更长周期;客户端用户表数据量大,适合增量备份加冷热分离,沉默用户的数据可以归档到冷存储。这些策略分开后,运维成本反而比混在一起时更低,因为不用再为一张巨表设计复杂的混合策略。
7. 几个容易被忽略的设计细节
7.1 系统用户表的“最后登录”与客户端的不同用法
两张表都有“最后登录时间”,但用途不同。系统用户表的最后登录时间主要用于安全审计,长时间未登录的账号要提醒或自动禁用;客户端用户表的最后登录时间主要用于活跃度分析,是召回策略的输入。所以前者要精确到秒并保留登录 IP,后者精确到天即可,重点在统计活跃区间。字段名可以一样,但精度和配套字段要按用途设计。
7.2 客户端用户表的手机号唯一性陷阱
客户端用户用手机号注册,手机号字段加唯一索引是常规操作。但要注意:用户换绑手机号时,旧手机号要能释放出来给新用户注册,同时历史订单还要能追溯到旧号码。这时候唯一索引会打架。常见解法是手机号存在独立的凭证表里,用户表只存当前主凭证的引用,历史凭证保留在凭证表并标记失效。这样既保证当前唯一,又保留历史追溯能力。系统用户表一般不需要这套,因为系统用户的账号是固定的,不涉及换绑。
7.3 两张表都要防的“软删除”问题
两张表都可能需要软删除,但语义不同。系统用户的软删除等于禁用,数据要保留供审计;客户端用户的软删除等于注销,数据要按隐私要求做脱敏或限期清理。所以软删除字段的设计要配合各自的清理策略,不能简单加个is_deleted就完事。客户端用户表建议加deleted_at时间戳,配合定时任务做分级清理;系统用户表用状态字段表达即可,不需要真正的删除标记。
7.4 命名规范:从表名到字段名的一致性
最后说个看似琐碎但影响深远的事:命名。系统用户表建议统一用sys_前缀,客户端用户表用app_或client_前缀,业务表关联时字段名也对应,比如sys_user_id和app_user_id。这样在任何一张业务表里看到外键,都能立刻判断它指向哪类用户,不用翻文档。我见过用user_id和uid混着命名的项目,每次排查问题都要先确认这个uid到底是谁的 ID,效率极低。命名规范定下来之后,最好写进团队的建表模板里,从源头统一。
这套设计思路我在几个项目里反复验证过,拆表初期确实要多写一些代码,但后期无论是排查问题、做权限审计还是应对业务增长,都比混用一张表轻松得多。数据库设计这件事,前期多想一步,后期少熬十个夜,用户表的分与合就是最典型的例子。