微服务里最危险的 DELETE,不是删不掉,是只删了一半

以前单体项目里删一个用户,我脑子里的代码大概是:

ini 复制代码
DELETE FROM users WHERE id = ?;

再复杂一点:

复制代码
用户
↓
订单
↓
优惠券
↓
用户配置

开个事务。

要么一起成功。

要么一起回滚。

事情虽然不一定优雅,但至少比较好理解。

到了微服务以后,这个事情突然就变味了。

现在一个"删除用户"的需求可能长这样:

sql 复制代码
User Service
    ↓
Order Service
    ↓
Coupon Service
    ↓
Search Service
    ↓
Message Service

产品说:

用户注销以后,把相关数据清掉。

听起来就一句话。

真开始做的时候,我现在第一反应已经不是:

DELETE 接口怎么写?

而是:

删到第三个服务失败了怎么办?

这个问题如果没回答清楚,我基本不敢让 AI 继续往下写代码。


先看一个很容易写出来的版本

假设用户注销:

csharp 复制代码
async function deleteUser(userId: string) {
  await userService.delete(userId);

  await orderService.deleteByUser(userId);

  await couponService.deleteByUser(userId);

  await searchService.removeUser(userId);

  await messageService.removeSubscriptions(userId);

  return {
    success: true
  };
}

第一眼看:

没什么毛病。

顺序也挺清楚。

AI 写这种代码甚至都不用十秒。

正常测试:

sql 复制代码
User Service        200
Order Service       200
Coupon Service      200
Search Service      200
Message Service     200

最后:

json 复制代码
{
  "success": true
}

完美。

然后你上线。


接着 Coupon Service 超时了一次

这次执行变成:

sql 复制代码
User Service
DELETE 成功

↓

Order Service
DELETE 成功

↓

Coupon Service
timeout

↓

流程中断

现在系统是什么状态?

用户:

复制代码
已经没了

订单:

复制代码
已经删了

优惠券:

复制代码
还在

搜索索引:

复制代码
还在

消息订阅:

复制代码
也还在

恭喜。

一个:

删除用户

变成了:

随机删除一半用户。

这种数据我一般叫:

孤儿数据。

真正麻烦的不是这次请求报了 500。

500 很好发现。

真正麻烦的是:

系统已经被改了一半。


那失败以后再重试不就行了?

听起来合理。

客户端:

bash 复制代码
DELETE /users/1001

第一次失败。

再来一次。

但现在又出现一个问题。

第一步:

csharp 复制代码
await userService.delete(userId);

用户已经不存在了。

User Service 可能返回:

复制代码
404 Not Found

于是程序:

复制代码
第一步就挂了

后面的 Coupon、Search、Message 永远没有机会继续清理。

也就是说:

这个删除流程甚至不是可重试的。


这时候我会开始问第二个问题

删除接口的"成功"到底是什么意思?

这个问题比代码本身重要很多。

是:

复制代码
所有服务的数据已经物理删除

才算成功?

还是:

复制代码
用户已经不可使用,
后续清理异步完成

就可以返回成功?

这两种定义对应的架构完全不一样。

如果需求都没说清楚,直接开始写:

csharp 复制代码
async deleteUser() {}

基本就是给未来埋雷。


我现在更喜欢把"注销"和"清理"拆开

例如用户注销时,不直接到处调用:

arduino 复制代码
delete
delete
delete
delete

而是先让真正拥有用户状态的 User Service 做一件确定的事情:

复制代码
ACTIVE
↓
DELETED

或者业务复杂一点:

复制代码
ACTIVE
↓
DELETING
↓
DELETED

然后告诉其他服务:

这个用户已经被删除。


一个比较实用的设计:Tombstone + Event

比如用户表:

sql 复制代码
CREATE TABLE users (
    id              BIGINT PRIMARY KEY,
    name            VARCHAR(100),
    email           VARCHAR(255),

    status          VARCHAR(20) NOT NULL,
    deleted_at      TIMESTAMP NULL,
    version         BIGINT NOT NULL DEFAULT 0
);

注销的时候,不急着:

sql 复制代码
DELETE FROM users;

而是:

ini 复制代码
UPDATE users
SET
    status = 'DELETED',
    deleted_at = NOW(),
    version = version + 1
WHERE id = ?
  AND status <> 'DELETED';

这里留下的是一个:

Tombstone。

墓碑。

意思很简单:

这个实体存在过,但现在已经被逻辑删除了。

这样以后再来:

bash 复制代码
GET /users/1001

User Service 很明确知道:

复制代码
不是"查不到"

而是:

复制代码
这个用户已经被删除

这两个语义其实不一样。


但只做软删除还不够

因为:

sql 复制代码
Order Service
Coupon Service
Search Service

根本不知道这件事发生了。

所以我们还需要一条事件:

json 复制代码
{
  "eventId": "01K...",
  "type": "UserDeleted",
  "userId": "1001",
  "version": 17,
  "occurredAt": "2026-09-10T10:30:00Z"
}

其他服务订阅:

复制代码
UserDeleted

然后分别处理自己的数据。


这里马上又来了一个经典坑

很多人会这么写:

python 复制代码
await userRepo.markDeleted(userId);

await eventBus.publish({
  type: "UserDeleted",
  userId
});

看起来合理。

但是如果:

sql 复制代码
数据库 UPDATE 成功
↓
服务崩了
↓
publish 没执行

会怎样?

User Service:

复制代码
用户已经删除

其他所有服务:

复制代码
完全不知道

又回到孤儿数据。

反过来也一样麻烦。

所以:

"改数据库"和"发事件"不能只是两个普通的 await。


Outbox Pattern 就是专门解决这个问题的

做法其实没有想象中玄学。

准备一个:

复制代码
outbox_events

表。

例如:

sql 复制代码
CREATE TABLE outbox_events (
    id              VARCHAR(64) PRIMARY KEY,
    aggregate_type  VARCHAR(50) NOT NULL,
    aggregate_id    VARCHAR(64) NOT NULL,
    event_type      VARCHAR(100) NOT NULL,
    payload         JSON NOT NULL,
    created_at      TIMESTAMP NOT NULL,
    published_at    TIMESTAMP NULL
);

注销用户的时候:

sql 复制代码
BEGIN;

UPDATE users
SET
    status = 'DELETED',
    deleted_at = NOW(),
    version = version + 1
WHERE id = 1001
  AND status <> 'DELETED';

INSERT INTO outbox_events (
    id,
    aggregate_type,
    aggregate_id,
    event_type,
    payload,
    created_at
)
VALUES (
    'evt-xxx',
    'User',
    '1001',
    'UserDeleted',
    '{"userId":"1001","version":17}',
    NOW()
);

COMMIT;

注意:

diff 复制代码
用户状态修改
+
Outbox Event

在:

同一个本地事务里。

所以只有两种结果:

复制代码
两个都成功

或者:

复制代码
两个都失败

不会出现:

复制代码
用户删了
但事件凭空没了

然后单独有个 Publisher 发事件

比如:

vbnet 复制代码
async function publishOutboxEvents() {
  const events =
    await outboxRepository.findUnpublished(100);

  for (const event of events) {
    try {
      await eventBus.publish(
        event.eventType,
        event.payload
      );

      await outboxRepository.markPublished(
        event.id
      );
    } catch (error) {
      logger.error({
        eventId: event.id,
        error
      });
    }
  }
}

发失败?

没事。

记录还在数据库。

下一轮继续。

这时候整个问题就从:

网络调用必须一次成功

变成了:

最终一定想办法把事件送出去。

工程上舒服很多。


但事情还没结束

假设 MQ 因为某些原因把:

复制代码
UserDeleted

投递了两次。

Coupon Service 收到:

csharp 复制代码
event 1
event 1

怎么办?

如果消费逻辑是:

scss 复制代码
await refundSomething();
await deleteCoupons();

执行两次可能就麻烦了。

所以这里又出现一个我现在看分布式系统一定会问的问题:

你的消费者幂等吗?


最简单的办法之一:记录处理过的 eventId

比如:

sql 复制代码
CREATE TABLE processed_events (
    event_id        VARCHAR(64) PRIMARY KEY,
    processed_at    TIMESTAMP NOT NULL
);

消费的时候:

vbnet 复制代码
async function onUserDeleted(event: UserDeleted) {
  await db.transaction(async tx => {
    const processed =
      await tx.processedEvents.exists(
        event.eventId
      );

    if (processed) {
      return;
    }

    await tx.coupons.deleteByUser(
      event.userId
    );

    await tx.processedEvents.insert({
      eventId: event.eventId,
      processedAt: new Date()
    });
  });
}

第一次:

复制代码
正常处理
↓
记录 eventId

第二次:

复制代码
发现已经处理
↓
直接结束

这就是最基础的幂等。


注意:不要迷信"消息只会发一次"

在真实系统里,我反而更愿意按:

复制代码
可能重复

来设计。

因为:

复制代码
Publisher 重试
消费者重启
ACK 丢失
网络问题

都可能让消息重新出现。

与其追求:

我一定保证整个链路 Exactly Once。

很多时候更实际的是:

diff 复制代码
At-least-once delivery
+
幂等消费

消息可以多来。

结果别多执行就行。


那如果 Coupon Service 一直失败怎么办?

这才是真正线上该考虑的问题。

理想状态:

sql 复制代码
UserDeleted
↓
Order OK
Coupon OK
Search OK
Message OK

现实状态:

sql 复制代码
Order     OK
Coupon    retry 17 次
Search    OK
Message   OK

这时候不能指望:

再等等应该会好。

需要可观测。

至少应该知道:

复制代码
哪个用户
哪个事件
哪个消费者
失败多少次
最后一次错误是什么
卡了多久

例如:

lua 复制代码
event_id
consumer
retry_count
last_error
next_retry_at
status

然后超过阈值:

复制代码
RETRYING
↓
DEAD

进入:

复制代码
DLQ / 人工处理 / 补偿流程

不然所谓:

最终一致性

特别容易变成:

最终没人管。


我现在特别警惕一句话:反正最终一致

这个词很容易变成架构万能胶。

数据不一致?

最终一致。

多久最终?

不确定。

失败了怎么办?

会重试。

重试多少次?

配个次数。

一直失败呢?

......

所以我现在只要有人说:

最终一致。

我会继续问:

复制代码
最终是多久?

怎么知道还没一致?

谁负责重试?

重试是不是幂等?

永远失败怎么办?

怎么人工恢复?

这些问题答不出来。

那不叫最终一致。

叫:

希望它最后能一致。


删除还有一个坑:顺序

假设事件来了。

Order Service:

复制代码
删除订单关联

Search Service:

复制代码
删除索引

但是用户又发生了一个更新事件:

ini 复制代码
UserUpdated version=16

由于网络乱序:

ini 复制代码
UserDeleted version=17
先到

UserUpdated version=16
后到

如果消费者完全不看版本。

Search Service:

复制代码
先删掉用户
↓
随后又被旧 UserUpdated 建回来

用户:

死而复生。

所以事件里我很喜欢带:

json 复制代码
{
  "userId": "1001",
  "version": 17
}

消费者自己保存:

复制代码
lastProcessedVersion

旧版本事件来了:

复制代码
16 < 17

直接忽略。


所以事件版本也很重要

比如:

csharp 复制代码
if (
  event.version <=
  currentVersion
) {
  return;
}

处理成功以后:

ini 复制代码
currentVersion = event.version

这样至少能避免一部分:

旧事件覆盖新状态。

当然具体设计还是看业务。

并不是所有场景都必须做版本控制。

但只要涉及:

复制代码
异步
乱序
重试
并发更新

这个问题就值得问一句。


我现在拿这种问题测 AI,不会问"怎么设计用户删除"

因为这样太宽了。

模型很容易给你一堆架构名词:

vbnet 复制代码
Saga
TCC
Seata
Event Sourcing
MQ
2PC
Outbox

看起来知识量拉满。

实际不知道怎么落。

我现在更喜欢:

给它失败现场。

Prompt 我会这么写:

markdown 复制代码
现在有一个微服务用户注销流程:

User Service
→ Order Service
→ Coupon Service
→ Search Service
→ Message Service

当前实现是同步顺序调用。

请不要先给我框架选型。

假设:

1. User 删除成功;
2. Order 删除成功;
3. Coupon 调用超时;
4. 后续服务未执行。

请回答:

- 系统现在处于什么状态?
- 再次重试会遇到什么问题?
- 哪些操作必须保证幂等?
- 怎么检测孤儿数据?
- 怎么保证用户状态修改成功后,删除事件最终不会丢?
- 如果某个消费者永久失败,如何恢复?

最后再给架构方案。

这个 Prompt 比:

帮我设计微服务删除流程

强很多。

因为它强迫模型先面对:

失败。


我还会换一个模型专门攻击第一份方案

这个特别适合分布式一致性问题。

因为第一个模型一旦决定:

用 MQ + 最终一致性。

后面通常会一路证明:

MQ + 最终一致性挺好。

我现在会把第一份方案直接拿去做第二轮。

平时这种技术方案复核,我一般直接在 chathao.com 里换一个模型继续问,同一个问题不用来回开一排页面;你有几个官方账号的话直接开两个窗口也一样。

第二轮不重新设计。

只问:

markdown 复制代码
这是另一个架构师给出的用户删除方案。

不要重新设计。

只攻击它。

分别模拟:

1. 数据库提交成功,但事件发布前进程崩溃;
2. 同一条删除事件重复投递;
3. 消费者处理成功但 ACK 丢失;
4. 事件乱序;
5. 某个消费者连续失败 24 小时;
6. 删除过程中用户又发生更新;
7. 删除请求本身被客户端重复提交。

每种情况告诉我:

- 当前方案是否安全;
- 会留下什么错误状态;
- 系统如何自动恢复;
- 如果不能恢复,缺少什么机制。

不要接受"最终一致性"作为完整答案。

最后一句我特别喜欢:

不要接受"最终一致性"作为完整答案。

因为模型有时候确实特别爱拿这个词结束讨论。


如果让我给一个实际能落地的版本

我大概会拆成:

sql 复制代码
用户发起注销
        ↓
User Service 本地事务
        ↓
User 状态 = DELETED
+
写入 UserDeleted Outbox
        ↓
COMMIT
        ↓
Outbox Publisher
        ↓
MQ
        ↓
┌───────────────┐
Order Consumer
Coupon Consumer
Search Consumer
Message Consumer
└───────────────┘
        ↓
每个 Consumer
幂等处理
        ↓
失败重试
        ↓
超过阈值进入异常队列
        ↓
监控 / 对账 / 人工修复

这里真正重要的不是 MQ。

而是下面这些能力都存在:

复制代码
本地原子性

可靠事件

幂等

重试

失败可见

可恢复

少一个。

都可能变成线上坑。


再往前一步:删除最好有状态机

复杂一点的系统,我甚至不会只有:

复制代码
ACTIVE
DELETED

可能会做:

复制代码
ACTIVE
↓
DELETE_REQUESTED
↓
DELETING
↓
DELETED

如果某些资源还有:

复制代码
退款
结算
审计
延迟清理

甚至:

复制代码
DELETE_FAILED

为什么?

因为:

用户点击了注销

和:

所有系统资源已经完全清理

本来就不是一个瞬间发生的事情。

不要为了让接口看起来:

复制代码
200 OK

就假装它们是同一个状态。


有一个问题尤其容易被漏掉:到底该不该真 DELETE?

例如订单。

用户注销了。

订单一定要:

sql 复制代码
DELETE FROM orders

吗?

不一定。

业务可能仍然需要订单记录。

真正需要删除或匿名化的,也许是:

复制代码
姓名
手机号
地址
Email

而订单本身需要保留。

所以"删除用户"在不同服务里的语义可能其实是:

sql 复制代码
User Service
逻辑删除账户

Search
删除索引

Message
取消订阅

Coupon
失效账户权益

Order
匿名化用户信息

根本不是所有服务统一:

sql 复制代码
DELETE

这就是为什么我现在很少让 AI 一上来直接:

写实现。

先问:

每个服务里的"删除"分别是什么意思?

这个问题经常比代码重要。


我现在 Review 微服务删除,固定问这 10 个问题

这个检查表可以直接拿走:

markdown 复制代码
1. 删到一半失败,当前状态是什么?

2. 整个操作能不能安全重试?

3. 重复删除两次,会不会产生副作用?

4. 数据库提交以后服务挂了,事件会不会丢?

5. 同一事件消费两次,会不会重复执行?

6. 事件乱序会不会让旧数据复活?

7. 某个消费者一直失败,谁能发现?

8. 失败以后是自动恢复还是只能人工修?

9. 新增一个微服务以后,它怎么知道自己也要处理删除?

10. 每个服务里的"删除"到底是物理删除、逻辑删除,还是匿名化?

如果这 10 条都能回答。

这个删除方案基本已经不是 Demo 级了。


最后

以前看到一个需求:

增加用户注销功能。

脑子里很容易变成:

sql 复制代码
DELETE FROM users

现在我第一反应已经完全不一样了。

我会先画:

复制代码
谁拥有用户状态?

哪些服务保存用户数据?

哪些步骤允许异步?

哪些动作必须幂等?

事件会不会丢?

失败了怎么恢复?

最终怎么证明真的清干净了?

所以微服务里真正难的从来不是:

怎么删除。

而是:

删到一半以后,系统还能不能自己走回正确状态。

如果做不到。

那这个 DELETE 接口成功得再快,也只是:

把数据一致性问题从同步请求里藏到了线上。

我现在看这种方案,甚至都不太关心:

Happy Path 能不能跑通。

那一般都能。

我只想问一句:

第三步挂了以后呢?

能把这句话讲清楚的架构,才是真的能上线。

相关推荐
夏洛克信徒1 小时前
当黄仁勋说出“AGI已来“:2026年9月大模型风暴观察
人工智能·gpt·chatgpt·agi
通问AI1 小时前
用多模态图像模型批量生产电商主图:Prompt 模板化 + 自动化质检的工程实践
人工智能
Zane19941 小时前
线上突然OOM,你的排查顺序是先看日志还是先重启
java·后端
也不知秋1 小时前
从“能回答”到“能干活”:AI Agent真正落地,需要哪些工程能力?
人工智能·程序员
ACP广源盛139246256731 小时前
国产 PCIe4.0 交换芯片 IX8012@ACP:AI 推理服务器高速 IO 扩展方案解析
人工智能·硬件架构·pcie·国产芯片·ai服务器
西门老铁1 小时前
UUID 还是雪花 ID?分布式唯一 ID 方案怎么选?
后端
吃饱了得干活1 小时前
Java设计模式实战:一个支付模块的重构之旅,层层递进理解设计模式精髓
后端·设计模式·架构
论文复现现场2 小时前
单卡RTX 3090能训练,切到4卡却OOM:Accelerate多卡训练怎么排查?
人工智能·pytorch·云计算·gpu算力·多卡训练