窄表的全生命周期管理
窄表建好之后,表单还会持续演进:加字段、删字段、改字段、甚至删除整个表单。本文说明这些操作在窄表模式下后端会自动做什么、不会做什么、以及需要人工介入的部分。
所有窄表的 schema 变更都由后端服务 FormEntityDataStandaloneTableNormalizedService.createOrUpdateTable(formEntity) 统一处理,内部用 Liquibase 动态生成变更集(changeset)。它是幂等的:每次保存表单都会对比"表单期望的列"与"库中实际的列",只做必要的增量变更;无变更时直接返回。
速查表:各操作的自动化程度
| 操作 | 后端是否自动执行 | 说明 |
|---|---|---|
| 建表(首次开启窄表) | ✅ 自动 | 建 id + 系统列 + 每字段业务列,并置 standalone_table_created=1 |
| 加字段 | ✅ 自动加列 | 保存表单时批量 ADD COLUMN,并回填 normalized_storage_field |
| 改字段类型 | ❌ 不改列类型 | 窄表列类型宽松(多为 NVARCHAR/TEXT),一般无需变更;如需严格类型需手动 ALTER |
| 改字段 token | ⚠️ 列名不变 | 靠已存的 normalized_storage_field 保持列名稳定,历史数据不动 |
| 删字段 | ⛔ 不自动删列(设计如此) | 后端只识别并打印待删列,不执行 DROP,需 DBA 手动处理 |
| 删表单 | ⛔ 不自动删表(设计如此) | 物理窄表残留在库中,需 DBA 手动 DROP TABLE |
为什么删除类操作不自动执行:删列 / 删表是不可逆的破坏性操作,一旦误删将造成数据永久丢失。CETA 有意将这类操作交由人工控制(by design),后端只做"只增不减"的安全变更,删除必须由 DBA 评估确认后手动执行。
加字段(自动)
给已开启窄表的表单新增字段后,保存表单即触发 createOrUpdateTable:
- 后端对比期望列与现有列,把缺失的列收集起来,用一次批量
ADD COLUMN加到窄表; - 为新字段回填
form_entity_field.normalized_storage_field(即新列名); - label/value 类字段(
SELECT/ACL/CASCADER/TREE_SELECT)和DATE字段会同时加 两列(xxx+xxx_label)。
无需任何手动操作。 验证:
-- 确认新列已存在,且字段的 normalized_storage_field 已回填
SELECT token, type, normalized_storage_field
FROM form_entity_field
WHERE form_entity_id = <feid> AND normalized_storage_field IS NOT NULL;
注意:
- 新列默认可空,已有历史行在新列上的值为
NULL(符合预期)。 - 列名由字段 token 转换而来(camelCase / 中划线 → snake_case,统一小写)。若列名撞数据库保留字或平台硬编码列名,会自动加前缀
ceta_pre_(见附录)。 LOOKUP字段无存储列,会被跳过。
改字段类型 / 改字段 token
- 改字段类型:后端不会 ALTER 已有列的类型。窄表列类型设计得比较宽松(文本类基本是
NVARCHAR(200)/TEXT,数字/日期也用NVARCHAR),能容纳大多数类型变化,通常无需处理。若业务上确实需要严格的列类型(如改成真正的NUMERIC/TIMESTAMP以便排序、聚合),需 DBA 手动ALTER COLUMN,并自行处理已有数据的类型转换。 - 改字段 token:列名以字段已回填的
normalized_storage_field为准,不随 token 改变而重命名,历史数据不受影响。这是有意的——避免频繁改名导致列漂移和数据错位。
加表(= 首次开启窄表)
"加表"就是把一个表单开启窄表,由后端 createOrUpdateTable 自动建表。完整流程见开启分表(窄表)。这里不再赘述。
删字段(需 DBA 手动删列)
删列会永久删除该列的所有数据,不可恢复。以下 SQL 仅供 DBA 在充分评估后执行,执行前必须完整备份该表。禁止在未备份、未确认的情况下直接运行。
当前行为:当你在表单上删掉一个字段后再保存,后端 createOrUpdateTable 会:
- 计算出"库中存在、但表单已不再需要"的列(
columnsToDelete); - 只打印一条日志提示这些列需要删除,不执行任何 DROP:
find column: [xxx, xxx_label], need to be drop, please confirm and do it manually!
所以删字段后,窄表里会残留这些废弃列(有数据但不再被平台读写)。是否清理、何时清理,由你和 DBA 决定。
手动删列步骤(DBA)
第 1 步 — 找出需要删除的列
对比"字段当前期望的列"与"窄表实际的列",人工确认差集:
-- 1a. 查表单当前所有字段对应的列名(期望保留的业务列)
SELECT token, type, normalized_storage_field
FROM form_entity_field
WHERE form_entity_id = <feid>;
-- 1b. 查窄表实际的列(PostgreSQL)
SELECT column_name
FROM information_schema.columns
WHERE table_name = '<standalone_table_name>'
ORDER BY ordinal_position;
用 1b 的结果减去:系统列(id / form_entity_id / deleted / created_time / updated_time / user_id / updated_user_id / flow_instance_id / thread_id / node_id / object_version)和 1a 中仍存在字段对应的列(含 _label),剩下的就是废弃列。务必逐列人工核对,不要凭猜测。
第 2 步 — 完整备份(强制)
-- 整表备份(数据量大时用 pg_dump 只备份该表,或备份到冷库)
CREATE TABLE bak_<standalone_table_name>_<yyyymmdd> AS
SELECT * FROM <standalone_table_name>;
第 3 步 — 删列(确认无误后执行)
-- ⚠️ 逐列删除,label/value 字段记得两列都删(xxx 和 xxx_label)
ALTER TABLE <standalone_table_name> DROP COLUMN <废弃列名>;
-- ALTER TABLE <standalone_table_name> DROP COLUMN <废弃列名>_label; -- 若是 label/value 字段
第 4 步 — 验证:确认列已删除、平台读写正常、备份表妥善保留一个发布周期后再清理备份。
删表(删表单后需 DBA 手动删表)
DROP TABLE 会永久删除整张表及其所有数据,不可恢复。执行前必须完整备份,并确认没有任何流程/页面/引用仍在使用该表单。由 DBA 手动执行。
当前行为:删除一个开启了窄表的表单(FormEntity)时,后端不会自动删除对应的物理窄表——createOrUpdateTable 里没有任何 drop table 逻辑(设计如此,避免误删)。因此删表单后,独立窄表会残留在数据库中,需要人工清理。
手动删表步骤(DBA)
第 1 步 — 确认表名与归属
删表单前先记下它的窄表名(删表单后 form_entity 记录可能已不在):
SELECT id, token, standalone, standalone_table_name
FROM form_entity
WHERE id = <feid>; -- 或按 pbc_token + token 查
第 2 步 — 完整备份(强制)
CREATE TABLE bak_<standalone_table_name>_<yyyymmdd> AS
SELECT * FROM <standalone_table_name>;
-- 数据量大时优先用 pg_dump 单表备份
第 3 步 — 确认无引用后删表
确认没有其它表单通过 ACL/引用字段指向该表单、没有流程/页面还在读它,再执行:
DROP TABLE <standalone_table_name>;
表名务必与
standalone_table_name完全一致,且不能是平台系统保留表(见附录)。删表前反复核对表名,DROP TABLE无法回滚。
附录:列名与表名规则
这些规则由后端 FormEntityDataStandaloneTableNormalizedService 决定,DBA 手动操作时需了解:
列名生成(toColumnName):
- 字段 token 从 camelCase / 中划线转为 snake_case 并统一小写(
applicantName→applicant_name)。 - 若结果撞数据库 SQL 保留字(如
date、user、select等)或平台硬编码系统列名,自动加前缀ceta_pre_(如ceta_pre_date)。 - 一旦字段的
normalized_storage_field已回填,后续列名以它为准,不再随 token 变化。
label/value 双列:SELECT / ACL / CASCADER / TREE_SELECT / DATE 字段各建两列——xxx(value)和 xxx_label(label)。删列时两列要一起处理。
系统列(不可删,平台读写依赖):id、form_entity_id、deleted、created_time、updated_time、user_id、updated_user_id、flow_instance_id、thread_id、node_id、object_version。
保留表名:窄表名不能使用平台系统表名(如 form_entity、form_entity_data、form_entity_data_acl 等约 130 个)。开启窄表时若表名冲突,后端会直接报错。
变更追踪:窄表的 schema 变更由 Liquibase 记录在独立的 changelog 表 form_entity_data_standalone_normalized_changelog_table(锁表 ..._lock_table),与平台主 changelog 隔离。