干后端这些年,我见过太多人把"CRUD 工程师"当成一个自嘲的词。其实 CRUD 本身没什么可羞耻的——一个系统 90% 的功能说到底都是增删改查,复杂如订单中台、支付系统,底层逻辑也是这张表读出来、那张表写进去。真正拉开差距的,是你在写 CRUD 的时候,眼睛里看的是"这张表怎么存",还是"这张表为什么长这样"。
第一次感受到这个差距,是我在维护一套商城系统的用户模块时。需求方提了一个很小的变更:账户状态里要加一个"冻结待解冻"的中间态。当时的account_status字段是代码里写死的枚举,从 0 到 2 三个值,前端、后端、数据库各有一份映射。为了加这一个枚举值,前后端联调了两天,上线还漏改了某个查询接口的过滤条件。后来我们做了一个很"土"的决定:把所有状态枚举抽到一个数据库表里,让代码去读表。那块表,就是我今天想聊的数据库设计里的"固化表"。
1. 写了三年 CRUD,两种人在数据库设计上的第一个分水岭
1.1 先给"固化表"一个准确定义
"固化表"不是一个严格的教科书名词,它在业务开发里更多是一种约定俗成的叫法:用来承载系统运行规则、枚举定义、参数配置、权限模型这些"本质不会频繁变化、但必须被系统识别"的数据的表。与之相对的是业务流水表,比如订单表、日志表、用户行为表,这些表才是在不停增长的真正的"数据"。
很多人容易把固化表等同于字典表,这是一个狭义的理解。字典表只是固化表家族里最常见的一员。按用途分,固化表可以粗略分为这样几类:
| 类型 | 用途 | 典型场景 |
|---|---|---|
| 字典表 | 枚举、类型、状态的统一定义 | 账户状态、用户类型、文章分类层级 |
| 参数配置表 | 全局开关、业务阈值、外部调用参数 | 系统开关、限流阈值、接口重试次数 |
| 业务规则表 | 可配置的算法参数和策略 | 运费模板、积分规则、营销活动配置 |
| 权限模型表 | RBAC 权限体系的支撑 | 用户、角色、菜单、权限点 |
| 流程与状态机表 | 状态流转规则的定义 | 订单状态机、审核流程节点 |
这五类表的数据量通常都不大,但整个系统的行为逻辑都构建在它们之上。你可以把固化表理解成系统的"宪法":业务数据表是每天都在发生的社会活动,而宪法规定了这些活动能怎么发生、不能怎么发生。
固化表最核心的价值,是把"行为规则"变成了"数据"。规则本身是代码里的一段逻辑,改起来要走发布流程;而数据可以被动态修改、管理、追溯。当你把规则变成数据,系统的扩展能力就一下子打开了。
1.2 为什么这张表决定了你的天花板
写一个接口,把业务数据从 MySQL 拿出来套个模板返回;和设计出这套系统的扩展边界,是两种完全不同的工作。前者是功能实现,后者是架构设计。会不会使用固化表,恰好就是这两类人最常见的分界线。
固化表之所以是分水岭,是因为它逼你想三个问题:
- 这个枚举值将来会不会变,要不要让运营自己加?
- 这个配置项如果写死在代码里,一次变更需要多久,要几个系统同步改?
- 权限或规则变了,是改代码上线,还是改一条数据库记录?
举一个最直观的例子:订单状态。如果状态逻辑是代码里写死的 switch 分支,那每一次状态变更都要发版,而且要同步改前端页面的文案和样式;如果把订单状态做成固化表,状态的取值、顺序、展示名称都变成一条条数据,新状态上线就变成了"插入一行记录"外加补充状态机的跳转规则。
我后来在多个项目里反复验证过这件事:一个系统里"硬编码的枚举"越少,它应对需求变化的能力就越强。所谓从 CRUD 到架构思维,不是换一个更复杂的框架,而是先学会"把不变的东西稳住,把可变的东西交给数据"。固化表就是你设计这个边界时最趁手的工具。
2. 固化表最常见的三个战场:用户、博客、订单
2.1 用户信息表:把"状态"从业务表里拆出来
先拿用户信息表这个几乎所有人第一次设计系统都会碰到的例子来说。很多人第一版用户表是这样写的:
CREATE TABLE t_user ( id INT PRIMARY KEY, username VARCHAR(64), password VARCHAR(64), gender CHAR(2) -- 直接存 "男"/"女" );这张表有两个明显的隐患。第一,gender直接存中文字符串,如果未来要做国际化,语言切换怎么办?查询条件里写WHERE gender = '男'吗?前后端传值倒是方便了,但所有依赖这个字段的逻辑都被钉死在了中文上。第二,没有用户状态、用户类型这类字段,因为一开始觉得用不到,等需求真来了,只能靠ALTER TABLE硬加,然后全链路补逻辑。
把账户状态、用户类型这类字段拆到字典表之后,业务表里只保留一个稳定的编码值。以账户状态为例:
| item_code | item_name | 备注 |
|---|---|---|
| ACTIVE | 正常 | 可正常登录 |
| FROZEN | 冻结 | 限制登录 |
| PENDING_REACTIVATE | 冻结待解冻 | 用户已申请解冻,运营待处理 |
如果需求方要求加一个"冻结待解冻"的中间态,往字典表里插一行,调整一下状态机流转规则即可。用户表本身不需要加字段,也不需要动接口。这就是用户信息表设计里最值得学习的"第 1 关":字段的取值应该语义化、可扩展,而不是写死。
2.2 博客系统:分类、标签和状态流转
博客系统的数据库设计里,分类表和标签表就是典型的固化表。你写一篇文章,标题和正文会变,但分类树、标签集合是相对稳定的系统语义。如果每次创建文章都要手动输入分类名,而不是从分类表里选一个,那这个系统基本谈不上什么设计。
分类表通常会做成层级结构,父子分类之间用parent_id关联。这里尤其要注意的一点是:分类的层级路径要不要冗余一个path字段。比如父分类 / 子分类 / 孙分类这样的完整路径,在展示面包屑和计算层级时非常方便,但这属于对"树形固化数据"的常见优化,不是所有系统都需要。
文章状态是另一个容易被写死的点。一篇文章有草稿、待审核、已发布、已下架这几种状态,它们是文章在系统里的"位置",而不是文章的内容本身。所以这些状态值应该放在字典表里,由字典表统一定义取值和显示名称。更进一步,状态之间的流转规则——比如"已下架的文章能不能直接重新发布"——可以做成状态机配置表,而不是散落在各个 Service 方法里的 if else。
2.3 订单与权限:固化规则背后的体系化力量
订单系统和权限系统是固化表体系最重的地方。先看权限,经典的 RBAC 模型里有四张核心表:用户表、角色表、菜单表、权限点表,外加它们之间的关联表。这套模型本身就是一张巨大的"固化表体系":权限点是一张表,角色是一张表,用户和角色、角色和权限点的关系再各用一张表。
这套设计的力量在哪里?在一个功能上线后,产品经理说这个菜单要配给某个新角色。如果权限点是硬编码在代码里的,比如can_edit_article,你需要发版;如果权限点是表里的一条记录,那么在管理后台里勾选一下,新角色下一秒就能看到这个菜单。权限系统的需求变更频率非常高,固化表在这里不是可选项,而是必选项。
订单系统同理。订单状态由已创建流转到已支付、已发货、已完成,每一步都有前置条件和后置动作。把状态机定义固化到表里,配合定时任务和消息队列,整个订单流转就变成了数据驱动。更妙的是,一旦状态机表可配置,很多"特殊订单"的跳转规则就无需专门写代码了。
3. 固化表设计的六个细节,全是实操经验
3.1 主键之外,一定要有一个稳定的业务编码
固化表的主键用自增 id 完全没问题,但你不能让代码靠自增 id 去识别业务含义。原因很简单:不同环境的 id 可能不一样,测试库里状态 1 是正常,生产库里状态 1 可能被后来插入的数据顶掉了。更合理的做法是给每一行固化数据一个语义化的业务编码(code),并在代码里通过这个 code 做判断。
比如账户状态的编码可以这样设计:ACTIVE、FROZEN、PENDING_REACTIVATE,用户表里存的就是这些字符串。查询条件写WHERE account_status = 'FROZEN',即使不看字典表,别人也能从 SQL 里读出业务含义。如果全部用 0、1、2 这种魔法数字,过三个月你再看那段 SQL,保证要翻代码才能想起来 1 代表什么。
我见过不少团队用 int 存字典值,理由是省空间。一张用户表几千万行,省几个字节有意义,但为了这几个字节牺牲可读性和可维护性,完全不划算。VARCHAR(32) 存短编码在 InnoDB 里的存储开销并没有你想象中那么夸张,默认字符集是 utf8mb4 的情况下,几个字符的编码几乎可以忽略不计。
3.2 字段设计:code、name、sort、status 之外还缺什么
一张设计合格的固化表,通常最少包含这些字段:
id:自增主键code:业务编码,全表唯一,语义化name:显示名称sort_no:排序号,控制展示顺序status:启用状态,1 启用 0 停用remark:备注,说明这个枚举值或配置项的用途
如果只有这些,只能说是一张能用的表。真正的实战经验是:固化表其实是前端和后端之间的一份"协议",它要承载前端展示需要的所有信息。以状态字典为例,前端展示不同状态时通常要配不同的颜色——正常是绿色,异常是红色。所以我会在字典项表里加一个color_code字段,用来存这条字典项在前端要渲染的颜色值。
再比如用户类型字典,前端可能要根据用户类型显示不同的标签图标。这时候一个icon_url字段就会非常有用。数据库表多两个字段的成本几乎为零,但你省掉的是前端代码里另一套写死的映射表。很多"表设计不好导致前后端各写一份映射"的问题,根源就在于设计固化表的时候只考虑了后端能识别,没想到前端也需要被满足。
3.3 时间戳和逻辑删除:所有支撑表的通用底座
固化表要加created_at和updated_at吗?我的答案是必须加,而且我建议用数据库默认值来自动维护。MySQL 8.0 里可以直接这样写:
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP这样插入和更新都不需要应用层手动维护时间,大大减少遗漏。
逻辑删除字段要不要?这要看场景。字典表和配置表我强烈建议保留逻辑删除,因为配置类的历史记录需要被追溯。比如一条状态被停用了,三个月后想查它之前是什么状态,如果物理删掉了,什么都查不到。但是逻辑删除会带来一个经典问题:唯一索引冲突。比如code字段设了唯一索引,删除一条再插入同 code 的新记录,就会被旧记录的 deleted 行挡住。
解决方案有两个:一是把逻辑删除字段设计成deleted_at DATETIME NULL DEFAULT NULL,唯一索引改成UNIQUE KEY uk_code (code, deleted_at),这样软删后再次插入同 code 的记录不会冲突;二是更简单粗暴,干脆给删除的记录重命名 code,比如加一个后缀_deleted_20241212。我个人更推荐前者,因为后者会让数据变得很脏。
3.4 字典表和配置表的边界:别把两种东西混在一个表里
这是固化表设计里最容易被搞混的一类问题。字典表和参数配置表虽然性格相似,但服务的目标不同。
字典表回答的是"这个字段可能有哪几种取值",比如账户状态有正常、冻结、待解冻;配置表回答的是"某个业务的阈值或开关是多少",比如订单超过 30 分钟未支付自动关闭、注册是否需要短信验证。字典表的行数是确定的、有限的,它代表的是一组离散的枚举;配置表是一组键值对,可能是任何值。
把两者混在一张表里的典型恶果是这样:一张表里既有"订单状态"的枚举行,又有"订单超时时间 1800"的配置行。管理后台要做一套通用的增删改查界面,结果发现枚举和配置的编辑逻辑完全不一样,代码越来越臃肿,最后只能拆表重构。
我建议直接拆成两张表:字典表走"类型 + 字典项"的两层结构,配置表走 KV 结构。如果配置项非常多,还可以按业务域拆成多张配置表,比如订单配置表、用户配置表、营销配置表,避免一张表几千行 key 在一个池子里。
3.5 版本与生效时间:配置变更要能回溯
普通字典表不需要版本概念,字典项加一行就是新的枚举值,旧的也不影响。但业务规则表和参数配置表不一样,它们常常需要支持"某个时间段内生效"。
比如营销活动里"满 100 减 20"的规则,活动结束之后这条规则应该自动失效,而不是被运营手工删除。这时候业务规则表就需要增加生效时间段:effective_time和expire_time。查询时只认当前时间落在区间内的规则,配合定时任务做状态的自动切换,整个营销配置就活了。
再往前一步,如果你的业务规则变更非常频繁且需要审计,比如优惠券发放策略每个月都在调,那光有生效时间还不够,需要一张独立的变更日志表,记录每一次改动的旧值、新值、操作人、操作时间。配置出错时能快速定位是谁在什么时间改了什么,这是配置类固化表在金融、电商系统里必不可少的一环。
3.6 索引设计:固化表同样需要认真对待
固化表数据量小,很多人就完全不建索引,这其实是个隐患。固化表虽然单表行数不大,但它往往是被高频 JOIN 或高频缓存回源的对象。比如用户表 JOIN 字典表查状态名称,如果字典表的type_code + item_code没有联合唯一索引,每次 JOIN 都要全表扫描,这在访问量上来之后会非常难看。
实用的索引设计建议是:
code或type_code + item_code建唯一索引,这是业务查询的主入口- 按
sort_no排序时如果数据量上万,考虑加一个普通索引 - 通过
status过滤"只查询启用项"的场景非常多,但单列区分度不高时不一定需要单独索引,要看实际执行计划
另外一点,固化表如果高频被读取并做了缓存,索引的意义更多是保证"缓存击穿时有兜底",不至于回源查询直接拖垮数据库。别因为它小就轻视它,大系统里被压垮的往往就是看着无害的小表。
4. 落地案例拆解:用户信息表和博客系统的完整设计
4.1 案例一:用户信息表的完整建表与字段拆解
结合前面聊的思路,我给一版经过实战验证的用户信息表设计。注意看用户类型、账户状态、性别这三个字段是如何和字典表配合的。
CREATE TABLE `t_user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', `user_code` VARCHAR(32) NOT NULL COMMENT '用户编码,全局唯一,对外业务标识', `nickname` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称', `avatar_url` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '头像地址', `user_type` VARCHAR(32) NOT NULL DEFAULT 'MEMBER' COMMENT '用户类型,字典user_type', `account_status` VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '账户状态,字典account_status', `gender` VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN' COMMENT '性别,字典gender', `mobile` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号', `email` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱', `last_login_at` DATETIME NULL DEFAULT NULL COMMENT '最后登录时间', `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 NULL DEFAULT NULL COMMENT '逻辑删除时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_code` (`user_code`), KEY `idx_account_status` (`account_status`), KEY `idx_mobile` (`mobile`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';这张表的核心思路是:所有状态类字段都存语义化编码,而不是中文或魔法数字。user_type存的是MEMBER、ADMIN这类编码,account_status存的是ACTIVE、FROZEN这类编码。对应的字典表结构如下:
CREATE TABLE `t_dict_type` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', `type_code` VARCHAR(32) NOT NULL COMMENT '字典类型编码,如account_status', `type_name` VARCHAR(64) NOT NULL COMMENT '字典类型名称,如账户状态', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', `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_type_code` (`type_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='字典类型表'; CREATE TABLE `t_dict_item` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', `type_code` VARCHAR(32) NOT NULL COMMENT '所属字典类型编码', `item_code` VARCHAR(32) NOT NULL COMMENT '字典项编码,如ACTIVE/FROZEN', `item_name` VARCHAR(64) NOT NULL COMMENT '字典项显示名称,如正常/冻结', `sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', `color_code` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '前端显示颜色', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', `remark` VARCHAR(255) NOT NULL DEFAULT '' 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_type_item` (`type_code`, `item_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='字典项表';这套设计的精髓在于:字典项表的联合唯一索引uk_type_item保证了同一类型下的字典项编码不会重复;而用户表里的状态字段通过字符串编码与字典项对应,既保持了可读性,又具备了扩展性。将来加一个新的用户类型,只需往字典表插一条item_code,不需要动任何业务表结构。
4.2 案例二:博客系统的分类、标签与文章状态设计
博客系统的数据库设计里有两类固化表特别典型:一类是文章分类表,它承载了内容的结构化组织;另一类是文章状态与状态流转,它承载了内容审核发布流程。
先看分类表的设计,重点在于父子层级的处理:
CREATE TABLE `t_category` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', `category_code` VARCHAR(32) NOT NULL COMMENT '分类编码,对外标识', `parent_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父分类ID,0表示顶级分类', `category_name` VARCHAR(64) NOT NULL COMMENT '分类名称', `path` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '层级路径,如/java/spring', `sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序号', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', `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_category_code` (`category_code`), KEY `idx_parent_id` (`parent_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章分类表';path字段是我比较喜欢的一个冗余设计。它存储从根到当前节点的完整路径,查询"某个分类下所有子分类的文章"时不需要递归,直接LIKE '/java/spring/%'就能覆盖。代价是分类改名时要同步更新子节点 path,但这个操作在分类这种低频写场景下完全可以接受。
文章表的设计需要同时关联分类表和标签表,并且文章的审核发布状态要能和字典表对上:
CREATE TABLE `t_article` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', `author_id` BIGINT UNSIGNED NOT NULL COMMENT '作者用户ID', `category_id` BIGINT UNSIGNED NOT NULL COMMENT '归属分类ID', `title` VARCHAR(200) NOT NULL COMMENT '文章标题', `summary` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '摘要', `content` LONGTEXT NOT NULL COMMENT '正文内容', `status` VARCHAR(20) NOT NULL DEFAULT 'DRAFT' COMMENT '文章状态,字典article_status', `published_at` DATETIME NULL 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`), KEY `idx_category_id` (`category_id`), KEY `idx_status_published` (`status`, `published_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章基础信息表';状态字段在这里直接用了VARCHAR存DRAFT、PENDING、PUBLISHED、OFFLINE这类编码。为什么用字符串而不是 int?因为文章状态不仅要在后端的 SQL 里被判断,还要在前端的筛选列表里展示,用一段有意义的编码可以大幅降低认知成本。配合文章状态的流转配置表,就能把审核发布的流程规则固化成数据:
CREATE TABLE `t_status_transition` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', `biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型,如article', `from_status` VARCHAR(32) NOT NULL COMMENT '当前状态', `to_status` VARCHAR(32) NOT NULL COMMENT '目标状态', `action_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作名称,如提交审核', `sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序号', PRIMARY KEY (`id`), UNIQUE KEY `uk_transition` (`biz_type`, `from_status`, `to_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='状态流转配置表';有了这张表,文章审核流程里"草稿可以提交审核,审核通过才能发布,已发布文章可以下架但不能直接删除原稿"这些规则都不再是散落在代码里的 if else,而是可以被管理后台直接查看和调整的数据行。这就是固化表在内容管理系统里最优雅的落地方式。
4.3 关联查询的两种写法:联表还是冗余字段
用户表要展示状态中文名,文章表要展示分类名,这就要在查询时做翻译。翻译方案无非三种:SQL 联表、应用层翻译、缓存翻译。
SQL 联表最直接,比如LEFT JOIN t_dict_item d ON d.type_code = 'account_status' AND d.item_code = u.account_status。优点是查询结果一步到位,缺点是 SQL 变长、字典表被高频 JOIN。如果系统访问量不大,这种做法完全没问题。
应用层翻译是我更推荐的方式。项目启动时把字典表全量加载到内存或 Redis,查询用户列表后,在内存里把FROZEN翻译成"冻结"。这样既避免了 JOIN 带来的性能开销,又保持了代码的可读性。缺点是字典更新后缓存要及时失效,这需要一套可靠的缓存刷新机制。
第三种是冗余字段,即业务表里同时存编码和显示名。我不太建议这种方案,因为数据和字典一旦不同步,很容易出现"状态为冻结但冗余字段显示正常"的数据不一致问题。编码 + 应用层翻译的组合,是实战中平衡性能和可维护性的最优解。
5. 常见问题与排查技巧:固化表的反模式与真实教训
5.1 反模式一:万物皆字典,最后没人维护
见过最夸张的一个团队,把"性别"都做成了一张字典表,还配了管理后台的增删改查页面。结果就是:没有人愿意为了维护两个中文词去打开那个后台系统,字典表上线半年从未被修改过,纯属为了设计而设计。
过度设计的本质是成本远大于收益。一个字段要不要做成固化表,我自己的判断标准是三个条件连问:
- 这个字段的取值会不会变?
- 这个字段是否要被多个系统或多处代码共用?
- 这个字段是否需要由非技术人员在后台维护?
如果三个答案都是否定的,直接用代码枚举就够了。比如性别,取值稳定、只在本系统内使用、不需要运营维护,存一个标准编码加前端映射即可。固化表是为变化而生的,没有变化的地方不需要它。
5.2 反模式二:字典有表无编码,代码里东一个西一个
数据库里明明建了字典表,代码里却还是写if (user.getAccountStatus() == 2),这种半吊子设计比完全不用固化表更坑。因为字典表给团队制造了"我们有良好设计"的错觉,但代码里的魔法数字早就和字典表脱节了。
解决方案是在应用层定义一个与字典编码对齐的枚举类,所有逻辑判断都通过枚举类完成:
public enum AccountStatus { ACTIVE("ACTIVE", "正常"), FROZEN("FROZEN", "冻结"), PENDING_REACTIVATE("PENDING_REACTIVATE", "冻结待解冻"); private final String code; private final String desc; // 构造方法、getter 省略 }字典表是数据的唯一来源,应用层枚举是代码里的镜像。两者靠编码字符串对齐,任何一边新增取值,都要同步更新另一边。为了万无一失,我还会在项目启动时做一次校验:把数据库字典表里的编码和应用层枚举对比,发现不一致直接启动失败,从机制上杜绝脱节。
5.3 反模式三:配置改了不生效,缓存成了背锅侠
字典表和配置表的特点是读多写少,不加缓存会让数据库多做很多无谓查询,加了缓存又容易出现"改了数据库但缓存不更新"的问题。尤其是服务做了多节点部署后,只清一台机器的本地缓存,其他机器依然返回旧值。
我踩过这个坑后的实践方案是:字典数据以 Redis 为主缓存,key 直接包含一个版本号或时间戳。每次修改字典表时,除了更新数据库,还要更新 Redis 里的版本号。业务侧在本地缓存字典数据时,每次读取先比对版本号,版本号变了就重新加载。这个方案不依赖消息队列,实现成本低,多个节点都能及时感知变化。
还有一个非常朴素的兜底方案:给配置中心的后台管理接口加一个"刷新缓存"按钮。运营在改完配置后手动点一下,触发所有节点清空本地缓存。虽然不够优雅,但在很多内部系统里,它就是最可靠的兜底手段。
5.4 到底什么时候不该用固化表
有些场景固化表并不合适。第一种是规则本身复杂到需要规则引擎,比如风控系统里的黑白名单策略、营销系统里嵌套的折扣条件,这些复杂逻辑用一张表装不下,强行设计成表会让表结构异常抽象,普通开发者根本看不懂。第二种是数据量极大且只在单个服务内部使用的枚举,比如算法跑批的任务状态,完全可以存在配置中心或代码枚举里。
判断的边界其实就一条:这个规则是需要被"人"在外部管理和修改,还是只需要被"代码"识别。需要被外部管理的,用固化表;只需要代码识别的,用枚举。把这条规则想清楚,就不会做出"万物皆字典"的反模式了。
6. 我的真实体会:固化表之外的三问自查
说到最后,想分享一个我一直在用的方法。每次设计完一张表,不要急着写代码,先问自己三个问题:
- 这张表承载的是数据,还是承载的是规则?
- 如果某个业务规则变了,是要改代码才能上线,还是改一条数据就能解决?
- 一个完全不了解项目的新人,能从表结构和字典数据里看出系统的业务语义吗?
固化表的本质,是帮我们把"规则"从代码的硬编码里解放出来,变成可以被查看、被管理、被追溯的数据。这个过程本身就构成了从 CRUD 到架构思维的关键一跃。它不要求你用多复杂的中间件,也不要求你懂多高深的理论,只需要你在画表的时候多想一步:这个字段的取值,将来可能会怎么变?
我后来负责过的内容管理系统里,角色和权限点因为是固化表,后续几乎所有权限需求没有改过一行后端代码,全部靠管理后台配置就完成了。那一刻我才真正理解了什么叫"把工作做在源头"。如果你现在正处在写 CRUD 到架构思维的过渡期,不妨从下一个表开始,认真考虑一下哪些字段值得被固化,哪些规则值得被数据化。这个习惯一旦养成,你看数据库的眼光就会彻底不一样。