以前单体项目里删一个用户,我脑子里的代码大概是:
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 能不能跑通。
那一般都能。
我只想问一句:
第三步挂了以后呢?
能把这句话讲清楚的架构,才是真的能上线。