DMTX 联系人组合主键能力 v3:dmd-cloud 参考与 PostgreSQL 适配方案
对比邮件产品线 dmd-cloud 的 unq_combi、UNIQUE INDEX unq 与 MySQL upsert 实现,说明 DMTX 在 PostgreSQL/DataClean 架构下可借鉴的边界:局部幂等可借鉴,不能直接替换联系人主表 OR 匹配 + merge 语义。
版本:v3 参考实现与 PostgreSQL 适配评审版
日期:2026-06-05
需求来源:DMTX 联系人组合主键方案补充评审;参考邮件产品线 [dmd-cloud] 的 unq_combi / UNIQUE INDEX unq / upsert 实现
适用系统:DMTX 联系人、ETL/DataClean、联系人导入、PostgreSQL contact 表、局部幂等写入表、后续组合身份规则设计
与前两版关系:v1 记录原始组合主键诉求;v2 梳理 DMTX upload → DataClean → contact 写入链路和当前多主键 OR 语义;本 v3 专门回答“dmd-cloud 的组合唯一索引方案能否被 DMTX 借鉴,以及在 PostgreSQL 下应该借鉴到什么边界”。
1. 一句话结论
DMTX 应该借鉴 dmd-cloud 的“业务唯一键 + 数据库唯一约束 + upsert 幂等写入”技术思想,但不应直接照搬它作为 DMTX contact 主表的身份解析模型。
原因不是 PostgreSQL 做不到,而是两套系统的联系人身份语义不同:
| 维度 | dmd-cloud 邮件产品线 | DMTX 当前 DataClean |
|---|---|---|
| 真实主键 | id | id / contact_id / customer_id 组成内部身份体系 |
| 业务唯一 | unq_combi 对应索引 unq(A, B, ...) | 多个 if_pk=true 字段是多个可替代识别信号 |
| 查重方式 | 数据库 UNIQUE INDEX 冲突 | PostgreSQLSource.search 对多个主键做 OR 查询 |
| 写入方式 | MySQL INSERT ... ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id) | 先查候选,再由 DataClean.handleUsers 决定 add / update / merge |
| 多命中处理 | 命中唯一键即已有联系人 | 多候选会进入 SourceCommon.merge,带标签、事件、删除等副作用 |
| 可借鉴程度 | 可借鉴“数据库裁决强唯一键”的思想 | 不能直接替代现有 DataClean 身份解析与 merge 编排 |
推荐方向:
保留 DMTX 默认 OR_MERGE 语义
+
新增显式 SINGLE_UNIQUE / GROUPED_COMPOSITE 策略
+
在明确策略下使用 PostgreSQL partial/expression/generated-column unique index
+
局部场景使用 INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING
+
DataClean 仍负责 merge、副作用、冲突治理和下游一致性2. v3 为什么单独成文
v2 已经明确 DMTX 当前现状:
- DMTX 已支持多个
if_pk=true主键字段。 - Email/Mobile 可以取消主键,自定义字段可以设为主键。
- 当前 DataClean 多主键运行时语义是 OR / 任一主键命中。
- 客户要的酒店场景是 AND / 一组字段同时命中。
这次补充 dmd-cloud 后,需要额外回答一个工程问题:
既然邮件产品线已经用 unq_combi + UNIQUE INDEX + upsert 实现了业务组合唯一,DMTX 是否也可以这么做?
答案是:可以借鉴局部技术,不建议直接迁移身份模型。
本方案不是替代 v2,而是 v2 的 companion:
| 文档 | 定位 |
|---|---|
v1:2026-06-05-dmtx-composite-contact-key/solution.html | 原始组合主键业务方案,说明酒店集团诉求。 |
v2:2026-06-05-dmtx-composite-contact-key-v2/solution.html | 深入开发方案,梳理 DMTX 现有多主键 OR 语义、DataClean 链路和改造顺序。 |
| v3:本文档 | 对照 dmd-cloud,明确 PostgreSQL 唯一约束 / ON CONFLICT 在 DMTX 中可借鉴的边界。 |
3. dmd-cloud 已确认实现事实
3.1 unq_combi 来自索引 unq
在 dmd-cloud 中,unq_combi 不是独立的复杂规则对象,而是“字段是否在唯一索引 unq 中”的展示/配置结果。
关键事实:
| 文件 | 作用 |
|---|---|
[dmd-cloud]/src/WebPower/Dmdelivery/Persistence/Legacy/DBTableInfoRepository.php | 读取表索引,把名为 unq 的索引字段标记为 unq_combi=true。 |
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Contacts/Table/ContactFieldStorer.php | 从前端提交的 unq_combi[] 构造 $newFields,再 add/drop 索引 unq。 |
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Contacts/Table/TableChanges.php | 把 addIndex(..., true, ...) 渲染成 ADD UNIQUE KEY。 |
典型逻辑:
$postedUniqueCombi = isset($post['unq_combi']) ? (array) $post['unq_combi'] : [];
foreach ($tableInfo as $name => $column) {
if (in_array($name, $postedUniqueCombi, true)) {
$newFields[$name] = $name;
}
}
$tableChanges->dropIndex('unq')
->addIndex('unq', $newFields, true, $fieldExtras);最终 SQL 类似:
ALTER TABLE `recipient_xxx`
DROP INDEX `unq`,
ADD UNIQUE INDEX `unq` (`A`, `B`);3.2 默认 recipient 表也以 unq 作为唯一索引名
在 dmd-cloud 的 recipient 表创建逻辑中,默认 email 唯一索引也叫 unq:
UNIQUE KEY `unq` (email(60))这说明 unq 是联系人业务唯一边界的固定索引名,unq_combi 是围绕该索引字段列表的配置能力。
3.3 写联系人时依赖 MySQL duplicate key
核心写入在:
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Interactor/Recipient/LegacyRecipientStorer.php
其 SQL 结构是:
INSERT INTO `recipient_table` (...)
VALUES (...)
ON DUPLICATE KEY UPDATE
id = LAST_INSERT_ID(id),
...id=LAST_INSERT_ID(id) 的作用:
| 场景 | 结果 |
|---|---|
| 没有冲突 | 插入新联系人,lastInsertId 是新 ID。 |
| 命中唯一索引 | 进入 duplicate 分支,LAST_INSERT_ID(id) 把已有联系人 ID 放进 lastInsertId。 |
因此调用方可以用同一套返回逻辑拿到联系人 ID。
3.4 duplicate 不是只针对 unq
需要注意:MySQL ON DUPLICATE KEY UPDATE 不是指定某个索引触发,它会被任何 PRIMARY / UNIQUE 冲突触发。
也就是说,如果表上还有其他唯一索引,例如单字段唯一索引,那么 duplicate 分支不一定是 unq(A, B) 触发。
这点在 PostgreSQL 下不同,因为 PG 通常需要明确 conflict target:
ON CONFLICT (a, b) DO UPDATE或:
ON CONFLICT ON CONSTRAINT uq_name DO UPDATE3.5 dmd-cloud 的前缀索引是 MySQL 实现细节
dmd-cloud 对文本字段使用前缀长度:
| 字段类型 | 前缀长度 |
|---|---|
| 60 | |
| 其他 varchar/text | 20 |
这是 MySQL / MyISAM key length 约束下的实现细节,不应照搬到 DMTX PostgreSQL。
在 PostgreSQL 中,更推荐使用:
- 规范化列
- 生成列
- 表达式索引
- partial unique index
4. DMTX 当前 PostgreSQL / DataClean 真实语义
4.1 当前不是数据库唯一键驱动
DMTX 当前联系人识别主链路是:
DataClean.searchUserByPks
-> AttributesUtils.userPks / setPkValue
-> PostgreSQLSource.search
-> DataClean.handleUsers
-> SourceCommon.merge 或新增/更新
-> CustomerService.saveContact / updateContact关键代码事实:
| 文件/方法 | 当前语义 |
|---|---|
AttributesUtils.userPks | 返回所有 ifShow && ifPk 的联系人字段。 |
DataClean.searchUserByPks | 只要求导入行至少有一个 PK 字段有值。 |
PostgreSQLSource.search | 多个 PK 条件之间用 or 拼接。 |
DataClean.handleUsers | 0 命中新增;1 命中更新;多命中 merge。 |
SourceCommon.merge | 保留第一条候选身份,合并字段、标签、user_ids_str,收集 loser uids/pids。 |
当前查询更接近:
select *
from contact
where email = ?
or mobile = ?
or card_no = ?
order by create_date asc
limit 1000;而不是:
select *
from contact
where email = ?
and card_no = ?;4.2 merge 是业务编排,不只是 DB update
DMTX 的 merge 不是单纯更新一行,它还包含:
| 副作用 | 说明 |
|---|---|
| survivor 选择 | PostgreSQLSource.search 按 create_date asc 返回,通常最早创建的联系人保留。 |
| 字段合并 | SourceCommon.merge 合并非空字段。 |
| 标签合并 | label_ids union,并计算新增标签。 |
| 历史身份 | user_ids_str 合并历史 customer_id。 |
| 删除 loser | DeleteStorageDTO / DataStorage / batchDelete 清理被合并联系人。 |
| 事件归并 | merge event / eventMergePush 等逻辑。 |
| 渠道定制 | WeCom、WeChat、Huawei 等定制字段和同步逻辑。 |
| 导入统计 | SFTP/import 成功失败计数。 |
因此,不能把:
DataClean 搜索 + merge + side effects直接替换成:
INSERT ... ON CONFLICT DO UPDATE数据库 upsert 只能完成“写哪一行”,无法自动完成 DMTX 当前 merge 的业务副作用。
4.3 DMTX 已经局部使用 PostgreSQL upsert
DMTX 并不是不能用 PG upsert,仓库里已有类似模式:
| 文件 | 用法 |
|---|---|
UpgradeV20240328Service.java | contact_event ... ON CONFLICT (event_uuid) DO UPDATE,事件幂等更新。 |
DmarTechHighSwarmService.java | ON CONFLICT (customer_id) DO UPDATE,current-state 类写入。 |
AbstractBufferFreeImportService.java | 标签/状态聚合类 upsert。 |
ContactSubscriptionService.java | 对订阅状态使用 partial conflict target,例如 where customer_id is not null 与 where customer_id is null and email is not null。 |
这说明:DMTX 可以使用 PG upsert,但更适合局部幂等表、状态表、事件表,而不是直接替代联系人身份解析主链路。
5. 核心语义差距
5.1 dmd-cloud 是唯一边界驱动
在 dmd-cloud 中,组合字段的含义是:
A + B + ... 命中 UNIQUE INDEX unq
=> 这是同一个 recipient
=> 通过 duplicate key 拿到已有 id
=> 按 overwrite 策略更新5.2 DMTX 是候选集合 + merge 编排
在 DMTX 中,多个 PK 字段的含义是:
A 命中联系人 1 或 B 命中联系人 2
=> 都是候选
=> 多候选进入 merge
=> 触发标签、历史 ID、删除、事件等副作用5.3 直接套用会改变业务行为
如果 DMTX 直接引入:
unique(email, card_no)那么行为会变成:
| 场景 | 当前 DMTX | 直接组合唯一后 |
|---|---|---|
| email 相同、cardNo 不同 | email 命中同一联系人,可能更新/合并 | 不冲突,可能新增多条 |
| email 命中 A、cardNo 命中 B | A/B 多候选后 merge | 不一定冲突;若冲突也只能更新一个 conflict target |
| 只有 email 有值 | 可按 email 查找 | 不满足组合唯一,可能不能匹配 |
| 多个候选 | DataClean 统一 merge | DB upsert 不知道如何合并多个候选 |
这不是“实现方式变化”,而是“身份语义变化”。
6. PostgreSQL 适配原则
6.1 不照搬 MySQL LAST_INSERT_ID
PostgreSQL 等价思路是:
INSERT INTO contact_identity_map (...)
VALUES (...)
ON CONFLICT (company_id, identity_key)
DO UPDATE SET
update_date = now()
RETURNING contact_id, created_at, update_date;如果业务需要区分“新建还是已有”,不能依赖 MySQL affected_rows 规则,需要显式设计,例如:
RETURNING xmax = 0 AS inserted,但这依赖 PG MVCC 细节,不建议作为长期产品契约。- 在应用层先尝试 insert,捕获冲突后 update/select。
- 使用额外字段记录
first_seen_at/last_seen_at,由业务判断是否首次。 - 用 CTE 显式返回插入/更新状态。
推荐在方案层定义清楚:RETURNING 只能保证返回最终行,不天然等价于 dmd-cloud 的 isDuplicate。
6.2 PostgreSQL UNIQUE 对 NULL 的语义不同
PG 普通唯一索引允许多条 NULL:
create unique index uq_contact_email_card
on contact (email_norm, card_no_norm);如果 email_norm 或 card_no_norm 为 NULL,多条记录可能不会冲突。
因此必须明确:
| 值类型 | 建议处理 |
|---|---|
| NULL | 不参与业务身份唯一。 |
| 空字符串 | 规范化为 NULL。 |
| 纯空白 | btrim 后转 NULL。 |
| Email 大小写 | lower 规范化。 |
| 手机号 | 统一国家码、去空格/横线。 |
推荐使用 partial unique index:
create unique index concurrently uq_contact_card_email_norm
on contact (card_no_norm, email_norm)
where card_no_norm is not null
and email_norm is not null;6.3 表达式索引 vs 生成列
PG 可以写表达式唯一索引:
create unique index concurrently uq_contact_email_norm
on contact (lower(nullif(btrim(email), '')))
where nullif(btrim(email), '') is not null;但如果后续要稳定使用 ON CONFLICT、易排查、易迁移,推荐使用生成列或显式规范化列:
alter table contact add column email_norm varchar;
alter table contact add column card_no_norm varchar;
create unique index concurrently uq_contact_card_email_norm
on contact (card_no_norm, email_norm)
where card_no_norm is not null
and email_norm is not null;原因:
- 查询语句更简单。
- 冲突目标更清晰。
- 可做 backfill 和冲突审计。
- 避免表达式与应用规范化逻辑不一致。
6.4 partial index 与 ON CONFLICT 的约束
如果使用 partial unique index,ON CONFLICT 也要匹配对应 conflict target 和 predicate,或者用约束名/索引推断方式设计清楚。
示意:
insert into contact_subscription (...)
values (...)
on conflict (company_id, customer_id, subscription_type_id)
where customer_id is not null
do update set ...;DMTX 已在 ContactSubscriptionService 中使用过类似模式:
on conflict (company_id, customer_id, subscription_type_id)
where customer_id is not null以及:
on conflict (company_id, email, subscription_type_id)
where customer_id is null and email is not null这比直接在 contact 主表上盲目加唯一索引更符合 DMTX 现有 PG 风格。
7. 哪些场景适合借鉴
| 场景 | 是否适合借鉴 dmd-cloud 思路 | 原因 |
|---|---|---|
| 事件幂等表 | 推荐 | event_uuid 这类键天然是唯一幂等边界,PG ON CONFLICT 非常适合。 |
| mapping 表 | 推荐 | 例如外部 ID → customer_id/contact_id 的映射,冲突目标明确。 |
| current-state 表 | 推荐 | 例如按 customer_id 维护当前状态,仓库已有类似 ON CONFLICT (customer_id) 用法。 |
| 标签/订阅状态聚合 | 推荐 | DMTX 已在局部使用 partial conflict target,可继续沿用。 |
| legacy 联系人导入局部重构 | 有条件推荐 | 若某个来源有单一权威键,可用 PG upsert;但必须补齐 DataClean 副作用或明确不走 merge。 |
| contact 主表默认身份解析 | 不推荐直接借鉴 | 当前是 OR 匹配 + merge 编排,直接换 upsert 会改变语义。 |
酒店 cardNo + email 组合身份 | 有条件推荐 | 只有在新增显式 GROUPED_COMPOSITE 模式、完成数据审计和冲突策略后,才适合用组合唯一索引兜底。 |
8. 不建议直接改 contact 主表的原因
8.1 会弱化或改变当前去重
当前 DMTX 的 Email/Mobile 多主键是 OR 语义。直接改成组合唯一,会让“只命中 email”或“只命中 mobile”的行不再等价于同一联系人。
这可能使联系人数量上升,影响:
- 分群人数
- 旅程触达
- 评分结果
- 退订状态
- SCRM/企微联系人归属
8.2 会绕过 merge 副作用
SourceCommon.merge 和 DataClean merge 相关逻辑当前承担业务一致性。数据库 upsert 无法自动复刻:
- 标签 union
- WeCom 字段合并
user_ids_str历史身份维护- loser 联系人删除
- merge event
- import 计数
- Huawei/Wechat 定制逻辑
因此即使未来用 PG unique 兜底,也应由 DataClean/Resolver 决定身份,再由 DB 兜底并发一致性,而不是 DB 单独决定完整业务流程。
8.3 旧导入器是独立分叉
ContacatImportDisposeService 当前有自己的 Email/Mobile 查询、合并、删除逻辑,未走 DataClean 通用 ifPk 机制。
如果 contact 表上突然新增唯一约束,旧导入器可能出现:
- 建索引失败:历史数据已重复。
- insert/update 冲突异常:旧逻辑未预期。
- 双重合并:应用层先 merge,DB 又触发 conflict。
- 统计错误:导入成功/失败与真实写入不一致。
所以 legacy 导入必须先纳入身份策略框架,不能让它先碰撞新约束。
9. 推荐 DMTX 身份策略分层
建议不要把 ifPk 直接映射为数据库唯一约束,而是显式建模策略。
| 策略 | 语义 | 数据库能力 | 默认性 |
|---|---|---|---|
OR_MERGE | 当前 DMTX 语义,任一主键命中即可,多个候选 merge。 | 可用普通索引加速,不建议唯一约束。 | 存量默认。 |
SINGLE_UNIQUE | 一个强身份键,如 external_id、CRM ID。 | 可用规范化唯一索引 + ON CONFLICT。 | 显式开启。 |
GROUPED_COMPOSITE | 一组字段 AND 命中,如 cardNo + email。 | 可用组合唯一索引兜底。 | 显式开启。 |
MAPPING_TABLE | 外部身份映射到 DMTX customer_id/contact_id。 | 强烈推荐 unique + upsert。 | 新能力推荐。 |
9.1 OR_MERGE 保持现状
继续使用:
AttributesUtils.userPks
-> PostgreSQLSource.search OR
-> DataClean.handleUsers
-> SourceCommon.merge只补充:
- 查询异常不能当作无命中新增。
- 写入失败不能记成功。
- 定时合并要与新策略隔离。
9.2 SINGLE_UNIQUE 使用 PG upsert
适合:
- 外部会员 ID
- CRM ID
- DMD Contact Key
- unionid/openid 的规范化派生键
示例:
create unique index concurrently uq_contact_external_id_norm
on contact (external_id_norm)
where external_id_norm is not null;写入:
insert into contact (contact_id, customer_id, external_id_norm, ...)
values (?, ?, ?, ...)
on conflict (external_id_norm)
where external_id_norm is not null
do update set
update_date = now(),
update_by = excluded.update_by
returning contact_id, customer_id;9.3 GROUPED_COMPOSITE 使用组合唯一兜底
适合酒店:
cardNo + email建议先落在 identity mapping 表,而不是直接在 contact 主表上改:
create table contact_identity_key (
id bigserial primary key,
company_id bigint not null,
rule_code varchar(64) not null,
identity_hash varchar(128) not null,
contact_id varchar not null,
customer_id varchar not null,
normalized_values jsonb not null,
create_date timestamp not null default current_timestamp,
update_date timestamp not null default current_timestamp
);
create unique index concurrently uq_contact_identity_key
on contact_identity_key (company_id, rule_code, identity_hash);写入:
insert into contact_identity_key (
company_id,
rule_code,
identity_hash,
contact_id,
customer_id,
normalized_values
)
values (?, ?, ?, ?, ?, ?::jsonb)
on conflict (company_id, rule_code, identity_hash)
do update set
update_date = now()
returning contact_id, customer_id;这样可以借鉴 dmd-cloud 的“唯一边界 + upsert”思想,同时避免直接改 contact 主表导致历史数据和 merge 副作用失控。
10. 推荐落地路径
P0:事实校准与数据审计
| 动作 | 说明 |
|---|---|
| 固化当前语义 | 明确 DMTX 当前默认是 OR_MERGE。 |
| 审计重复数据 | email、mobile、email+cardNo、external_id 等维度统计重复。 |
| 审计空值与格式 | NULL、空字符串、大小写、空格、手机号格式。 |
| 审计来源 | 哪些导入来源有强单键,哪些依赖 DataClean merge。 |
P1:局部幂等场景先试点
优先选择:
- 事件幂等表
- current-state 表
- identity mapping 表
- 订阅/标签聚合类表
这些场景 conflict target 明确,比较适合 PG ON CONFLICT。
P2:引入身份策略配置
新增策略枚举:
OR_MERGE
SINGLE_UNIQUE
GROUPED_COMPOSITE
MAPPING_TABLE存量默认 OR_MERGE,新策略必须显式启用。
P3:酒店组合键 MVP
建议路径:
cardNo + email
-> 规范化
-> identity_hash
-> contact_identity_key unique(company_id, rule_code, identity_hash)
-> 返回 contact_id/customer_id
-> DataClean 决定更新/新增/冲突不要第一步就直接在 contact 主表建:
unique(card_no, email)P4:是否进入 contact 主表改革
只有满足以下条件才考虑:
- 已完成历史重复清理。
- 已明确 survivor 规则。
- 已明确冲突回写策略。
- 已迁移 legacy 导入器。
- 已证明 DataClean 副作用可以完整保留。
- 已跑过灰度与回滚演练。
11. 测试与回滚
11.1 测试矩阵
| 类型 | 用例 |
|---|---|
| PG 唯一约束 | 同 identity_hash 并发写入只生成一条映射。 |
ON CONFLICT RETURNING | 插入和冲突更新都返回正确 contact_id/customer_id。 |
| NULL/空值 | 空 email/cardNo 不参与唯一识别。 |
| 规范化 | A@EXAMPLE.COM 与 a@example.com 命中同一规范化值。 |
| DataClean 兼容 | OR_MERGE 租户行为完全不变。 |
| 组合模式 | cardNo+email 完整一致更新,同 email 不同 cardNo 不合并。 |
| 冲突处理 | identity mapping 指向不同 contact 时进入冲突,不静默覆盖。 |
| legacy 导入 | 未迁移前明确阻断或标注不支持;迁移后结果与 ETL 一致。 |
| 下游回归 | 分群、旅程、评分、退订、SCRM 仍通过 customer_id/contact_id/user_ids_str 正常运行。 |
11.2 回滚策略
| 对象 | 回滚方式 |
|---|---|
| 策略配置 | 公司从 SINGLE_UNIQUE / GROUPED_COMPOSITE 切回 OR_MERGE。 |
| 新映射表 | 保留不删除,作为审计;运行时停止读取。 |
| 新索引 | 不立即 drop,确认无依赖后再清理。 |
| 写入逻辑 | feature flag 切回旧 DataClean 路径。 |
| 冲突记录 | 保留,作为数据治理输入。 |
12. 工作量评估
| 范围 | 内容 | 预估 |
|---|---|---|
| 方案与数据审计 | 固化 dmd-cloud 参考边界、DMTX 当前语义、重复数据审计 SQL。 | 3-5 人天 |
| 局部 PG upsert 试点 | 选一个 mapping/current-state 表,引入 unique + ON CONFLICT RETURNING。 | 5-8 人天 |
| 身份策略配置骨架 | OR_MERGE / SINGLE_UNIQUE / GROUPED_COMPOSITE 配置与读取。 | 8-12 人天 |
| identity mapping 表 MVP | 规范化、hash、unique、upsert、冲突记录。 | 8-12 人天 |
| DataClean 接入 | 在组合模式下调用 mapping/Resolver,不破坏 OR_MERGE。 | 10-16 人天 |
| legacy 导入处理 | 阻断、标识不支持,或迁移到新 Resolver。 | 6-15 人天 |
| 测试与灰度 | PG 并发、DataClean 回归、下游回归、发布回滚。 | 8-12 人天 |
总估算:
| 版本 | 预估 |
|---|---|
| 只做局部 PG upsert 试点 | 8-13 人天 |
| 酒店组合键 mapping MVP | 30-45 人天 |
| 完整接入 DataClean + legacy 导入 + 下游回归 | 55-80 人天 |
13. 最终建议
- dmd-cloud 值得借鉴,但借鉴的是思想,不是直接搬 SQL。
- MySQL
LAST_INSERT_ID换成 PGRETURNING。 - MySQL 前缀索引换成 PG 规范化列 / partial unique index。
- DMTX 不能直接把 contact 主表身份解析改成 unique + upsert。
- 当前 DataClean 是 OR 匹配 + merge 副作用链。
- 直接替换会改变联系人数量、合并规则和下游一致性。
- 推荐先从局部幂等能力借鉴。
- mapping 表、事件表、current-state 表、标签/状态聚合表。
- 这些场景 conflict target 清晰,更适合 PG
ON CONFLICT。
- 若要支持酒店组合键,建议先做 identity mapping 表。
- 用
company_id + rule_code + identity_hash唯一。 - 返回
contact_id/customer_id给 DataClean。 - DataClean 仍负责新增、更新、冲突和副作用。
- contact 主表组合唯一只能作为后续高级阶段。
- 必须先清理历史重复。
- 必须迁移 legacy 导入。
- 必须证明 DataClean merge 副作用不会丢失。
一句话定稿:
DMTX 应选择性借鉴 dmd-cloud 的“业务唯一边界 + 数据库幂等写入”模式,在 PostgreSQL 下用 partial/expression/generated-column unique index 与 ON CONFLICT ... RETURNING 落地;但不能直接用它替换 DMTX 当前 contact 主表的 OR 匹配 + DataClean merge 语义。酒店组合键建议先通过 identity mapping 表和显式 GROUPED_COMPOSITE 策略实现,而不是直接在 contact 表上照搬 UNIQUE(email, cardNo)。
14. 源码证据路径
dmd-cloud
| 文件 | 证据 |
|---|---|
[dmd-cloud]/src/WebPower/Dmdelivery/Persistence/Legacy/DBTableInfoRepository.php | 从索引 unq 反推出字段 unq_combi=true。 |
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Campaign/Create/TableCreatorService.php | recipient 表默认 UNIQUE KEY unq(email(60))。 |
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Contacts/Table/ContactFieldStorer.php | 从 unq_combi[] 构造组合字段,drop/add unq 唯一索引。 |
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Contacts/Table/TableChanges.php | addIndex(..., true, ...) 渲染为 ADD UNIQUE KEY。 |
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Interactor/Recipient/LegacyRecipientStorer.php | INSERT ... ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)。 |
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Tests/Interactor/Recipient/LegacyRecipientStorerTest.php | 测试断言生成 SQL 包含 ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)。 |
DMTX
| 文件 | 证据 |
|---|---|
[dmtx-biz-projects]/etl/src/main/java/com/qdum/dmtx/biz/etl/service/AttributesUtils.java | userPks 返回所有 ifShow && ifPk 字段。 |
[dmtx-biz-projects]/etl/src/main/java/com/qdum/dmtx/biz/etl/service/source/PostgreSQLSource.java | search 对多个主键使用 OR 查询。 |
[dmtx-biz-projects]/etl/src/main/java/com/qdum/dmtx/biz/etl/service/kafka/DataClean.java | handleUsers 负责 add/update/merge 分流。 |
[dmtx-biz-projects]/etl/src/main/java/com/qdum/dmtx/biz/etl/service/source/SourceCommon.java | merge 负责 survivor、字段、标签、user_ids_str 等合并。 |
[dmtx-biz-projects]/contact/src/main/java/com/qdum/dmtx/biz/contact/service/ContacatImportDisposeService.java | legacy 导入有独立 Email/Mobile 合并逻辑。 |
[dmtx-biz-projects]/contact/src/main/java/com/qdum/dmtx/biz/contact/service/ContactSubscriptionService.java | 局部使用 partial ON CONFLICT,说明 PG upsert 在 DMTX 中适合明确状态表。 |
[dmtx-biz-projects]/contact/src/main/java/com/qdum/dmtx/biz/contact/service/dmartechservice/DmarTechHighSwarmService.java | 存在 ON CONFLICT (customer_id) DO UPDATE 的 current-state 用法。 |
[dmtx-biz-projects]/etl/src/main/java/com/qdum/dmtx/biz/etl/service/metadata/upgrade/UpgradeV20240328Service.java | 存在 ON CONFLICT (event_uuid) DO UPDATE 的事件幂等用法。 |
15. 质量自检
| 检查项 | 得分 | 说明 |
|---|---|---|
| 回答用户核心问题 | 2/2 | 明确 DMTX 是否借鉴 dmd-cloud。 |
| dmd-cloud 事实核对 | 2/2 | 覆盖 unq_combi、unq、ON DUPLICATE KEY、LAST_INSERT_ID。 |
| PostgreSQL 适配 | 2/2 | 覆盖 ON CONFLICT、RETURNING、NULL、partial/expression/generator 取舍。 |
| DMTX 当前语义 | 2/2 | 明确 OR 匹配 + DataClean merge。 |
| 不照搬边界 | 2/2 | 明确不替换 contact 主链路。 |
| 可借鉴场景 | 2/2 | 给出 mapping/current-state/event/订阅等局部场景。 |
| 数据模型建议 | 2/2 | 给出 identity mapping 表和唯一索引。 |
| 风险和回滚 | 2/2 | 覆盖 legacy、DataClean 副作用、历史重复、灰度。 |
| 工作量评估 | 2/2 | 按试点、MVP、完整版本拆分。 |
| 源码证据 | 2/2 | 附录列出 dmd-cloud 与 DMTX 文件路径。 |
总分:20 / 20。可进入研发/架构评审。