如何设计跨 microservices 的用户数据清理业务规则?
结论
这条规则适合采用“事件驱动的本地投影 + Process Manager/Saga”组合。不要让 User service 每天同步查询所有服务,也不能只靠 User 删除相关的两个事件完成整个流程。
整个流程可以分为两个阶段:
- 资格判断:维护一份只包含清理判断所需字段的本地投影,避免跨服务同步查询。
- 删除执行:由专门的
UserLifecycleProcess Manager 编排 soft delete、撤销和最终物理删除。
Rentals 和 Subscription 仍然拥有各自的业务数据。生命周期组件只保存 activeRentalCount、outstandingPaymentCount 等派生状态,不保存租赁或账款的完整历史,因此不会破坏 bounded context。
服务自治也不代表“没有协调者”。自治是指每个服务拥有自己的数据和业务规则,其他服务只能通过公开的命令、事件或 API 与它交互。
为什么不建议每天同步查询多个服务
同步查询可以用于早期版本,但有一个很难回避的问题:多次查询的结果无法组成原子快照。
例如:
Userservice 查询后确认当前没有进行中的租赁;- 用户随后创建了一笔新租赁;
Userservice 接着查询,发现没有欠款;- 系统据此发起删除。
每次查询的结果单独看都没错,组合起来却可能得出错误结论。即使所有服务都正常运行,这个竞态条件仍然存在。
同步方案还会带来这些问题:
- 批量扫描会产生大量调用;
- 任一依赖服务不可用,都会影响整批判断;
- 请求超时后,调用方无法确定操作是否已经执行;
- 部分服务可能已经完成 soft delete,其他服务却执行失败;
- 清理规则被放进了并不掌握全部事实的
Userservice。
如果暂时只能采用同步方式,至少要遵循“无法确认就不删除”的原则,也就是 fail closed,并在执行删除前再次校验。这种方案仍然只适合规模较小的系统或过渡阶段。
本地投影应该保存什么
生命周期组件不需要保存所有租赁和付款历史,只需记录执行规则所需的最小状态:
userId
lastRentalActivityAt
activeRentalCount
outstandingPaymentCount
lifecycleState
deletionGeneration
deleteAfter
projectionVersions
这些字段分别承担以下作用:
lastRentalActivityAt表示清理规则定义的最后活动时间;activeRentalCount比“最后一次进行中租赁日期”更可靠;outstandingPaymentCount比“最后一次欠款日期”更可靠;deletionGeneration用于区分多轮删除和撤销流程;projectionVersions用于发现重复、乱序或缺失事件。
设计前必须明确“没有新的租赁”究竟指什么。通常应该把租赁的创建时间或开始时间视为活动时间,而不是完成时间。否则,一笔持续时间很长的租赁可能在结束时意外重置三个月期限。
投影保存的是当前判断结果,不是领域模型的副本。生命周期组件可以知道某位用户有两笔活动租赁,但不需要知道视频名称、租期或价格。
建议的事件设计
事件应该描述已经发生的业务事实,不能只是含义模糊的状态提示:
{
"eventId": "evt-7f4c",
"eventType": "RentalStarted",
"aggregateId": "rental-381",
"aggregateVersion": 12,
"userId": "user-123",
"occurredAt": "2026-09-01T08:30:00Z"
}
Rentals 可以发布:
RentalStarted
RentalCompleted
RentalCancelled
Subscription 可以发布:
OutstandingPaymentCreated
OutstandingPaymentSettled
OutstandingPaymentWrittenOff
如果计数容易因为事件丢失或业务分支而失真,也可以发布带版本号的绝对状态:
{
"eventType": "UserRentalStatusChanged",
"userId": "user-123",
"activeRentalCount": 0,
"lastRentalActivityAt": "2026-05-01T10:00:00Z",
"userStateVersion": 47
}
这类事件更便于恢复投影,但发布方必须保证同一用户的版本号单调递增。
生命周期流程不能只有 UserMarkedForDeletion 和 UserDeleted。一套较完整的事件和命令通常包括:
UserDeletionProposed
PrepareUserDeletion
UserDeletionPrepared
UserDeletionRejected
UserDeletionCancelled
PurgeUserData
UserDataPurged
UserPurgeCompleted
UserPurgeCompleted 应该表示所有参与服务都已确认物理清理完成,而不是协调者刚刚发出了删除请求。
删除流程
1. 发现候选用户
定时任务只查询本地投影:
SELECT user_id
FROM user_lifecycle_projection
WHERE last_rental_activity_at < :cutoff
AND active_rental_count = 0
AND outstanding_payment_count = 0
AND lifecycle_state = 'ACTIVE';
时间判断建议使用半开区间,并统一采用 UTC。例如,可以把规则明确写成:
lastRentalActivityAt <= now - 3 calendar months
“三个月”是指三个自然月还是固定 90 天,必须由业务明确,不能让不同服务分别解释。
2. 进入准备删除状态
Process Manager 为本次流程生成唯一的 deletionId 和递增的 generation:
{
"commandType": "PrepareUserDeletion",
"deletionId": "del-20260901-8842",
"generation": 3,
"userId": "user-123",
"reason": "INACTIVITY",
"observedAt": "2026-09-01T09:00:00Z"
}
各服务收到命令后,在自己的事务中完成以下操作:
- 重新检查本服务掌握的阻止条件;
- 将相关数据标记为
PENDING_DELETION; - 记录
deletionId和generation; - 写入待发布事件。
如果 Rentals 发现活动租赁,或 Subscription 发现未结清款项,就应返回 UserDeletionRejected。协调者随后向已经准备成功的服务发送 CancelUserDeletion。
这相当于业务层的 prepare 阶段,但不是跨数据库的 ACID 两阶段提交。它仍然是最终一致且可以重试的 Saga。
3. 设置保留期
只有全部必要服务都确认准备成功,流程才能正式进入 soft delete:
ACTIVE
-> PREPARING_DELETION
-> SOFT_DELETED
-> PURGING
-> PURGED
如果不同服务的保留期不同,协调者可以等待其中最长的保留期,也可以分别记录每个参与者的 purgeAfter。分别记录更灵活,但会让流程状态更复杂。
4. 处理保留期内的新活动
soft delete 后如何处理新租赁或付款,必须由业务明确决定。常见方案有两种:
- 禁止新活动,要求用户先恢复账户;
- 新活动自动触发整个删除流程的撤销。
不能让每个服务“独立取消 soft delete”而不通知其他服务。否则可能出现一个服务已经恢复,另一个服务仍准备执行物理删除的分裂状态。
撤销也应该由统一流程协调:
DeletionInvalidated
CancelUserDeletion
UserDeletionCancellationConfirmed
每条命令都要携带相同的 deletionId 和 generation。服务只允许当前 generation 的命令修改状态,防止延迟到达的旧消息再次删除已经恢复的用户。
例如:
void handle(PurgeUserData command) {
DeletionMarker marker = repository.findMarker(command.userId());
if (marker == null) {
return; // 已处理或无需处理
}
if (!marker.deletionId().equals(command.deletionId())
|| marker.generation() != command.generation()) {
return; // 过期命令
}
if (marker.status() != Status.SOFT_DELETED) {
return;
}
purgeOwnedData(command.userId());
outbox.publish(new UserDataPurged(
command.userId(),
command.deletionId(),
command.generation()
));
}
幂等性与消息可靠性
采用事件驱动架构,不代表每条消息只会到达一次。设计时应假设消息可能重复、延迟或乱序。
每个消费者都应建立 inbox 或功能相同的去重记录:
CREATE TABLE consumed_message (
consumer_name VARCHAR(100) NOT NULL,
event_id VARCHAR(100) NOT NULL,
consumed_at TIMESTAMP NOT NULL,
PRIMARY KEY (consumer_name, event_id)
);
业务变更和事件发布应该使用 Transactional Outbox,确保两者处于同一个本地数据库事务中:
BEGIN;
UPDATE rental
SET status = 'ACTIVE'
WHERE rental_id = :rental_id;
INSERT INTO outbox_event (
event_id,
aggregate_id,
aggregate_version,
event_type,
payload
) VALUES (
:event_id,
:rental_id,
:version,
'RentalStarted',
:payload
);
COMMIT;
还要处理以下问题:
- 同一 aggregate 的事件尽量进入同一个消息分区;
- 消费者通过
aggregateVersion识别旧事件; - 发现版本跳跃时,需要重试、报警或从来源服务重建投影;
- 所有命令和确认事件都携带
deletionId; - 重试使用退避策略,并配置 dead-letter queue;
- Process Manager 必须持久化每个参与者的确认状态,不能只保存在内存中。
最终物理删除的边界
物理删除通常无法补偿。因此,在进入 PURGING 前还要再次确认:
- 保留期是否已经结束;
- 是否收到撤销事件;
- 当前
generation是否仍然有效; - 本地是否出现新的阻止条件;
- 是否存在审计、财务或法律方面的保留要求。
每个服务只能删除自己拥有的数据。Rentals 不应接受由 User service 直接操作其数据库的请求。
有些记录可能不能真正删除,例如依法需要保留的财务凭证。具体要求取决于所在地区和业务性质,需要根据合规规则确认。在这种情况下,可以删除或匿名化个人标识,同时保留必要的不可识别记录。不要为了方便重放事件,就在永久事件日志中继续保存完整的个人信息,否则“数据库已删除”并不等于数据已经真正清理。
协调者可以保留一条最小化的删除凭证,例如:
{
"deletionId": "del-20260901-8842",
"subjectReferenceHash": "sha256:...",
"completedAt": "2026-12-01T09:15:00Z",
"participants": [
"user",
"rentals",
"subscription",
"online-account"
]
}
能否保留这类凭证,也需要结合隐私和审计要求决定。
服务边界怎么放更合适
如果用户规模较小,可以先把生命周期投影和 Process Manager 放在 User service 内,但应将它们设计为独立模块,不要混入用户资料领域模型。
随着清理、归档、匿名化和账户关闭等流程增多,可以建立独立的 UserLifecycle 或 DataRetention bounded context。它负责:
- 汇总清理资格判断所需的最小事实;
- 执行保留策略;
- 编排 soft delete、撤销和 purge;
- 记录流程进度与失败;
- 不拥有租赁、付款或登录账户的领域数据。
这种设计既能避免同步查询造成的运行时耦合,也不需要把其他服务的完整历史复制到 User service。
事件驱动降低的是运行时耦合,并不会消除所有耦合。事件 schema 本身就是服务之间的契约,需要做好版本管理、兼容性测试、监控和投影重建。
实际落地建议
可以按照以下顺序实施:
- 明确定义“活动”“三个月”“欠款”和“进行中租赁”的业务语义。
- 让
Rentals和Subscription通过 Outbox 发布事实事件。 - 建立最小生命周期投影,只保存时间、计数、状态和版本。
- 由定时任务查询本地投影,发现候选用户。
- 使用持久化的 Process Manager 发起带有
deletionId和generation的 prepare 流程。 - 所有参与者确认后再进入 soft delete。
- 把任何新活动都转换为统一的撤销流程,不能让各服务独立恢复。
- 保留期结束后再次校验,再向各服务发送 purge 命令。
- 收齐
UserDataPurged后发布最终完成事件。 - 针对重复消息、乱序事件、服务宕机、永久失败和人工干预建立监控与恢复入口。
最终一致性允许各服务的状态在短时间内不同,但不能接受永久不一致。所有中间状态都要能够观察,所有操作都要能够安全重试。执行不可逆删除前必须进行明确的最后校验,并确保整个流程最终能够收敛。
备注:内容仅供参考。
