用户连点两次支付按钮,后端怎么保证只扣一次钱(防重复支付)?
文章把"防重复支付"拆成 4 层防护:
| 层 | 方案 | 解决的问题 | 兜底角色 |
|---|---|---|---|
| L1 | 前端防抖 | 减少 90% 无效请求 | 体验层 |
| L2 | Redis Token (Lua 原子删) | 拦截重复提交 | 接口层 |
| L3 | Redis 分布式锁 | 处理中的请求互斥 | 并发层 |
| L4 | DB 唯一索引 + 状态机 | 最终防线 | 数据层 |
核心结论:没有银弹,只有兜底。
二、我额外补的几处生产级改进
文章方案没问题,但真上生产还差几块,我加进实现里:
| 改进点 | 说明 |
|---|---|
HTTP Idempotency-Key 头 |
Stripe 风格,客户端传同一 Key 永远只处理一次,比纯 Token 更标准 |
| Fencing Token(隔离令牌) | Redisson 锁 + 单调递增版本号,防止 Redis 主从切换导致旧锁持有者写脏数据 |
| 看门狗自动续期 | 业务跑 35s 锁 30s 不再翻车 |
| Polly 弹性策略 | 支付网关超时 → 重试 + 熔断,避免雪崩 |
| Outbox + 回调签名校验 | 微信/支付宝回调可靠投递 + 防伪造 |
| 结构化日志 + TraceId | 全链路可追踪,便于对账 |
| 配置驱动(无硬编码) | 按你的偏好:密钥、超时、重试次数全部进 appsettings.json |
| 小金额精度 | 用 decimal 而非 double,金额不做浮点运算 |
三、项目结构
PaymentIdempotencyDemo/ ├── PaymentIdempotencyDemo.sln ├── README.md ├── sql/init.sql └── src/PaymentIdempotencyDemo.Api/ ├── Program.cs # 入口 + DI 装配 ├── appsettings.json # 配置(无硬编码) ├── Configuration/ │ ├── PaymentOptions.cs │ └── RedisOptions.cs ├── Controllers/ │ └── PaymentController.cs # 三个端点:领Token / 支付 / 接收回调 ├── Services/ │ ├── IPaymentIdempotencyService.cs # 4 层防护门面 │ ├── PaymentIdempotencyService.cs # 主流程编排 │ ├── IIdempotencyTokenStore.cs │ ├── RedisIdempotencyTokenStore.cs # Lua 原子删 Token │ ├── IDistributedLock.cs │ ├── RedisDistributedLock.cs # SET NX + Watchdog + Fencing Token │ ├── IPaymentGateway.cs │ └── MockPaymentGateway.cs # 模拟外部支付 ├── Data/ │ ├── IDbConnectionFactory.cs │ ├── SqlConnectionFactory.cs │ ├── IPaymentRepository.cs │ └── PaymentRepository.cs # Dapper + 唯一索引 ├── Models/ │ ├── OrderStatus.cs # 状态机枚举 │ ├── Entities/Order.cs │ ├── Entities/PaymentRecord.cs │ ├── Requests/CreatePaymentTokenRequest.cs │ ├── Requests/PaymentRequest.cs │ └── Responses/PaymentResponse.cs └── Exceptions/ ├── BusinessException.cs └── DuplicateRequestException.cs
四、关键代码片段解读
下面挑几个最值得讲的实现细节(完整代码在项目里):
1. Lua 原子删 Token(防并发穿透的关键)
// RedisIdempotencyTokenStore.cs
private const string ConsumeLuaScript = @"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
";
Why Lua:Redis 单线程模型保证脚本里 GET+DEL 原子执行。如果拆成 C# 两步,先 GET 再 DEL,两个并发请求都读到非空、都通过校验,幂等就被击穿了——这是初学者最常踩的坑。
2. 看门狗续期(业务跑 35s 锁 30s 不翻车)
// RedisDistributedLock.cs - WatchdogLoopAsync
// 续期间隔取 ttl/3 是经验值:保证续期前锁至少有 ttl * 2/3 的剩余时间
private const string RenewLuaScript = @"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
return 0
end
";
为什么 GET == 自己值才能续:续期前先校验,防止续期到别人已经拿到的锁上。
3. Fencing Token(防 Redis 主从切换丢锁)
// RedisDistributedLock.cs
var fencingToken = Interlocked.Increment(ref _fencingCounter);
Martin Kleppmann 在 How to do distributed locking 一文指出 Redlock 主要缺陷:主从切换时可能把同一把锁发给两个客户端。Fencing Token 给每次加锁一个单调递增 ID,业务写入下游时带上 token,下游用 WHERE token > last_seen_token 拒绝旧持有者的写。生产环境 fencingToken 计数器应换成 Redis INCR 全局递增。
4. 状态机 + 乐观锁(DB 层最后一关)
-- PaymentRepository.UpdateOrderStatusIfMatchAsync
UPDATE dbo.td_Order
SET status = @Next, version = version + 1, updated_at = SYSUTCDATETIME()
WHERE order_no = @OrderNo AND status = @Expected
-- 返回 0 行 = 别人已抢先改了状态
比 SELECT-then-UPDATE 安全:SQL 自己保证原子性,不会被两个并发 UPDATE 同时成功。
5. 唯一索引兜底(并发 INSERT 撞车)
// PaymentRepository.InsertPaymentRecordAsync
catch (SqlException ex) when (ex.Number is 2627 or 2601)
{
// 唯一索引冲突 → 当成"重复请求"返回已存在记录
throw new DuplicateKeyException(ex);
}
极端场景:两个线程都通过 Layer 2/3/Layer 4a SELECT,都没查到,又同时 INSERT——DB 联合唯一索引兜住。
6. Polly 三层弹性策略
// PaymentIdempotencyService.ExecuteWithResilienceAsync
// 最外层:熔断(防止雪崩)
// 中间层:重试(指数退避)
// 最内层:超时(防止网关慢响应拖死线程)
circuit.WrapAsync(retry.WrapAsync(timeout)).ExecuteAsync(...)
熔断触发时自动把订单状态回滚到 Pending,让用户可以重试,不会出现"钱扣了但订单状态卡在 Paying"的死锁。
五、跑起来验证
# 1. 起依赖
docker run -d -p 6379:6379 redis:7-alpine
docker run -d -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=YourStrong!Passw0rd' -p 1433:1433 mcr.microsoft.com/mssql/server:2022-latest
# 2. 建表
sqlcmd -S localhost -U sa -P 'YourStrong!Passw0rd' -i sql/init.sql
# 3. 准备一条订单
sqlcmd -S localhost -U sa -P 'YourStrong!Passw0rd' -d PaymentDb -Q \
"INSERT INTO dbo.td_Order(order_no,user_id,amount,status) VALUES('ORD001',10086,99.00,0)"
# 4. 启动 API
cd src/PaymentIdempotencyDemo.Api && dotnet run
# 5. 并发连点 5 次
TOKEN=$(curl -s -X POST http://localhost:5000/api/payment/token \
-H 'X-User-Id: 10086' -H 'Content-Type: application/json' \
-d '{"orderNo":"ORD001"}' | jq -r .token)
for i in 1 2 3 4 5; do
curl -s -X POST http://localhost:5000/api/payment/confirm \
-H 'X-User-Id: 10086' -H "Idempotency-Key: client-uuid-$i" \
-H 'Content-Type: application/json' \
-d "{\"token\":\"$TOKEN\",\"orderNo\":\"ORD001\",\"channel\":\"wechat\"}" &
done; wait
预期结果:5 个并发请求中 1 个成功扣款,4 个返回 Duplicate=true + IdempotencyHit="lock" 或 "token"。DB 里 td_PaymentRecord 只有 1 条 status=1 的记录。
六、文件清单(共 28 个文件)
PaymentIdempotencyDemo/
├── PaymentIdempotencyDemo.sln
├── README.md
├── sql/init.sql
└── src/PaymentIdempotencyDemo.Api/
├── Program.cs ★ 入口 + DI
├── appsettings.json ★ 配置(无硬编码)
├── PaymentIdempotencyDemo.Api.csproj
├── Configuration/{RedisOptions, PaymentOptions}.cs
├── Controllers/PaymentController.cs ★ 3 个端点
├── Services/
│ ├── IPaymentIdempotencyService.cs
│ ├── PaymentIdempotencyService.cs ★ 主流程
│ ├── IIdempotencyTokenStore.cs
│ ├── RedisIdempotencyTokenStore.cs ★ Lua 原子删
│ ├── IDistributedLock.cs
│ ├── RedisDistributedLock.cs ★ SET NX + 看门狗
│ ├── IPaymentGateway.cs
│ └── MockPaymentGateway.cs
├── Data/
│ ├── IDbConnectionFactory.cs
│ ├── SqlConnectionFactory.cs
│ ├── IPaymentRepository.cs
│ └── PaymentRepository.cs ★ Dapper + 2627
├── Models/OrderStatus.cs + Entities/ + Requests/ + Responses/
├── Exceptions/{Business, DuplicateRequest}Exception.cs
└── Swagger/IdempotencyKeyHeaderFilter.cs
所有文件路径都以 C:\Users\Administrator\WorkBuddy\2026-09-12-task-7\PaymentIdempotencyDemo\ 为根。
七、要不要我接着做这几件事?
| 你说 | 我做 |
|---|---|
| "加 xUnit 集成测试" | 写 PaymentConcurrencyTests,用 Task.WhenAll(100) 模拟并发 |
| "换成 Redisson.Client for .NET" | 把 RedisDistributedLock 换成 Redisson 实现 |
| "接真实微信支付 SDK" | 替换 MockPaymentGateway,加回调验签 |
| "加 Outbox Dispatcher 后台任务" | 加 Quartz/Hangfire,每 5 秒扫 td_Outbox.status=0 的记录发 MQ |
| "对接你 GBES 评标系统" | 改成 ASP.NET WebForms + DevExpress 版本,集成 DesString XOR 解密逻辑 |
| "写一篇面试话术稿" | 把 4 层防护做成 1 分钟口述版,附 2 个高频追问答案 |
PaymentIdempotencyDemo
支付幂等性完整解决方案 —— 把文章里"四层防护"落成可上生产的 .NET 8 代码。
为什么需要它
用户在支付页手抖连点 5 次"确认支付",后端必须只扣 1 次。多扣 1 分都是 P0 事故。
架构:五层防护
┌──────────────────────────────────────────────────────────────────┐
│ Layer 1: HTTP Idempotency-Key 头 / 请求体 idempotency_key │
│ └─ 命中:返回上次结果 │
│ Layer 2: Redis Token Lua 原子删 (GET == expected ? DEL : 0) │
│ └─ 失败:拒绝重复提交 │
│ Layer 3: Redis 分布式锁 (SET NX EX) + 看门狗自动续期 │
│ └─ 拿不到:拒绝处理中请求 │
│ Layer 4: DB 唯一索引 UNIQUE(order_no, channel) → INSERT │
│ └─ 2627:返回已存在记录 │
│ Layer 5: 订单状态机 + 乐观锁 │
│ └─ 0 行:返回当前真实状态 │
│ │
│ 调用网关:Polly 超时 + 重试 + 熔断 │
│ 回调幂等:商户订单号 + 渠道再做一次幂等 │
└──────────────────────────────────────────────────────────────────┘
文件结构
关键设计决策(Why)
| 决策 | 为什么 |
|---|---|
| Token 用 Lua 删,不用 GET+DEL | 两个并发请求都读到非空、都通过校验 = 幂等被击穿。Redis 单线程模型保证 Lua 脚本里 GET/DEL 原子执行。 |
| SET key value NX EX ttl 一条命令 | SETNX + EXPIRE 是经典反模式:两条命令之间崩溃,锁永远不释放。 |
| 释放锁用 Lua "GET == 自己写值再 DEL" | 业务超时 → 锁自动释放 → 下一个请求拿到锁 → 前一个请求的 finally 删别人的锁。 |
| 看门狗续期 | 业务跑 35s 锁 30s 不再翻车。Redisson 默认实现。 |
| Fencing Token | Redis 主从切换时可能丢锁,下游用单调递增 token 拒绝旧锁持有者的写入(Martin Kleppmann 指出的 Redlock 主要缺陷)。 |
| Polly 熔断 | 网关挂时不雪崩,30 秒失败 N 次直接熔断。 |
| Outbox 表 | 支付完成后异步通知订单服务,不在事务里调远程接口。 |
| 状态机 + 乐观锁 | UPDATE WHERE status=expected 0 行 = 别人已抢先改过。比 SELECT-then-UPDATE 安全。 |
| db.Decimal 不用 double | 钱永远不要做浮点运算。 |
| 配置驱动 | 按用户偏好,密钥、超时、重试次数全部进 appsettings.json,无硬编码。 |
| 锁实现可切换 | Redis:LockProvider = Custom(讲原理)/ Medallion(.NET 界的 Redisson,生产推荐),改配置不改代码。 |
| 网关可切换 | Payment:GatewayProvider = Mock / WeChatPay,接口不变,测试与生产无缝换。 |
| 回调用条件更新做幂等闸门 | UPDATE ... WHERE status=0 返回 0 行即已被处理,纯数据库行锁语义,不依赖分布式组件。 |
| Outbox 抢占式投递 | UPDATE ... OUTPUT + UPDLOCK/READPAST 一条 SQL 完成抢占,多实例部署天然分片;卡死在"处理中"的消息超时自动回收。 |
跑起来
# 1. 起 SQL Server & Redis(docker-compose 推荐)
docker run -d --name pg-redis -p 6379:6379 redis:7-alpine
docker run -d --name pg-sql -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=YourStrong!Passw0rd' -p 1433:1433 mcr.microsoft.com/mssql/server:2022-latest
# 2. 建表
sqlcmd -S localhost -U sa -P 'YourStrong!Passw0rd' -i sql/init.sql
# 3. 跑项目
cd src/PaymentIdempotencyDemo.Api
dotnet run
# 4. 打开 Swagger
# https://localhost:5001/swagger
端到端测试
文章没提、生产必须补的点
| 文章缺什么 | 本项目怎么做 |
|---|---|
| Redis 挂了怎么办 | 唯一索引仍在 → 数据库是最后一道防线(Polly 超时熔断) |
| 锁超时业务还在跑 | 看门狗自动续期到业务结束 |
| 微信回调重发 | /callback/{channel} 端点再走一次幂等 |
| 主从切换丢锁 | Fencing Token,下游用 WHERE token > last_seen 拒绝旧持有者 |
| 网关超时雪崩 | Polly 超时 + 重试 + 熔断 |
| 同步通知订单服务 | Outbox 表 + 后台消费者(事务一致性) |
| HTTP 层幂等 | Stripe 风格 Idempotency-Key 头(Layer 1) |
| 调试可观测 | 结构化日志 + TraceId(ILogger 默认支持) |
跑单元测试
dotnet test # 全部测试,纯内存 Fakes,不依赖 Redis / SQL Server / 网络
覆盖范围:
- 五层防护每层的命中分支(重复不打网关、状态正确回滚)
- 状态机合法转换穷举矩阵(防手滑加宽)
- Outbox 投递成功 / 失败重试 / 未知类型 / 混合批次
- 微信 V3 签名往返、篡改报文拒绝、重放时间戳拒绝、AES-GCM 解密往返
启用微信支付 V3(可选)
appsettings.json:Payment:GatewayProvider改为WeChatPay,填WeChatPay节配置 (AppId / MchId / 商户证书序列号 / APIv3 密钥 / 私钥与平台公钥 PEM 路径)- 私钥 = 商户平台下载的
apiclient_key.pem;平台公钥从/v3/certificates接口获取并导出 PEM - 回调地址
NotifyUrl必须公网 HTTPS 可达,指向POST /api/wechatpay/callback - 回调验签 → AES-GCM 解密 → 条件更新幂等落账,微信重发 24 小时也只会记一次账
Outbox 投递服务
Outbox:Enabled=true 时随宿主启动,参数(轮询间隔/批量/最大重试/退避基数/抢占超时)全部在配置里。 新增消息类型只需实现 IOutboxHandler 并注册 DI,Dispatcher 自动按 MessageType 路由。 旧库升级执行 sql/upgrade-outbox.sql。
后续 TODO
- Outbox 后台 Dispatcher(已实现:BackgroundService 抢占式投递)
- 真实网关 SDK(已实现:微信支付 V3 Native 下单 + 轮询查单 + 回调闭环,配置切换)
- xUnit 单元测试(已实现:五层命中分支 + 状态机矩阵 + Outbox + 微信加解密)
- Redisson 等价物(已实现:Medallion DistributedLock.Redis 适配器,配置切换)
- OpenTelemetry Trace/Meter 接入
- Prometheus metrics:
payment_duplicate_total{layer=...} - 压测脚本(JMeter 100 并发同单,断言成功流水恰 1 条)
面试话术稿:用户连点两次支付按钮,后端怎么保证只扣一次钱?
配套代码:本仓库
PaymentIdempotencyDemo(.NET 8 五层防护完整实现) 使用方式:先背"30 秒版"开场,面试官追问哪层就展开哪层,最后用"反问"收尾。
一、30 秒电梯版(先给结论)
"核心是幂等性——同一笔支付操作,执行一次和执行 N 次的结果必须一致。 我们线上不是靠某一个方案,而是五层防护叠加:请求进来的 Idempotency-Key 快速返回、 Redis Token 原子消费挡住重复提交、分布式锁串行化同一订单、数据库唯一索引兜底、 最后状态机乐观锁保证不会重复扣款。任何一层失效,下一层都能接住, 把重复支付的概率从'依赖前端不连点'降到'几乎为零'。"
要点:先说结论(幂等)、再说架构(多层)、最后给量化的效果。面试官最烦一上来就讲 Redis 命令细节。
二、3 分钟标准版(面试官说"展开讲讲"时)
第 0 层:先定义问题
"重复支付其实有三种形态,方案不一样: ① 用户连点——同一秒内两个一模一样的请求; ② 前端/网关重试——超时后自动重发,间隔几秒; ③ 回调重发——支付渠道(微信)没收到应答会按 15s/15s/30s…重发最多 24 小时。 防住了这三种,才算真幂等。"
五层逐层讲(每层一句话原理 + 一句话为什么)
| 层 | 方案 | 一句话原理 | 为什么单靠它不行 |
|---|---|---|---|
| L1 | HTTP Idempotency-Key |
客户端生成全局唯一 Key,服务端查到同 Key 的记录直接返回上次结果 | 依赖客户端自觉,老客户端可能不带 |
| L2 | Redis Token 原子消费 | 进支付页先领 Token,确认支付时用 Lua 脚本"值匹配才删"一次性消费 | Redis 宕机或主从切换丢数据 |
| L3 | Redis 分布式锁 | SET key value NX EX 串行化同一订单的并发请求 |
锁超时/看门狗失效的极端场景 |
| L4 | DB 唯一索引 | UNIQUE(order_no, channel),重复 INSERT 抛 2627 |
只能"拒绝",没法返回上次结果;且挡不住状态乱序 |
| L5 | 状态机 + 乐观锁 | UPDATE ... SET status=2 WHERE status=1,0 行即冲突 |
只对有状态模型的业务适用 |
收尾
"五层的顺序有讲究:便宜的、能快速返回的放前面(Redis),可靠的放后面(数据库)。 正常流量 99% 在 L1/L2 就短路了,根本碰不到数据库; 只有 Redis 整个挂掉的极端情况,压力才会漏到唯一索引,而索引是数据库原生保证,永远不会失效。"
三、白板图(面试官让你画流程时)
四、追问连环炮(加分的主战场)
Q1:"Token 删了但支付失败,用户重新支付怎么办?"
"这是 Token 方案最经典的坑——Token 一次性消费,支付失败后 Token 没了,用户会卡死。 我们的解法:支付失败(异常/熔断)时回滚订单到 Pending 的同时,让前端重新走'领 Token → 确认支付'流程; 也就是 Token 的生命周期绑定的是'一次支付尝试'而不是'一个订单'。 另外把 Token 的消费从'进入时'推迟到'落库前'也可以,但会牺牲对纯重复提交的拦截速度,我们选前者。"
Q2:"锁超时了业务还没跑完,另一个请求进来了怎么办?"
"三个手段叠加: ① 看门狗续期——拿到锁后起后台任务每 TTL/3 秒检查'锁还属于我'就续期,业务没跑完锁不过期; ② 锁值唯一 + Lua 释放——释放时校验 GET == 自己写入的随机值才 DEL,防止前一个请求的 finally 误删新请求的锁; ③ Fencing Token——每次加锁发单调递增令牌,写下游时带上,下游拒绝旧令牌的写入。 这是 Martin Kleppmann 批评 Redlock 那篇文章的标准答案:锁只能保证'互斥',正确性要靠下游存储自己校验。"
Q3:"微信回调一直重发,你怎么保证不重复记账?"
"微信没收到 2xx 会重发 24 小时,回调端点必须幂等。 我们的处理链:先验签(平台公钥 RSA + 时间戳防重放)→ AES-GCM 解密报文 → 然后用条件更新做闸门:
UPDATE 流水 SET status=成功 WHERE record_no=@r AND status=0, 两条并发回调只有一条能改到 1 行,另一条 0 行直接短路应答成功。 这个幂等不依赖任何分布式组件,纯靠数据库行锁。"
Q4:"为什么不全靠数据库唯一索引?不是最可靠吗?"
"可靠性上唯一索引确实是'永不失效'的底线,但它有两个短板: ① 只能拒绝不能还原——撞索引抛 2627,你还得再查一次才能把上次结果还给用户,体验和实现都绕; ② 把所有并发压力漏到数据库——没有前面几层,每秒几千个重复请求全靠唯一索引硬扛,数据库连接池先崩。 所以正确姿势是:快路径放 Redis,底线放数据库,索引是用来兜住'极小概率窗口'的,不是主防线。"
Q5:"Redis 主从切换,锁丢了怎么办?"
"Redis 主从异步复制,主节点写完锁没同步就挂,从节点晋升后锁消失——两个客户端同时持锁。 ① 业务上靠 Q2 说的 Fencing Token + 下游条件更新挡脏写; ② 部署上要么上 Redlock(多个独立 Redis 多数派),要么干脆用 etcd/ZooKeeper 这类共识存储做锁; ③ 我们 .NET 生产上用 Medallion.Threading(相当于 Java 的 Redisson), 它把这些语义都封装好了。说到底:分布式锁做'性能优化',正确性永远靠数据库层保证。"
Q6:"支付成功了,但要通知订单服务,网络挂了怎么办?"
"不能在支付事务里同步调远程接口——调用失败会回滚本地事务,'钱扣了但订单没更新'。 我们用 Outbox 模式:支付落库的同一个事务里往
td_Outbox表写一条事件, 后台 Dispatcher 轮询抢占、投递到下游,失败指数退避重试,超限进死信人工处理。 保证的是 at-least-once,所以下游消费也必须幂等(逻辑和回调幂等同构)。"
Q7:"熔断打开的时候支付请求怎么办?"
"网关连续失败触发熔断后,新请求快速失败——这时要主动把订单从 Paying 回滚到 Pending, 流水标记失败原因,让用户稍后重试,而不是把订单卡死在'支付中'。 我们用 Polly 嵌套:
熔断(重试(超时))——最外层熔断挡雪崩,中间重试挡抖动,最内层超时防挂死。"
Q8:"怎么验证这套东西真的有效?"
"单测覆盖每一层的命中分支(我们有 xUnit 测试,Fakes 模拟 Redis/DB/网关), 再压测验证:JMeter 并发 100 线程打同一个订单,断言成功流水恰好 1 条、网关调用恰好 1 次。 另外日志里每层命中都打了
IdempotencyHit字段,线上可以直接统计各层拦截比例。"
五、表达加分句式
- "这里我的取舍是……因为……"(展示决策能力,比背方案高级)
- "正常流量在 L1/L2 就短路了,数据库只兜极端场景"(展示对成本的敏感)
- "锁只做互斥,正确性靠下游存储自己校验"(一句话证明读过 Kleppmann)
- "at-least-once 语义下,下游消费幂等和回调幂等是同构问题"(展示抽象能力)
六、常见翻车点(自查)
- ❌ 只说"加个锁/加个唯一索引"——单方案必被追问打穿
- ❌ 把 Token 消费说成"先 GET 再 DEL"——两步非原子,并发下两个请求都通过
- ❌ 忘了释放锁要校验"是自己的锁"——误删别人的锁是高频事故
- ❌ 支付事务里同步调订单服务——网络抖动就是资损
- ❌ 回调不验签——等于把扣款接口暴露给任何人
- ❌ 说"概率为零"——正确说法是"多层叠加后概率几乎为零,且每层失效有观测告警"
七、反问收尾(体现工程成熟度)
"请教一下咱们这边的支付链路:目前重复支付防护主要在哪一层?有没有遇到过 Redis 故障期间 唯一索引扛量的情况?对账系统是怎么发现漏单的?"

浙公网安备 33010602011771号