时序图栏目 ·

同步消息 vs 异步消息:区别、误用场景、箭头画法与判断标准

同步/异步不只是“快慢”的区别,更决定了耦合、失败语义、重试策略与可测试性。本文从研发/测试/架构/写文档视角,讲清 UML 时序图里同步消息与异步消息的含义、箭头画法、回调/事件/队列怎么表达,给出反例修正、评审清单与 FAQ,帮助你把图画到可交付。

你如果在评审文档或画时序图时纠结过这些问题,这篇就是给你的:

  • “这个调用到底算同步还是异步?我看它也是发了请求,然后过会儿才有结果。”
  • “时序图里同步/异步箭头怎么画?实心/空心到底表示什么?”
  • “异步就没有返回消息了吗?那回调、Webhook、MQ 消费算啥?”
  • “我图里画了异步,但测试同学说看不出失败路径;架构同学说语义不一致。”

先把结论放在首屏(你看完就能立刻改图):

  1. 同步 vs 异步的核心不是“快慢”,而是“调用方会不会等待结果(阻塞/占用控制流)”。
  2. 同步消息通常表示“请求-处理-返回”在同一条控制流上闭合;异步消息表示“发出去就继续干别的”,结果通过“回调/事件/轮询/另一路消息”回来。
  3. 在 UML 时序图里,常见约定是:
    • 同步消息:实线 + 实心箭头(调用/操作调用)
    • 异步消息:实线 + 空心箭头(信号/消息发送)
    • 返回消息:虚线 + 空心箭头(可画可不画,但要让读者看得懂“何时结束/何时拿到结果”)

接下来按“定义 → 判断标准 → 画法 → 反例修正 → 场景拆解 → 清单/FAQ”的方式,把同步/异步这件事讲到能落地、能交付。

一句话定义:同步/异步到底指什么

同步消息(Synchronous Message)

你可以把它理解为:

  • 调用方发出请求后,当前控制流会等待被调用方处理完成并给出结果(返回值/异常)。
  • 在实现层面常表现为:线程阻塞等待、协程 await、Future.get()、RPC 同步请求等。

它回答读者的问题是:“我下一行代码要不要等它返回才能继续?”

异步消息(Asynchronous Message)

你可以把它理解为:

  • 调用方发出消息后,不等待对方处理完成;当前控制流继续向下走。
  • 结果(如果有)通常通过“回调/事件通知/消息队列/另一个接口/轮询”回来。

它回答读者的问题是:“我发出去就算交差了吗?后面发生什么靠另一个机制补齐?”

重要提醒:同步/异步描述的是“交互语义”,不是“实现快慢”。一个异步处理可以很慢;一个同步请求也可以很快。

判断标准:别纠结术语,按这 6 条问自己

画图时最容易混乱的是:系统里既有 HTTP/RPC,又有 MQ、事件、回调、定时任务。你只要按下面问题逐条回答,就能确定箭头应该画成同步还是异步。

1)调用方是否等待结果才能继续?

  • → 同步
  • (发出去继续执行)→ 异步

2)是否存在“同一条调用链上的返回值/异常”?

  • 有明确返回值/异常语义(例如“拿 token 失败就直接返回 401”)→ 更像同步
  • 没有返回值,最多是“收到/入队/已受理”→ 更像异步

3)失败语义是什么:失败能否在当前链路里被感知并处理?

  • 失败会在当前调用点抛出/返回错误,调用方当场做降级/重试 → 同步
  • 失败发生在后台处理,调用方只能通过后续通知/对账/补偿发现 → 异步

4)边界在哪里:你画的是“业务结果”,还是“消息已送达”?

  • 你要表达“对方处理完业务并给出结果”→ 同步
  • 你要表达“消息已送达/已落库/已入队,后面异步处理”→ 异步

5)是否存在明显的“队列/事件总线/任务调度器”参与者?

  • 有(Kafka/RabbitMQ/SQS、事件总线、Job Runner)→ 通常异步更贴近真实语义
  • 没有,只是服务间直接调用 → 通常同步更自然(除非你明确是 fire-and-forget)

6)读者是谁:他需要看见“等待关系”还是“解耦关系”?

  • 读者关心阻塞、超时、重试、熔断 → 同步链路画清楚更重要
  • 读者关心事件驱动、最终一致性、补偿 → 异步链路画清楚更重要

把这 6 条写进团队画图规范,能直接减少一半争论。

UML 时序图画法:箭头、返回、激活条怎么配合

不同工具对 UML 细节支持不完全一致,但行业常用的“可读性优先”画法基本一致。你可以把下面当作最小规则集。

同步消息怎么画

常见符号:实线 + 实心箭头(填充箭头)。

建议你同时做好两件事:

  1. 让读者看到“等待关系”
  • 被调用方出现激活条(处理区间)
  • 调用方如果需要强调阻塞,也可以让它的激活条贯穿等待区间
  1. 让读者知道“在哪里结束”
  • 画返回消息(虚线箭头),或者在下一步/片段边界上明确结束点

示例(文字描述):

  • Client -> AuthService: login()(同步)
  • AuthService --> Client: token / error(返回,可选但强烈建议在关键路径画)

异步消息怎么画

常见符号:实线 + 空心箭头(不填充箭头)。

异步画法的关键不是“箭头长什么样”,而是:

  • 把“结果回到哪里”画出来(否则读者不知道系统如何闭环)
  • 把“失败如何被发现/补偿”画出来(否则测试/运维无法落地)

常见的闭环方式:

  1. 回调(Callback / Webhook)
  • ServiceA -> ServiceB: startJob()(异步发起/受理)
  • ServiceB -> ServiceA: jobFinished(event)(异步回调通知结果)
  1. 消息队列(MQ)
  • Producer -> MQ: publish(Event)(异步)
  • MQ -> Consumer: deliver(Event)(异步)
  • Consumer 处理失败:Consumer -> MQ: nack/requeue 或者落 DLQ
  1. 轮询(Polling)(不推荐但很常见)
  • Client -> Service: createTask()(同步拿到 taskId)
  • loop 片段:Client -> Service: getTaskStatus(taskId)(同步轮询)

返回消息要不要画?

经验规则:

  • 关键业务路径建议画:登录、扣款、下单、权限校验、签名校验等。
  • 非关键/读者不关心的返回可以省略:例如“写日志”“发埋点”这类。

你省略返回没问题,但要保证读者仍能判断:

  • 是否阻塞
  • 什么时候结束
  • 失败如何传播

如果省略返回导致这三件事看不出来,就别省。

最常见的 10 个误用:反例 + 怎么改

下面这些“看起来差不多”的画法,会直接影响评审结论和测试用例设计。每条我都给出改法,你可以拿去当团队 review checklist。

误用 1:把“HTTP 请求”画成异步,只因为服务端是异步框架

反例:

  • 你画成空心箭头,暗示调用方不等待。

问题:

  • 调用方是不是等待响应,跟服务端用 Node/Netty/协程没直接关系。

改法:

  • 如果调用方发请求后要等 200/500 才继续 → 画同步消息

误用 2:把“异步消息”当成“延迟执行的同步调用”

反例:

  • A -> B: doSomethingAsync()(空心箭头)
  • 然后紧接着画 B --> A: result(返回虚线)

问题:

  • 你把“异步”画成了“只是慢一点的同步返回”,读者会误解等待关系。

改法:

  • 如果结果要回来:画成 回调(另一条异步消息),或画成“查询接口 + loop 轮询”。

误用 3:异步链路没有“受理确认/幂等键”,测试无法落地

反例:

  • A -> MQ: publish(OrderPaid),然后就结束。

问题:

  • 读者会问:发布失败怎么办?重复发布怎么办?消费者幂等怎么做?

改法:

  • 至少补一个:
    • A 侧的 outbox 落库(同步)
    • 或 publish 失败重试(loop + 退避)
    • 或事件里携带 eventId 并在消费者侧去重

误用 4:把“并行处理”画成同一条生命线上拉很长的激活条

问题:

  • 看起来像“单线程长时间占用”,而不是并行。

改法:

  • par 片段表达并行,或拆出 Worker/Handler 参与者。

误用 5:同步调用链不画超时/重试,导致读者误以为“必然成功”

改法:

  • alt 片段把超时/失败分支画出来:
    • alt success / else timeout / else 5xx
  • 如果有重试:用 loop (max=3, backoff=...)

误用 6:异步回调没有签名校验/鉴权,或者没画出来

问题:

  • 评审时会被直接指出“安全语义不完整”。

改法:

  • 在回调处理链里补步骤:verifySignaturecheckTimestampidempotent

误用 7:把“消息送达”当成“业务完成”

反例:

  • Client -> Service: submit(),返回 200,你就认为完成。

改法:

  • 如果只是受理:返回 202 Accepted + taskId 更准确,并在图里画出后续完成通知/查询。

误用 8:在同一张图里混用两套语义(同步/异步),但不说明边界

问题:

  • 读者不知道哪些步骤是“强一致”链路,哪些是“最终一致”。

改法:

  • alt / 注释把边界写清:
    • “到这里为止是同步强一致路径”
    • “从这里开始是异步最终一致处理”

误用 9:把异步消息画成同步,只为了让图“看起来闭合”

问题:

  • 你用同步箭头 + 返回虚线强行闭环,掩盖了真实的补偿与对账。

改法:

  • 诚实地画:
    • 受理确认(同步)
    • 后台处理(异步)
    • 结果通知/对账/补偿(异步)

误用 10:把“日志/埋点”画得像关键业务同步调用

问题:

  • 图变长、主线被噪音淹没。

改法:

  • 这类通常画成异步(或用注释/旁注),除非它会影响主链路(例如审计写失败必须拒绝交易)。

典型场景拆解:怎么画才“专业且可测”

下面给 3 个常见场景,你可以直接套模板。

场景 1:注册后发欢迎邮件(同步主链 + 异步副作用)

目标:主链路快、可用,邮件失败不影响注册。

推荐画法:

  1. 同步主链:
  • Client -> UserService: register()(同步)
  • UserService --> Client: userId(同步返回)
  1. 异步副作用:
  • UserService -> MQ: publish(UserRegistered{userId,eventId})(异步)
  • MQ -> MailWorker: deliver(UserRegistered)(异步)
  • MailWorker -> MailProvider: sendEmail()(通常同步 HTTP,但属于后台 Worker 的同步链)
  1. 失败补偿(至少画一个):
  • alt send failedMailWorker -> DLQ: moveloop retry

测试同学会据此写用例:

  • 注册成功但邮件失败 → 仍返回 userId
  • 事件重复投递 → MailWorker 幂等

场景 2:前端下单(同步确认 + 异步最终一致)

目标:用户快速得到“已受理”,库存/风控/积分异步处理。

推荐画法:

  • Client -> OrderService: createOrder()(同步)
  • OrderService -> DB: insert(order, status=CREATED)(同步)
  • OrderService --> Client: orderId + status=CREATED(同步返回)

异步处理:

  • OrderService -> MQ: publish(OrderCreated{orderId,eventId})
  • MQ -> RiskService: deliver(OrderCreated)
  • MQ -> InventoryService: deliver(OrderCreated)

结果回流:

  • alt approved:各服务发布 OrderApproved/Reserved
  • else rejected:发布 OrderRejected,OrderService 更新订单状态并通知用户(回调/站内信/轮询)。

关键点:

  • 你要让读者看见“强一致边界”在哪里(创建订单落库)
  • 你要让读者看见“最终一致”怎么收敛(状态机/事件驱动/补偿)

场景 3:支付回调(异步入口,但处理链内部多为同步)

很多人会误解:回调是异步,所以里面都应该画异步箭头。

更准确的表达是:

  • 入口是异步(对你而言你是被调用方)
  • 处理链内部通常是同步步骤(验签、幂等、更新订单、出账、发消息)

推荐画法(简化):

  • PaymentGateway -> CallbackAPI: notify()(对方发起,你是接收方;语义上是“外部异步通知”)
  • CallbackAPI -> CallbackAPI: verifySignature()(同步内部步骤)
  • CallbackAPI -> DB: upsert(payment, unique=tradeNo)(同步幂等)
  • CallbackAPI -> OrderService: markPaid(orderId)(同步或内部调用,按实现)
  • CallbackAPI --> PaymentGateway: 200 OK(同步响应:告诉对方“我收到了”,不等于“全链路都做完了”)
  • CallbackAPI -> MQ: publish(OrderPaid)(异步扩散)

这张图如果画对,测试/运维会很省心:

  • 重复回调、乱序回调、回调重试 → 都能从图里看出幂等点和状态点

写文档/画图的“交付级”检查清单

把下面清单贴到 PR 模板或文档评审标准里,效果立竿见影。

  • 每条跨服务调用都明确是同步还是异步(不要模棱两可)
  • 同步链路中,关键步骤有清晰结束点(返回/片段边界)
  • 异步链路中,结果闭环方式清楚(回调/事件/轮询/对账)
  • 至少有一处失败分支(alt)让读者知道“失败如何传播/补偿”
  • 有重试就画 loop,并写出次数/退避策略(哪怕是文字注释)
  • 有幂等就标明幂等键/去重点(eventId、tradeNo、requestId)
  • 主链路没有被日志/埋点等噪音淹没(必要时用注释或单独一张图)
  • 读者能从图中推导出关键测试用例(超时、重复、乱序、部分失败)

用工具更快把“语义”画清楚(并且图更整洁)

如果你经常需要把这些同步/异步语义画到可交付,我建议你用可视化工具把“规范动作”做成肌肉记忆:

  • 左侧编辑器点选生命线/消息/组合片段(alt、loop、par),不用手写 UML 语法也能快速对齐语义
  • 自动排版:避免箭头交叉、激活条歪斜、片段框乱飞
  • 右侧实时预览:改一处消息类型(同步/异步)就能马上看到读者视角的效果
  • 支持导出 SVG/PNG/JPEG/draw.io,方便放进 PRD、技术方案、测试用例或架构评审材料
  • 需要起草时还可以用 AI 生成先把主干交互铺出来,再手动补齐失败分支和幂等点

你可以直接用这个链接打开并开始画(带上本文的来源标记):

FAQ:同步/异步那些最容易被问住的问题

Q1:异步就一定“没有返回”吗?

不一定。

异步的本质是“当前控制流不等待”。结果可以通过很多方式回来:

  • 回调(Webhook/回调接口)
  • 事件通知(订阅者收到事件)
  • 轮询查询(taskId + status)
  • 未来式结果(Promise/Future,语言层面是异步,但业务上你可能 still await)

画图时的原则是:不要用“返回虚线”假装这是同步闭环;用“另一条消息/另一条接口”表达结果回流。

Q2:Promise/async-await 算同步还是异步?

从编程模型看是异步;但从业务交互语义看,要看你有没有 await

  • await 等待结果 → 对当前流程而言是同步语义(你在等)
  • await,fire-and-forget → 异步语义

所以画图时别纠结语言关键字,回到“是否等待”。

Q3:消息队列投递是同步还是异步?

通常你会看到两层语义:

  1. Producer 投递到 MQ 的动作:可能是同步(等待 broker ack)
  2. 业务处理语义:对业务而言通常是异步(消息入队后,消费者何时处理不由调用方等待)

图里你可以这样画:

  • Producer -> MQ: publish()(同步或异步都行,但要用注释说明“等待 ack / 不等待 ack”)
  • MQ -> Consumer: deliver()(异步)

Q4:我用异步,是不是就不需要画超时/重试了?

更需要。

同步的问题是“超时阻塞”;异步的问题是“重试导致重复、乱序、积压”。

所以异步链路至少要让读者知道:

  • 重试在哪里发生(Producer?Broker?Consumer?)
  • 幂等在哪里做(DB 唯一键?去重表?幂等服务?)
  • 失败最终去哪里(DLQ?人工对账?补偿任务?)

Q5:箭头画法各家工具不一致怎么办?

现实里确实存在差异。

我的建议是:团队先统一“语义约定”,再统一“画法约定”。

  • 语义约定:什么算同步、什么算异步、异步结果如何回流
  • 画法约定:同步用实心箭头、异步用空心箭头、返回用虚线(或省略但要有结束点)

你甚至可以在图的右上角加一行图例(Legend),让新同学一眼看懂。

最后给一个“选择同步/异步”的实用小抄

如果你只是想快速决策,记住这三句话就够了:

  1. 要强一致、要立刻给用户结果 → 优先同步(但要画超时/降级)
  2. 要解耦、要抗峰值、允许最终一致 → 优先异步(但要画幂等/重试/补偿)
  3. 画图时先决定“谁在等谁”,再决定“箭头怎么画”

如果你想把一段文字交互快速变成“规范、可交付的时序图”,可以用时序图生成器先把生命线和消息主干铺出来,再用 alt/loop/par 把失败、重试、并行补齐,然后一键导出到文档里:

反例加餐:画错同步/异步,通常会在工程里“炸”在哪里

很多图画错不是审美问题,而是会直接引出工程后果。下面这些是我在技术方案评审里最常见的“追责点”,你可以对照自己正在写的设计文档改一遍。

反例 1:把“异步受理”当成“业务成功”,导致前端/上游误导用户

典型表现:接口返回 200,页面提示“已成功”,但后台其实只是入队/落库,后续可能失败。

修正做法:

  • 把返回语义改成 已受理(比如 202 + taskId / orderId + status=PROCESSING)。
  • 在时序图里补上最终结果的回流路径:回调通知、站内信、轮询查询、或对账页。
  • 在 alt 分支里明确“失败后状态如何变更、用户如何被告知”。

反例 2:同步链路没画超时边界,读者以为“永远会等到”

典型表现:A -> B -> C 一条直线到底,没有超时,没有降级;测试同学不知道要测什么,运维同学不知道哪里会阻塞。

修正做法:

  • 在关键同步调用旁标注 timeout=xxms(注释即可)。
  • alt timeout 画出:重试(loop)、降级返回、熔断打开、补偿任务入队。

反例 3:异步重试没画幂等点,最终一致变成“最终重复”

典型表现:消费者失败重试后产生重复扣款/重复发货/重复发消息。

修正做法:

  • 在图里明确幂等键:requestId/eventId/tradeNo
  • 标出幂等落点:DB 唯一键、去重表、幂等服务、或状态机的“只前进不回退”。
  • 如果有 DLQ/人工补偿,把终点画出来:失败不是“消失”,而是“可被处理”。

反例 4:把“并行”画成同步串行,导致性能预期和实现偏差

典型表现:你以为是并行处理,图却画成一条同步链;结果上线后才发现 P99 被某个串行步骤拖垮。

修正做法:

  • par 组合片段表达并行(比如同时查库存/查价格/查风控)。
  • 或拆分出 Worker/Handler 生命线,让读者一眼看懂哪些在后台并发执行。

评审/写用例时的“追问清单”(直接贴进文档也行)

  • 同步调用的超时是多少?超时后是重试、降级还是直接失败?
  • 重试在哪里发生?最多几次?退避策略是什么?会不会放大流量?
  • 异步链路如何保证幂等?重复消息、乱序消息怎么处理?
  • 结果如何回流给用户/上游?如果回流失败怎么办?
  • 什么时候算“完成”?是入队、落库、还是业务状态到达某个终态?
  • 可观测性在哪里:traceId/eventId 是否贯穿?关键状态是否有日志/指标?

把这些问题在图里提前回答,你的时序图就不只是“画得像 UML”,而是能真正支撑研发落地、测试设计和运维排障的交付物。