一条扣款消息被消费了两次、用户被重复扣了钱,我查遍代码逻辑都没错、消息也确实只发了一条,我对着幂等性缺失和消息重投排查了大半天的复盘

半夜被电话叫醒的线上事故:有用户一笔订单被扣了两次钱。翻遍扣款逻辑没有任何会扣两次的分支,查消息队列确认上游那条扣款消息确实只发了一条。一条消息、一段没错的代码,怎么就扣了两次?排查大半天才理解分布式系统最易被忽略却最致命的设计缺失——幂等性,以及 MQ "至少投递一次"这个我从没认真对待的特性。事故链条是:消费者成功扣款,但 ACK 确认时网络抖动/重启导致 ACK 丢失,MQ 没收到 ACK 就认为没处理成功、重新投递了同一条消息,消费者又扣了一次。根因不是消息发多了也不是扣款代码错了,而是"消费逻辑不幂等"撞上了"MQ 必然存在的重复投递"。这篇从 at-least-once 投递语义与幂等性、去重表/状态机/数据库唯一约束/Redis 让消费幂等的正解、幂等是分布式基本功(消息/接口/超时重试/网络重发处处需要)、几种幂等方案对比、消息消费其他可靠性要点(死信队列/顺序/积压)、决策图与铁律,到附上一个去重表+同事务+自动跳过的可复用幂等执行器封装。核心领悟:网络不可靠打破了单机时代"一次调用=一次执行"的确定性,消息会重复/顺序会乱/结果会未知;重试和幂等必须配对;做分布式是在不确定环境里构建确定正确的结果;把幂等沉淀成对业务透明的基础设施。

一条扣款消息被消费了两次、用户被重复扣了钱,我查遍代码逻辑都没错、消息也只发了一条,我对着幂等性缺失排查了大半天的复盘

那是一个让我半夜被电话叫醒的线上事故:有用户投诉,一笔订单被扣了两次钱。我第一反应是代码有 bug,可翻遍扣款逻辑,清清楚楚、没有任何会"扣两次"的分支;我又去查消息队列,确认上游那条扣款消息确实只发了一条。一条消息、一段没错的代码,怎么就扣了两次?我对着这个诡异的现象百思不得其解。排查了大半天,我才真正理解了分布式系统里那个最容易被忽略、却又最致命的设计缺失:幂等性(Idempotency)——以及消息队列"至少投递一次"这个我从没认真对待过的特性。这篇就把这场"一条消息扣两次钱"的事故,从头复盘一遍。

故障现场:一条消息,处理了两次

先看现场。消息确实只发了一条,可消费端却实实在在处理了两次:

// 我的消费者: 收到扣款消息就扣款 —— 看起来天经地义
@RabbitListener(queues = "deduct_queue")
public void onMessage(DeductMessage msg) {
    // 直接扣款, 没有任何"这条消息是不是处理过"的判断
    accountService.deduct(msg.getUserId(), msg.getAmount());  // ✗ 直接扣!
    orderService.markPaid(msg.getOrderId());
    // 处理完, ACK 确认
}

// 事故是怎么发生的:
// 1. 上游确实只发了 1 条扣款消息(msgId=X, 扣100元)。
// 2. 消费者收到, 成功执行了扣款(用户被扣了100)。
// 3. 但就在它要 ACK 确认"我处理完了"的时候 ——
//    网络抖动 / 消费者重启 / ACK超时, 导致这个 ACK 没成功传回 MQ!
// 4. MQ 没收到 ACK, 就认为"这条消息还没被成功处理", 于是
//    【重新投递】了这条消息(这是 MQ 的"至少投递一次"机制)。
// 5. 消费者又收到了【同一条消息】, 又执行了一遍扣款(用户又被扣了100)!
//    → 一条消息, 因为重投, 被处理了两次 → 重复扣款。

// 现象拼图:
//   - 我以为"消息只发一次 = 只会被处理一次", 大错特错!
//   - MQ 的投递语义, 绝大多数是【at-least-once 至少一次】:
//     它保证"消息不丢"(至少投到一次), 但【不保证"不重复"】!
//     网络抖动、消费者重启、ACK丢失、rebalance... 都会导致【重投】。
//   - 我的消费逻辑【没有幂等性】: 同一条消息处理1次和处理2次, 结果不同
//     (扣了2次)→ 重投就出事。
//   - ★ 根因不是"消息发多了", 也不是"扣款代码错了", 而是:
//     "消费逻辑不幂等" 撞上了 "MQ 必然存在的重复投递"。

看清真相后,我恍然大悟,也惊出一身冷汗。问题的根源,既不是"消息发多了"(确实只发了一条),也不是"扣款代码错了"(逻辑没问题),而是:我的消费逻辑"不幂等",撞上了 MQ"必然存在的重复投递"事故链条是:消费者成功扣了款,但在 ACK 确认时网络抖动/重启导致 ACK 没传回 MQ;MQ 没收到 ACK,就认为"这条消息没被成功处理",于是重新投递了它(这是 MQ 的"至少投递一次"机制);消费者又收到同一条消息、又扣了一次款我犯的根本认知错误是:我以为"消息只发一次 = 只会被处理一次",大错特错!MQ 的投递语义绝大多数是 at-least-once(至少一次)——它保证"消息不丢",但不保证"不重复";网络抖动、消费者重启、ACK 丢失、rebalance 都会导致重投而我的消费逻辑没有幂等性:同一条消息处理 1 次和 2 次结果不同(扣了 2 次),所以重投就出事

第一件事:搞懂"至少一次"投递与"幂等性"

要解决它,得先搞懂两个核心概念:消息投递的语义,和什么是"幂等"。

消息投递语义 与 幂等性

# 一、消息队列的三种投递语义:
#   1. at-most-once(至多一次): 最多投一次, 可能丢。简单但不可靠, 少用。
#   2. at-least-once(至少一次): 保证不丢, 但【可能重复】。最常用!
#      - 它靠"消费确认(ACK)"机制: 消费成功后ACK, MQ才删消息。
#      - 若ACK丢失/超时/消费者挂了, MQ会【重投】 → 重复。
#   3. exactly-once(精确一次): 既不丢也不重。理想但极难实现、代价高,
#      多数MQ"号称"的exactly-once也有前提/限制。
#   ★ 现实: 绝大多数系统是 at-least-once → 你必须假设"消息会重复"!

# 二、什么是幂等性(Idempotency)?
#   - 定义: 一个操作, 执行【一次】和执行【多次】, 结果【相同】。
#   - 幂等的: "把状态设为已支付"(设几次都是已支付)、"删除id=5"(删几次结果一样)。
#   - 不幂等的: "余额减100"(减几次差几百)、"插入一条记录"(插几次多几条)。
#   - 本文的"扣款"(余额-100)就是典型的【不幂等】操作 → 重复执行就出错。

# 三、为什么"at-least-once + 不幂等" = 灾难:
#   - MQ 必然会重复投递(at-least-once)。
#   - 你的操作不幂等(重复执行结果不同)。
#   - 两者一撞: 重复投递 → 重复执行不幂等操作 → 数据错乱(重复扣款)。
#   - 解法只能是: 既然"重复投递"无法避免, 就让"消费逻辑变幂等",
#     让"即使收到重复消息, 也只产生一次效果"。

# 核心: MQ多为at-least-once(保证不丢但会重复); 幂等=执行一次和多次结果相同;
#   不幂等操作(扣款/插入)遇到重复投递就出错; 解法是让消费逻辑幂等, 重复也只生效一次。

原来,这个事故是两个我没认真对待的概念"对撞"的结果。一、消息投递语义:at-most-once(可能丢)、at-least-once(不丢但可能重复,最常用)、exactly-once(不丢不重,理想但极难实现);现实是绝大多数系统是 at-least-once,你必须假设"消息会重复"二、幂等性:一个操作执行一次和多次结果相同;"把状态设为已支付""删除 id=5"是幂等的,而"余额减 100""插入一条记录"是不幂等的——本文的扣款正是典型的不幂等操作。三、为什么"at-least-once + 不幂等"= 灾难:MQ 必然会重复投递,你的操作又不幂等,两者一撞就是数据错乱(重复扣款)而解法的方向也清晰了:既然"重复投递"无法避免,就让"消费逻辑变幂等"——让即使收到重复消息,也只产生一次效果

第二件事:正解——给消费逻辑加上幂等保护

搞懂了原理,正解就清晰了:用唯一业务ID + 去重表/状态机/数据库唯一约束,让同一条消息无论处理几次都只生效一次

// ====== 正解一(推荐): 用"唯一业务ID + 去重表"做幂等 ======
@RabbitListener(queues = "deduct_queue")
@Transactional
public void onMessage(DeductMessage msg) {
    String bizId = msg.getMsgId();   // 唯一业务ID(消息ID或业务流水号)
    // 1. 先尝试"占坑": 往去重表插一条记录(msgId 是主键/唯一索引)
    try {
        dedupMapper.insert(new DedupRecord(bizId));  // 唯一约束保证只能插一次
    } catch (DuplicateKeyException e) {
        // 插入失败 = 这条消息处理过了 → 直接跳过(幂等!)
        log.info("消息已处理过, 跳过: {}", bizId);
        return;
    }
    // 2. 占坑成功(第一次处理), 才执行真正的扣款
    accountService.deduct(msg.getUserId(), msg.getAmount());
    orderService.markPaid(msg.getOrderId());
    // 去重表插入 + 扣款 在同一事务里, 保证"标记已处理"和"扣款"原子性。
}
// → 同一条消息再来: 去重表已有记录, 插入失败 → 跳过, 不再扣款。幂等达成!

// ====== 正解二: 用"状态机"天然幂等(适合有状态流转的业务)======
// 扣款前检查订单状态, 只有"待支付"才扣, 扣完置为"已支付":
@Transactional
public void deduct(Order order) {
    // UPDATE orders SET status='paid' WHERE id=? AND status='unpaid'
    int updated = orderMapper.markPaidIfUnpaid(order.getId());
    if (updated == 0) return;   // 没更新到 = 已经是paid了 → 跳过(幂等)
    accountService.deduct(...);  // 只有第一次(状态从unpaid→paid)才扣
}
// → 靠"状态只能流转一次"实现幂等: 已经paid的, 再处理也不会重复扣。

// ====== 正解三: 数据库唯一约束(让DB帮你挡重复)======
// 给"业务唯一键"加唯一索引: 如 UNIQUE(order_id) on 支付流水表。
// 重复插入支付流水 → 唯一约束报错 → 天然挡掉重复。

// ====== 正解四: 用 Redis 做轻量去重(高频场景, 可配合DB)======
// SET dedup:{bizId} 1 NX EX 86400  → 设置成功才处理, 失败说明处理过。
// (注意: Redis去重要考虑持久化/一致性, 关键业务仍建议DB去重表兜底)

// ====== 关键注意点 ======
// - 幂等的"唯一键"要选对: 用"业务唯一标识"(订单号/流水号), 别用会变的东西。
// - "标记已处理"和"业务操作"要在【同一事务】里, 否则:
//   标记了没执行业务, 或执行了没标记 → 又会出问题。
// - 去重记录要有"过期/清理"策略(别无限增长, 见日志篇的教训)。

// 核心: 用唯一业务ID + 去重表(唯一约束)/状态机/DB唯一索引让消费幂等,
//   标记与业务同事务保证原子; 重复消息因"已处理"被跳过, 无论几次只生效一次。

修复的核心,是"给消费逻辑加上幂等保护,让重复的消息无法产生重复的效果"正解一(推荐):唯一业务 ID + 去重表——先用消息 ID 往去重表"占坑"(msgId 做唯一约束),插入成功(第一次)才执行扣款,插入失败(已处理过)就直接跳过;且去重表插入和扣款要在同一事务里,保证"标记已处理"和"扣款"的原子性。正解二:状态机天然幂等——UPDATE ... WHERE status='unpaid',没更新到行说明已是 paid 就跳过,靠"状态只能流转一次"实现幂等正解三:数据库唯一约束——给业务唯一键加唯一索引,让 DB 帮你挡重复。正解四:Redis 轻量去重(SET ... NX,高频场景,关键业务仍建议 DB 兜底)。关键注意点:幂等的唯一键要选"业务唯一标识"(订单号/流水号)、"标记已处理"和"业务操作"必须同事务、去重记录要有清理策略(别无限增长)归根结底:用唯一业务 ID + 去重表/状态机/DB 唯一索引让消费幂等,标记与业务同事务保证原子;重复消息因"已处理"被跳过,无论几次只生效一次。

第三件事:幂等性是分布式系统的"基本功"

排查后我意识到,幂等性远不止"消息消费"一个场景,它是整个分布式系统的基本功。

幂等性: 无处不在的分布式基本功

# 哪些地方都需要幂等?(凡是"可能重复发生"的地方)
#   1. 消息队列消费(本文): at-least-once 必然重投。
#   2. 接口重复提交: 用户手抖双击、前端重复发请求 → 重复下单。
#   3. 接口超时重试: 调用方超时后重试(其实第一次成功了)→ 重复执行。
#   4. 重试机制: 上一篇讲的重试, 如果操作不幂等, 重试就会重复执行!
#   5. 网络抖动重发: RPC/HTTP 的各种重传。
#   → 共同点: 在分布式/网络环境下, "一个操作被触发多次"几乎不可避免。

# 为什么分布式环境"必然"有重复?
#   - 网络是不可靠的: 请求可能丢、响应可能丢、超时无法判断"到底成没成"。
#   - "超时"是最大的不确定: 你调一个接口超时了, 它【到底执行了没有】?
#     你不知道! 不重试怕没执行, 重试怕执行两次 → 只能"重试 + 让它幂等"。
#   - 所以: 幂等性是应对"网络不确定性"的【必备前提】。

# 幂等设计的通用套路:
#   - 唯一标识 + 去重(去重表/唯一索引/Redis): 认出"这是重复的"。
#   - 状态机: 让状态只能单向流转, 重复操作自然无效。
#   - 乐观锁(版本号/CAS): 基于版本更新, 重复的版本对不上而失败。

# 一句话: 在分布式系统里, "重试/重发"和"幂等"是【必须配对】出现的 ——
#   你要重试(应对失败), 就必须让操作幂等(应对重试带来的重复)。

# 核心: 幂等不止用于MQ消费, 接口防重/超时重试/重试机制/网络重发都需要它;
#   网络不确定(尤其超时)使重复不可避免, 幂等是应对它的必备前提, 与重试必须配对。

排查让我意识到,幂等性是整个分布式系统的基本功,远不止消息消费这一个场景。凡是"可能重复发生"的地方都需要幂等:消息消费(at-least-once 必然重投)、接口重复提交(用户双击/前端重发)、接口超时重试(超时后重试但第一次其实成功了)、重试机制(上一篇讲的重试,操作不幂等就会重复执行)、网络抖动重发它们的共同点是:在分布式/网络环境下,"一个操作被触发多次"几乎不可避免为什么"必然"有重复?因为网络不可靠——请求/响应可能丢、超时无法判断"到底成没成";"超时"是最大的不确定:你调接口超时了,它到底执行了没有?你不知道!不重试怕没执行、重试怕执行两次,只能"重试 + 让它幂等"所以:幂等性是应对"网络不确定性"的必备前提通用套路有唯一标识+去重、状态机、乐观锁一句话:在分布式系统里,"重试/重发"和"幂等"是必须配对出现的——你要重试(应对失败),就必须让操作幂等(应对重试带来的重复)下面这张图,是这次重复扣款的成因与解法:

第四件事:几种幂等方案对比速查

这次踩坑后,我把常见的幂等实现方案整理成一张表,按场景对照着选。

方案 原理 适用 注意
去重表 唯一业务ID插表,插过就跳 通用,最常用 需与业务同事务+定期清理
数据库唯一约束 唯一索引挡重复插入 插入类操作 要捕获唯一键冲突异常
状态机 状态单向流转,只生效一次 有状态的业务(订单) UPDATE...WHERE 旧状态
乐观锁(版本号) 带版本更新,版本对不上失败 更新类操作 需version字段
Redis SETNX 占坑成功才处理 高频/轻量去重 关键业务配DB兜底
Token令牌 提交前领token,用过即失效 接口防重复提交 前端配合

这张表,把幂等的"工具箱"摆全了。选择要点:通用场景用去重表(最常用);纯插入用数据库唯一约束;有状态流转的业务用状态机;更新类用乐观锁;高频轻量用 Redis;接口防重复提交用 Token 令牌它给我的启发是:幂等的本质,其实只有一件事——"认出重复,并让重复无效";而上面这些方案,无非是用不同的机制(去重表认、唯一索引认、状态认、版本认),去完成"认出这是重复操作"这同一件事。理解了这个本质,我就不再死记方案,而是根据业务特点去想:"在我这个场景里,什么是能唯一标识一次操作的东西?我怎么用它,来识别并拦住重复?"更深一层,这让我体会到:分布式系统设计里,很多看似不同的"方案/模式",背后往往是同一个"核心思想"在不同场景下的具体实现;抓住那个核心思想(比如幂等就是"识别并拦截重复"),比记住一堆零散的方案名词,要有用得多透过方案看思想,才能举一反三、随机应变。

第五件事:消息消费的其他可靠性要点

幂等之外,消息消费的可靠性还有几个配套要点,我一并梳理了。

要点 问题 对策
幂等消费 重复投递导致重复处理(本文) 去重表/状态机/唯一约束
消费失败重试 处理失败消息丢了 重试+死信队列(DLQ)
死信队列 反复失败的消息堵塞队列 超过重试次数转DLQ,人工处理
消费顺序 有依赖的消息乱序处理 同key路由到同分区/单线程
消息积压 消费慢,消息堆积 扩consumer/批量消费/限流上游
消息丢失 at-most-once或配置不当丢消息 持久化+手动ACK+生产确认

这张表,把消息消费的可靠性要点串成了一个整体。除了本文的幂等消费,还要考虑:消费失败要有重试 + 死信队列(反复失败的转 DLQ 人工处理,别堵塞队列);有依赖的消息要保证顺序(同 key 路由到同分区);消费慢要防积压(扩 consumer/批量/限流上游);要防消息丢失(持久化 + 手动 ACK + 生产确认)它给我的最大启发是:"用消息队列"这件事,远不是"发消息、收消息"那么简单——它背后是一整套关于"可靠性"的考量:不丢(持久化/ACK)、不重(幂等)、不乱(顺序)、不堵(积压处理)、失败兜底(死信队列)很多人(包括曾经的我)引入 MQ 时,只看到了它"解耦、削峰、异步"的好处,却低估了它带来的"分布式复杂性"——一旦消息在网络中传递,所有分布式系统的不确定性(重复、乱序、丢失、延迟)就都来了这让我领悟到:引入任何一个"分布式中间件"(MQ、缓存、RPC),在享受它带来的能力的同时,都必须认真对待并妥善处理它带来的那一整套分布式问题;"会用"一个中间件的标志,不是会调它的 API,而是懂得如何应对它在分布式环境下的各种异常和边界

第六件事:写"会被重复触发"的逻辑时,我现在的判断习惯

现在每当我写一段"可能被重复触发"的逻辑(消费消息、接口、重试),我都会先过这张图:

这张图的精髓,是"写逻辑前,先假设它会被重复触发,再判断要不要加幂等"第一问 "它可能被重复触发吗":消息消费/接口/会重试的——必然会重复,必须幂等然后问 "操作本身幂等吗":幂等的(设状态/删除)天然安全;不幂等的(扣款/插入/加减)必须加幂等保护加保护时按场景选:有状态业务用状态机、纯插入用唯一约束、通用用去重表;且标记/去重与业务操作必须同事务最后一步是我现在的硬习惯:压测时故意重复投递消息/重复调用接口,看会不会出错(这次的坑正是因为测试时从没模拟过"同一条消息来两次")。这套习惯,让我写这类逻辑时,从"假设它只来一次"变成了"假设它会来很多次"——核心始终是:分布式环境下重复触发不可避免,会被重复触发的不幂等操作,必须加幂等保护。

我立下的几条规矩

这场"一条消息扣两次钱"的事故,换来了我做分布式系统时,刻进骨子里的几条铁律:

  1. MQ 是 at-least-once,消息必然会重复。别假设"发一次=处理一次",重投不可避免。
  2. 不幂等的消费逻辑必须加幂等保护。扣款/插入/加减遇到重投就会数据错乱。
  3. 幂等首选去重表/状态机/唯一约束。用唯一业务ID认出重复并拦截。
  4. "标记已处理"和"业务操作"必须同事务。否则标记了没做/做了没标记又出问题。
  5. 重试和幂等必须配对。你要重试应对失败,就必须幂等应对重试带来的重复。
  6. 幂等是分布式基本功。消息/接口/超时重试/网络重发处处需要它。
  7. 压测要模拟重复触发。故意重投消息/重复调接口,验证只生效一次。

附:一个可复用的幂等消费封装

口说无凭。下面把"去重表 + 同事务 + 自动跳过重复"封装成一个可复用的幂等工具,任何消费/接口都能套:

// 一个通用的幂等执行器: 传入唯一业务ID + 业务逻辑, 自动去重
@Component
public class IdempotentExecutor {

    @Autowired private DedupMapper dedupMapper;

    /**
     * 幂等执行: 同一个 bizId 无论调用几次, 业务逻辑只会真正执行一次。
     * @param bizId  唯一业务标识(消息ID/订单号/流水号)
     * @param action 业务逻辑(只在第一次执行)
     */
    @Transactional   // ★ 去重记录 与 业务逻辑 在同一事务, 保证原子
    public boolean executeOnce(String bizId, Runnable action) {
        try {
            // 1. 占坑: 插入去重记录(bizId 是唯一索引)
            dedupMapper.insert(new DedupRecord(bizId, System.currentTimeMillis()));
        } catch (DuplicateKeyException e) {
            // 2. 插入失败 = 已处理过 → 跳过, 不执行业务(幂等!)
            log.info("幂等跳过, 已处理: {}", bizId);
            return false;
        }
        // 3. 占坑成功(第一次), 执行业务逻辑
        action.run();
        return true;   // 真正执行了
        // 若 action 抛异常 -> 事务回滚 -> 去重记录也回滚 -> 下次重投还能重试。
    }
}

// ====== 用法: 消费消息 ======
@RabbitListener(queues = "deduct_queue")
public void onMessage(DeductMessage msg) {
    idempotentExecutor.executeOnce(msg.getMsgId(), () -> {
        accountService.deduct(msg.getUserId(), msg.getAmount());
        orderService.markPaid(msg.getOrderId());
    });
    // 重复的消息会被自动跳过, 业务方完全不用关心幂等细节。
}

// ====== 用法: 接口防重复提交 ======
public Result submitOrder(OrderReq req) {
    boolean executed = idempotentExecutor.executeOnce(
        req.getRequestId(),   // 前端为每次提交生成唯一requestId
        () -> orderService.create(req)
    );
    return executed ? Result.ok() : Result.ok("请勿重复提交");
}

// 配套: 去重表定期清理(别无限增长)
// DELETE FROM dedup_record WHERE create_time < (now - 7天)  -- 定时任务

// 核心: 把"去重占坑+同事务+重复跳过+失败回滚可重试"封装进 executeOnce,
//   业务只传 bizId + 逻辑, 幂等对业务透明; 异常回滚保证"要么干净跳过要么可重试"。

这个 IdempotentExecutor,把这篇文章的幂等思路,落成了一个对业务透明的可复用工具。它的精妙,在于把"去重占坑 + 同事务 + 重复跳过 + 失败回滚可重试"这套容易写错的逻辑,封装进一个 executeOnce 方法:业务方只需要传入"唯一业务 ID"和"业务逻辑",就自动获得了幂等保护,完全不用关心去重表怎么操作、事务怎么控制尤其那个"action 抛异常则整个事务回滚、连去重记录一起回滚"的设计——它保证了"要么(成功)干净地标记已处理,要么(失败)干净地什么都没留下、下次重投还能重试",避免了"标记了已处理但业务其实失败了"这个最隐蔽的坑。这,正是我想用这个封装,留给每个做分布式系统的人的最后一课:像"幂等"这种"每个会被重复触发的地方都必须做、但又容易做错(忘了事务、选错唯一键、漏了回滚)"的横切关注点,最好的办法是把它抽象成一个统一的、经过验证的工具,让"正确的幂等"成为业务代码"一行调用即得"的能力因为靠"每个开发者在每个消费者里都正确地手写一遍幂等逻辑"是不现实的——人会忘、会写错、会在赶进度时省略;而一个统一的封装,把"做对幂等"的成本降到最低、把"做错的可能"降到最小把分布式的复杂性,沉淀成简单可靠的基础设施,让业务开发者能专注于业务本身——这,是一个成熟架构对团队最大的赋能,也是我从这场"重复扣款"事故里,带走的最有价值的东西。

写在最后

回头看,这场由"幂等性缺失"引发的、一条消息扣两次钱的事故,真正教给我的,远不止"消费消息要做幂等"这一个技巧。它让我对"分布式系统"和"单机程序"之间那道深刻的鸿沟,有了切肤的认识。我栽跟头,是因为我把写单机程序的直觉,带到了分布式的世界里。在单机程序里,"我调用一个函数一次,它就执行一次"是一个天经地义、从不需要怀疑的前提。可在分布式世界里,这个最基本的前提崩塌了:因为有网络、有超时、有重试、有重投,"一次调用"和"一次执行",不再是一一对应的我那段消费代码,放在"消息只会来一次"的单机式假设下,完全正确;可一旦置于"消息可能来很多次"的分布式现实中,它就成了一颗会重复扣款的炸弹。这让我领悟到一个关于分布式系统的根本性认知:分布式系统的复杂性,很大程度上来源于"网络的不可靠"打破了我们在单机时代习以为常的种种"确定性"——消息会重复(打破"只发一次")、顺序会乱(打破"先发先到")、状态会不一致(打破"读到的就是最新的")、调用结果会未知(打破"调用了就知道成没成")所以,做分布式系统,本质上是在"一个充满不确定性的环境里,努力构建出确定性的、正确的业务结果";而幂等、重试、对账、最终一致性这些设计,正是我们用来对抗这些不确定性的"武器"从"假设一切确定"的单机思维,转向"假设一切都可能出错"的分布式思维——这,是我用一次"重复扣款"的事故,换来的、关于架构、也关于"分布式思维"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写消费逻辑时,先问一句"这条消息要是来两次,会怎样?",那我那个被半夜叫醒、对着重复扣款排查的大半天,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我让大模型帮我答用户的专业问题,它一本正经地编了个根本不存在的政策条款,还说得有理有据,我对着大模型的幻觉排查了大半天的复盘

2026-6-2 6:49:13

技术教程

我的 C# 服务跑一段时间就报连接池耗尽、超时连不上数据库,代码看着都正常、连接也都"用完了",我对着 IDisposable 没释放排查了大半天的复盘

2026-6-2 7:00:21

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索