如果这个操作再跑一遍,会发生什么?
你好,我是提米哥,TMDM.cn【开发者专区】的首席选品官。今天不聊工具、不秀代码、不堆术语——我们只聊一个工程师每天都在写,却极少主动问自己的问题:
“如果这个操作再跑一遍,会发生什么?”
这不是脑筋急转弯,也不是面试题。这是你在写第一行 SQL、发第一个 API 请求、配第一个定时任务、消费第一条消息之前,必须按下暂停键问自己的问题。
为什么?因为现实世界里,所有操作都可能运行多次——不是“会不会”,而是“什么时候”。
- 你调支付接口超时了,前端自动重试 → 支付成功了两次
- 你写的夜跑 SQL 脚本半夜失败,运维手动又跑了一次 → 订单表多出 1278 条重复数据
- 你的 Azure Function 处理用户注册,刚写完数据库就崩溃 → 下一秒新实例又处理同一请求 → 用户收到两封欢迎邮件
- 你用 Service Bus 接收订单消息,消费者处理到一半挂了 → 消息被重新投递 → 同一订单又扣款一次
这些不是“小概率故障”,而是分布式系统的默认状态。就像电梯按钮:按一次,电梯来;按十次,电梯还是来——不会来十部电梯。这种“按多少次,结果都一样”的特性,就叫 idempotency(幂等性)。
它不是某个 HTTP header 的名字,不是 Stripe 文档里的一段说明,更不是高级工程师的专属暗号。
它是每个开发者都应该养成的肌肉记忆:在敲下 INSERT、POST 或 Start-AzAutomationRunbook 之前,先想 3 秒——
✅ 如果它成功运行了一次,再跑一次:
→ 数据库会多一条记录吗?
→ 客户会被扣两次款吗?
→ 邮件会发两遍吗?
→ 报表数字会变歪吗?
❌ 如果答案是“会”,那它现在就不安全。
✅ 如果答案是“不会”,恭喜,你已经设计出了幂等操作。
下面用 5 个真实场景,手把手告诉你怎么“让操作天然不怕重跑”——不用高深理论,只讲人话、给方案、附注释:
✅ 场景 1:支付 API —— idempotency key 不是万能的
你以为加个 Idempotency-Key: abc123 就万事大吉?错。关键不在“加没加”,而在“什么时候存、跟谁一起存”。
危险写法(伪代码):
# ❌ 危险!崩溃后钱扣了,但 key 没存进去 → 下次重试还会扣钱
if not key_exists_in_db("abc123"):
charge_result = stripe.charge(...) # ✅ 扣款成功
# 💥 此时服务器突然宕机 → 下面这行根本没执行!
save_key_and_result("abc123", charge_result)
安全写法(核心原则:扣款 + 存 key 必须在一个事务里):
-- ✅ PostgreSQL 示例:用 INSERT ... ON CONFLICT 原子化完成
INSERT INTO idempotency_keys (key, result_json, created_at)
VALUES ('abc123', '{"status":"succeeded","charge_id":"ch_123"}', NOW())
ON CONFLICT (key) DO NOTHING; -- 如果 key 已存在,什么都不做(避免重复扣款)
-- ✅ 然后才真正调用支付网关(或把调用放在事务内)
💡 提米哥划重点:idempotency key 只是手段,原子性才是底线。数据库事务、Redis 的
SET key value NX EX 3600(仅当 key 不存在时设置,带 1 小时过期),都是帮你守住这条线的工具。
✅ 场景 2:每晚跑的 SQL 脚本 —— 别再用 INSERT 了!
你写的 ADF / Airflow / T-SQL 脚本,是不是长这样?
-- ❌ 危险!跑两次 = 两倍数据
INSERT INTO fact_orders (order_id, amount, date)
SELECT order_id, amount, date FROM staging_orders;
换成这句,立刻变安全:
-- ✅ 安全!跑十次 = 和跑一次结果完全一样
MERGE INTO fact_orders AS target
USING staging_orders AS source
ON target.order_id = source.order_id -- ✅ 用业务主键匹配(不是自增ID!)
WHEN MATCHED THEN
UPDATE SET amount = source.amount, date = source.date -- 已存在?就更新成最新
WHEN NOT MATCHED THEN
INSERT (order_id, amount, date) VALUES (source.order_id, source.amount, source.date); -- 不存在?就插入
💡 提米哥说人话:
INSERT是“我要加一行”,MERGE/UPSERT是“我要让这一行存在且正确”。前者怕重跑,后者欢迎重跑。
✅ 场景 3:每 15 分钟跑一次的后台程序 —— 先抢“钥匙”,再干活
Azure WebJob / .NET Console App / Python cron?别让它“默默并发”。用 Azure Blob Lease 当分布式锁:
# ✅ Python 示例(使用 azure-storage-blob)
from azure.storage.blob import BlobServiceClient
blob_service = BlobServiceClient.from_connection_string("your_conn_str")
container_client = blob_service.get_container_client("locks")
lease_blob = container_client.get_blob_client("nightly-enrich-job-lock")
try:
# 尝试获取 60 秒租约(像抢会议室钥匙)
lease = lease_blob.acquire_lease(lease_duration=60)
print("✅ 抢到钥匙!开始干活...")
run_enrichment_job() # 你的核心逻辑
finally:
# 干完活,主动还钥匙(防止死锁)
if 'lease' in locals():
lease.release()
💡 提米哥提醒:锁 ≠ 幂等。锁是预防并发,幂等是容错重试。理想方案是:锁 + 幂等写入(比如 upsert)双保险。
✅ 场景 4:消息队列(Service Bus / Kafka)—— 用业务 ID,别用消息 ID
Service Bus 的“去重”功能只能防 broker 自己发重,防不住消费者崩溃后的重投递。怎么办?
别存 message_id(基础设施 ID),存你业务里的唯一标识:
# ✅ 正确:用订单号作为去重依据(业务意义明确,跨重试稳定)
order_id = json.loads(message.body)["order_id"] # 例如 "ORD-2024-78901"
# 查 Redis:这个订单处理过了吗?
if redis_client.exists(f"processed_order:{order_id}"):
print("🔄 已处理过,跳过")
message.complete()
else:
process_order(order_id) # 执行扣款、发邮件等
redis_client.setex(f"processed_order:{order_id}", 3600, "done") # 保留1小时
message.complete()
💡 提米哥点破:
message_id是“快递单号”,order_id是“你买的 iPhone 型号”。客户投诉时,你说“单号重复”没人懂;你说“iPhone 15 Pro 订单扣了两次”,所有人秒懂。
✅ 场景 5:Azure Functions(Serverless)—— 状态不能存内存,必须放共享存储
Function 实例之间完全隔离。in-memory cache 在这里等于没用。必须用外部存储:
// ✅ C# Function 示例(用 Azure Table Storage 存幂等 key)
public static async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
[Table("idempotency", Connection = "StorageConnectionAppSetting")] CloudTable table)
{
var key = req.Headers["Idempotency-Key"].ToString();
// 查询 Table Storage:key 是否已存在?
var query = new TableQuery<IdempotentResultEntity>()
.Where(TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, key));
var results = await table.ExecuteQuerySegmentedAsync(query, null);
if (results.Results.Any())
return new OkObjectResult(results.Results.First().ResultJson);
// ✅ 不存在?那就干正事,并原子化写入结果(用 ETag 防并发冲突)
var result = await ProcessPayment(req);
var entity = new IdempotentResultEntity(key, result);
await table.ExecuteAsync(TableOperation.Insert(entity));
return new OkObjectResult(result);
}
💡 提米哥总结 Serverless 幂等心法:Compute 是一次性的,Data 是永久的。把“我干过没”这个问题,永远交给数据库、Redis、Table Storage 这类共享存储回答。
🌟 最后送你一句提米哥的硬核口诀(背下来,贴工位):
“所有操作,默认会跑多次;
所有幂等设计,必须始于建模前;
所有重试逻辑,必须文档写清楚:
‘这个接口/脚本/任务,重跑安全,放心重试’。”
别等凌晨 3 点被电话叫醒查“为什么客户被扣了两次款”,才想起翻 Stripe 文档。
就在今天,打开你正在写的那个脚本、API、函数、SQL,停 10 秒,问自己:
👉 “如果它再跑一遍,会发生什么?”
然后,动手把它变成“跑多少遍,结果都一样”。
这才是真正的工程确定性。
直达网址:https://www.thetruecode.com
