面试里有类问题最不起眼也最见真章:”你们的表有什么设计约定?””软删除和唯一索引冲突怎么处理?””增量同步怎么做的?”——这些题不涉及高并发也不涉及分布式,但答不好比架构题答不好更伤:架构方案可以背,地基习惯背不出来,它暴露的是你有没有真的维护过一张被人天天写的表。
本篇是系列的地基篇。前面的库存篇讲了账本怎么运转,本篇往下一层,讲这套能服役十几年的系统,建表规范、软删除、索引范式、字典体系这些”没有技术含量”的地基是怎么打的——以及它们埋过的坑。规范类文章最容易写成流水账,我的写法是每条规范都配一个”为什么”和一个”翻车现场”。
一、一张表的 anatomy:标准字段七件套
这套系统的建表规范是成文的:表名按 biz_模块_业务名 命名,从表用固定后缀;字段全小写下划线;主键统一叫 id、外键叫 <单数>id 且注释里标注关联目标;不用数据库外键约束;InnoDB 加 utf8mb4,每表每字段必须有 COMMENT。
业务主表有标准字段七件套:id(自增)、factoryid(多租户)、delflag(软删除)、maintainer(维护人)、maintaintime(维护时间)、creator、createtime。每一件都有存在理由:factoryid 是登录会话篇讲过的行级租户命脉;delflag 和两个维护字段是审计链;creator/createtime 的实现取巧——插入时直接复用维护人和维护时间的值,一次赋值两处生效。
三个值得在面试里展开的细节:
主键就是 int 自增,不装。 不是 bigint,不是雪花 ID,从表里还有一部分走框架的序列服务发号。诚实地说这是个历史欠账:int 上限 21 亿,十几年的系统新表还在用 int(11),靠的是”单表数据量到不了”这个朴素事实。面试被问”为什么不用分布式 ID”时,标准答案不是辩护,而是指出分布式 ID 解决的是分库分表后的全局唯一,而这套系统分的是库不是表,库内自增够用——选型跟着架构形态走,不跟着潮流走。
主键查询也叠 factoryid。 按主键查详情的 SQL 写的是 where id = #{id} and factoryid = #{factoryid}——主键已经唯一了还要租户条件,图什么?图的是越权防护的双保险:拿到别的工厂的单据 ID 伪造请求,单加 factoryid 条款就直接查空。多租户系统的主键查询不带租户条件,等于把”猜 ID”变成了攻击面。
规范有弹性空间。 字典、分组这类配置型小表没有全套七件套(没有 delflag 和创建字段),1936 个模型文件里执行程度不一。规范的真实生命力不在”百分之百执行”,在核心交易表铁律、配置表放宽的分寸感——全库一刀切的规范,最后会死在执行率上。
二、软删除与唯一索引的和解
软删除是业务系统的标配:删除不执行 DELETE,而是 UPDATE 把 delflag 置 1,顺带刷维护人和维护时间——连删除都要留审计痕迹。全库 mapper 里 delflag 出现近两千处,查询统一带 delflag = 0。
然后经典冲突来了:唯一索引怎么和软删除共存?同一个编码删了再建,唯一索引会撞——已删除的旧行还占着唯一键。这个问题有三个主流解法,这套系统选了最有意思的一种:
解法一(最常见也最脆):唯一索引带上 delflag。 缺陷在于 delflag 只有两个值——同一编码删除两次,两行 delflag=1 的记录照样撞。要真解决得在删除时把唯一键置空或替换成时间戳,索引语义从此变得诡异。
解法二:唯一索引不含业务键,验重全靠代码。 这套系统的选择:唯一索引只覆盖 (factoryid, 条码) 这类天然不可重复的组合,编码类防重交给代码层——验重 SQL 只查活数据(delflag=0),查到重复才报错。
解法三(本系统的精髓):查到已删行就复活。 验重通过后、真正插入前,再查一次同键的已删除行——查到就把那行的 delflag 改回 0、更新内容,走 UPDATE 而不是 INSERT。这一手”复活”在工程上同时解决了三件事:唯一索引不冲突、历史单据对旧记录的引用不断链(外键还在指着它)、编码的历史沿革可追溯(它”回来”了而不是”重来”)。
面试聊到这道题,值得主动点破各方案的适用边界:解法一适合”删除极少发生”的表;解法二简单但放弃了数据库层的最后防线;解法三最好但要求插入路径全部收口(库存篇讲的原语收敛正是它的前提)——如果插入散落在二十个模块,复活逻辑根本维护不住。地基规范和架构决策是咬合的,不是孤立的。
三、maintaintime:一个字段的三重身份
维护时间字段是全库最有故事的字段,它同时扮演三个角色。
第一身份:审计时间戳。 所有 UPDATE 强制刷新它——不是靠开发者记得写,而是几乎每条 update 语句都带 maintaintime = now(),连 on duplicate key update 的 upsert 也不放过。这条纪律的强度可以从反面看出:漏刷维护时间的 SQL 会被当作 bug 而不是风格差异。
第二身份:增量同步的水位线。 报表和跨系统同步靠它做增量:本厂水位 = 当前最大的 maintaintime,下一轮只拉”维护时间 > 上一轮水位”的行;本轮落表时间戳成为下一轮的水位,窗口起点回退五分钟制造重叠——宁可拉重、不可拉漏,重了由下游按主键幂等覆盖(又是幂等),漏了就是永久丢数据。这个”水位回退 + 幂等落表”的组合是增量同步的标准姿势,比”精确不重不漏”的幻想工程化得多。
第三身份:关账口径。 库存篇讲过,倒冲扣料回写时把簿记时间压到关账前一秒——操作的也是维护时间。
一个字段三种语义,好处是不加表结构就让增量、审计、账期三套机制跑起来;代价是语义耦合——任何”修正历史数据”的脚本都要意识到:你改的不只是值,还有水位。改历史行的 maintaintime 会把那行重新推给所有增量消费者,这类修复脚本必须连消费者一起评估。这曾在真实修复事故里炸过(改值没改时间戳、或改了时间戳引发幻影增量),教训沉淀成了”改值必配时间戳策略”的检查项。
四、索引范式:从规范到两次真实提速
索引规范只有一句话好背:高频查询配联合索引,租户字段打头,(factoryid, status, 时间列) 是状态类查询的标准形状。这套系统所有索引几乎都以 factoryid 开头——多租户和索引设计在这里合流。
但规范的真正价值要看提速实例,挑两个讲:
实例一:覆盖索引救了 403 万行的聚合。 库存操作流水表(上一部库存篇的主角)按工厂汇总的 footer 查询要 6.2 秒——查询模式固定是”按工厂 + 时间范围聚合流量类型和数量”。解法是把 (factoryid, createtime) 的普通索引扩成 (factoryid, createtime, flowtype, quantity, 结余) 五列覆盖索引:查询所需的所有列都在索引里,回表归零,6.2 秒变 0.12 秒。配套的工程细节是MySQL 5.7 的 INPLACE 在线重建——403 万行的表加索引不能锁死业务,在线 DDL 的锁表窗口评估是必答题。
实例二:union 派生表的过滤下推。 报表爱写”各业务表 union 成派生表再统一过滤”的 SQL,坑在于外层的时间条件不会自动下推到每个内层子查询——全量数据先 union 再过滤,派生表内部再玩关联子查询,无索引列的关联直接组合出三千万行的笛卡尔积。规范化的写法是时间条件写进每个内层子查询、派生表内禁止无索引列关联,配合 EXPLAIN 里 DEPENDENT SUBQUERY + type:ALL 这个特征组合做扫描识别。
这两个实例的共性:慢 SQL 的修复不是加索引就完事,是把查询形态和索引形状对齐——先有确定的查询模式,才有对的索引;反过来给所有查询配所有索引,写放大和优化器误选会一起找上门。
五、字典三表:可维护枚举的完整形态
业务系统一半以上的下拉框是”分类字典”——物料类型、检验类型、维护类别。硬编码枚举改个显示名要发版,这套系统给可维护字典设计了三表结构:
- 字典表:
编码、父分组编码、序号、i18n 外键——存的是”有哪些码、怎么排序”; - 词条表:
KEY、类型(dict)——存翻译的锚点; - 语言版本表:
词条外键、语言、译文——简繁英越四种语言各一行。
三表的关键设计是翻译不放在字典表里,而是字典表通过外键指向词条锚点——同一套词条基础设施同时服务页面文案和字典显示名,新增一种语言不需要动任何业务表。查询时的 union 也值得看:系统字典 union 上工厂级自定义字典(工厂自己补的字典项带租户字段),用一列”是否系统级”区分来源,验重时两表一起查——平台管规范,工厂管扩展,多租户思想又一次渗透到了最细的数据结构里。
字典和代码枚举的双轨制分界也很清晰:流程状态是枚举(orderstate 那十八种,改它等于改流程,必须发版);业务分类是字典(物料类型今天加一种,运营页面自己配)。两者的显示名共享同一个翻译命名空间——跨模块声明同一枚举时显示名自动一致。
最后是一个工程化细节,DBA 同学会很共鸣:新增字典的 SQL 模板。字典要过三张表,主键有的库自增有的不是,脚本怎么做到跨环境可重跑?答案是业务键幂等——每张表用业务锚点(分组+编码、KEY 唯一索引、词条+语言)先查再插,主键用运行时 MAX+1 现算而不是写死(写死 ID 跨环境必撞),两表 ID 空间还要互相避让。**”这条 SQL 可以在任何环境重跑任意次”是数据变更脚本的质量标准**——多少线上事故源于一个”只在 dev 试过一次”的变更脚本。
六、时区与精度:两个通用大坑的实录
时区坑:某个应用的写入时间和数据库 NOW() 差了 12 小时。排查结果是连接串参数漂移——主链路连接串不带时区参数,新加的 WMS 数据源带了;两个数据源对”应用时区 vs 数据库时区”的解释不一致,写入时间就各自为政。这类坑的阴险在于平时全对,某个新数据源上线才炸,且 12 小时不是常见的 8 小时偏移(UTC 对半),按常规思路排查会先排除时区。规范化的解法:连接串时区参数进统一模板,全库数据源不允许各自为政。
精度坑:库存数量用 decimal(20,10),快照类用 decimal(19,6)——精度不统一本身就是坑的温床,规范里为此立了自查项:”存储字段精度截断过的值,不能拿去验证全精度的源数据”。翻译成人话:库里的数量已经被截断到 4 位小数了,就别拿它去和精确到 10 位的上游数据对账,永远差那么一点点还查不出原因。数值对账前先对精度口径,这个意识比任何具体 SQL 都值钱。
七、归档框架:大表治理的常态化
流水和快照类大表的归档在这套系统里已经框架化:一张配置注册表声明”哪张表、按年还是按月归档、按哪个时间字段切”,运行时自动 create table like 出归档子表、登记清单;查询服务按时间范围自动路由到对应归档表,查不到子表回退主表。业务代码对归档无感知。
落地踩过的坑也很有代表性:按月归档的子表名带月后缀,格式形如”2026-08”——表名里的连字符在 SQL 里会被解析成减法,所有引用必须反引号包住。这类坑的价值在于提醒:自动化框架的边角(命名、转义)往往比框架本身更容易出事。配合库存篇讲过的组级短事务搬运纪律,大表治理在这套系统里完成了从”救火”到”机制”的转变——早年的 28GB 单表在线瘦身是救火,归档框架是让下一次救火不必发生。
八、面试视角:六个问题拆到底
Q1:自增主键和分布式 ID 怎么选?
答:看分片形态。分库不分表、库内单写,自增够用且最省心;分表或多写才需要全局发号——雪花或号段模式。这套系统主键 int 自增,从表部分走序列服务,历史欠账是 int 容量和新表迁移成本,我的立场是承认欠账、指出它和”单库单写”的架构形态是自洽的,而不是盲目追新。
Q2:软删除和唯一索引冲突怎么解?
答:三个方案——唯一键含 delflag(脆,两次删除就撞)、唯一键不含业务键靠代码验重(简单、丢数据库防线)、删除行复活(最好、要求插入路径收口)。我们的选择是第三种加第二种混合:天然不可重复的组合(如条码)保留唯一索引,编码类防重走”验重只查活数据 + 已删行复活”,历史引用不断链是它独有的收益。
Q3:跨系统增量同步怎么做?
答:维护时间水位线:水位取 max(maintaintime),窗口起点回退若干分钟制造重叠,重叠部分靠下游按主键幂等覆盖消化。要点是放弃”精确不重不漏”的幻想,把不漏交给重叠窗口、把不重交给幂等,两者都比精确调度便宜。
Q4:字典表、代码枚举、配置表怎么分界?
答:看变更主体。流程状态是代码语义的一部分,用枚举发版管理;业务分类由运营维护,用字典三表(字典 + 词条 + 语言版本)管理,翻译外置共享一套词条基础设施;跨系统映射类才进配置表。多租户场景再加一层:平台字典管规范,工厂级扩展字典带租户字段 union 进来。
Q5:大表怎么治理?
答:查询侧覆盖索引(配合在线 DDL 的窗口评估)、写入侧短事务搬运归档、机制上框架化——配置注册表声明归档策略,运行时自动建子表、查询自动路由,业务无感知。前提是查询模式先收敛,否则索引和归档都在给 chaos 付费。
Q6:为什么不用数据库外键?
答:三个原因:性能(每写一次都要做约束检查,高并发写入路径不划算)、运维(分库、归档、批量修数时外键是障碍)、以及多租户与复活机制的存在让”引用完整性”本来就要在应用层管理(已删行复活、跨厂数据引用)。用注释标注关联目标替代外键,把约束检查的成本花在业务真正需要的地方。
小结
- 标准字段是系统的骨架习惯:七件套各有职责,规范的生命力在”交易表铁律、配置表放宽”的分寸;
- **软删除与唯一索引的和解靠”复活”**:验重只查活数据、已删同键行走 UPDATE 回来,历史引用不断链;
- maintaintime 一字段三用:审计、增量水位、账期口径——用它的代价是任何修复脚本都要评估水位影响;
- 索引跟着查询形态走:
(factoryid,status,时间)是形状模板,覆盖索引和过滤下推是两次真实提速的方法论; - 字典三表的关键是翻译外置:一套词条基础设施服务所有显示名,平台字典 + 工厂扩展 union 是多租户的又一次渗透;
- 变更脚本的质量标准是跨环境可重跑:业务键幂等 + 运行时算 ID,写死 ID 的脚本就是定时炸弹;
- 时区和精度是通用大坑:连接参数进统一模板,对账先对精度口径。
下一篇按规划收尾数据线:导出与归档的集成篇——已有导出改造长文的机制要点浓缩 + 下载中心兜底 + 归档机制的拼图整合;或者按你的优先级切回主线(MQ 事件总线 / 定时任务 / IoT)。默认按预告走,想调头直接说。