PAYATHON 2026

如何设计跨 microservices 的用户数据清理业务规则?

支付小周

结论

这条规则适合采用“事件驱动的本地投影 + Process Manager/Saga”组合。不要让 User service 每天同步查询所有服务,也不能只靠 User 删除相关的两个事件完成整个流程。

整个流程可以分为两个阶段:

  1. 资格判断:维护一份只包含清理判断所需字段的本地投影,避免跨服务同步查询。
  2. 删除执行:由专门的 UserLifecycle Process Manager 编排 soft delete、撤销和最终物理删除。

Rentals 和 Subscription 仍然拥有各自的业务数据。生命周期组件只保存 activeRentalCount、outstandingPaymentCount 等派生状态,不保存租赁或账款的完整历史,因此不会破坏 bounded context。

服务自治也不代表“没有协调者”。自治是指每个服务拥有自己的数据和业务规则,其他服务只能通过公开的命令、事件或 API 与它交互。

为什么不建议每天同步查询多个服务

同步查询可以用于早期版本,但有一个很难回避的问题:多次查询的结果无法组成原子快照。

例如:

  1. User service 查询后确认当前没有进行中的租赁;
  2. 用户随后创建了一笔新租赁;
  3. User service 接着查询,发现没有欠款;
  4. 系统据此发起删除。

每次查询的结果单独看都没错,组合起来却可能得出错误结论。即使所有服务都正常运行,这个竞态条件仍然存在。

同步方案还会带来这些问题:

  • 批量扫描会产生大量调用;
  • 任一依赖服务不可用,都会影响整批判断;
  • 请求超时后,调用方无法确定操作是否已经执行;
  • 部分服务可能已经完成 soft delete,其他服务却执行失败;
  • 清理规则被放进了并不掌握全部事实的 User service。

如果暂时只能采用同步方式,至少要遵循“无法确认就不删除”的原则,也就是 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"
}

各服务收到命令后,在自己的事务中完成以下操作:

  1. 重新检查本服务掌握的阻止条件;
  2. 将相关数据标记为 PENDING_DELETION;
  3. 记录 deletionId 和 generation;
  4. 写入待发布事件。

如果 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 本身就是服务之间的契约,需要做好版本管理、兼容性测试、监控和投影重建。

实际落地建议

可以按照以下顺序实施:

  1. 明确定义“活动”“三个月”“欠款”和“进行中租赁”的业务语义。
  2. 让 Rentals 和 Subscription 通过 Outbox 发布事实事件。
  3. 建立最小生命周期投影,只保存时间、计数、状态和版本。
  4. 由定时任务查询本地投影,发现候选用户。
  5. 使用持久化的 Process Manager 发起带有 deletionId 和 generation 的 prepare 流程。
  6. 所有参与者确认后再进入 soft delete。
  7. 把任何新活动都转换为统一的撤销流程,不能让各服务独立恢复。
  8. 保留期结束后再次校验,再向各服务发送 purge 命令。
  9. 收齐 UserDataPurged 后发布最终完成事件。
  10. 针对重复消息、乱序事件、服务宕机、永久失败和人工干预建立监控与恢复入口。

最终一致性允许各服务的状态在短时间内不同,但不能接受永久不一致。所有中间状态都要能够观察,所有操作都要能够安全重试。执行不可逆删除前必须进行明确的最后校验,并确保整个流程最终能够收敛。

备注:内容仅供参考。