模块之间怎么说话
原创 · 约 29 分钟阅读 · 阅读 --

模块之间怎么说话

作者: 字与码


古董级程序员,大厂出来后一直在创业公司,现在仍在一线写代码、做产品、和 AI 编程工具较劲。更完整的更新会放在微信公众号「字与码」:工作经历、新技术观察、AI 开发实践,以及这些年踩过的坑,都会陆续写在那里。

《AI时代的程序员修养》前面几篇,从程序、数据结构、执行模型讲到并发。接下来要看一个更贴近日常开发的问题:模块之间怎么说话。

这个问题看起来很普通。一个模块要调用另一个模块,不就是写个函数、发个 HTTP 请求、丢条消息,或者查一下数据库吗?但真实系统里,模块通信方式会决定耦合度、事务边界、失败传播、延迟、吞吐、可观测性和后续演进空间。

AI 很擅长补出调用代码,却经常默认选择最顺手的通信方式。你不明确告诉它边界,它就可能把本该异步的事写成同步调用,把本该有契约的接口写成随手传 dict,把本该隔离的模块通过共享数据库绑死。

通信方式就是系统边界

模块通信不是“怎么把数据传过去”这么简单。每一种通信方式都在回答几组问题:

  • 调用方需不需要等结果?
  • 被调用方失败时,调用方怎么办?
  • 两边是不是必须同时在线?
  • 数据是否必须在同一个事务里生效?
  • 接口变更时,谁会被影响?
  • 调用链能不能被观测和追踪?
  • 流量突然变大时,压力落在哪一侧?

常见通信方式可以先放进一张表里:

通信方式典型场景优点主要风险
函数调用同进程模块、领域服务、工具函数简单、快速、类型和测试容易控制模块边界容易被写穿,隐式依赖变多
HTTP / REST跨服务业务接口、开放 API通用、易调试、生态成熟延迟、超时、版本兼容、错误码设计
RPC / gRPC内部高频服务调用、强 schema 场景契约清晰、性能好、代码生成方便调试成本高,服务治理要求更高
消息队列异步任务、削峰、解耦、最终一致调用方不必等,能缓冲流量幂等、重复消费、顺序、死信复杂
事件流审计、数据同步、多个消费者读取可追溯、易扩展消费者schema 演进、重放、offset 管理复杂
共享数据库小系统、历史系统、报表读取实现快,少一层接口强耦合,绕过业务规则,难拆分
文件 / 对象存储大文件交换、批量导入导出适合大对象,成本低生命周期、权限、清理、原子性要设计

这张表不是让人背,而是提醒自己:每次让 AI 写“调用另一个模块”之前,都应该先选通信方式。选错了,代码越多越麻烦。

函数调用:最简单,也最容易把边界写没

同一个进程里,函数调用是最自然的通信方式。它快、直观、容易测试,也最容易被 AI 写出来。

比如一个订单服务里,AI 可能会生成这样的代码:

def create_order(user_id: str, items: list[OrderItem]) -> Order:
    user = user_service.get_user(user_id)
    price = pricing_service.calculate(items, user.level)
    inventory_service.reserve(items)
    order = order_repository.create(user_id=user_id, items=items, price=price)
    notification_service.send_order_created(user.email, order.id)
    return order

这段代码乍看很清楚,但里面至少混了三类操作:

  • 必须在主流程里完成的校验和计算。
  • 需要和订单创建保持一致的库存预占。
  • 可以异步执行的通知。

函数调用的问题不在函数本身,而在边界太容易消失。所有模块都在同一个进程里,调用成本低,AI 就会很自然地把一切串起来。最后一个业务动作变成一条长链,任何一步慢或失败,都拖住整个请求。

更稳的拆法,是先区分同步核心路径和异步副作用:

def create_order(command: CreateOrderCommand) -> Order:
    user = user_policy.require_active_user(command.user_id)
    price = pricing.calculate(command.items, user.level)

    with transaction():
        inventory.reserve(command.items)
        order = order_repository.create(command.user_id, command.items, price)
        outbox.add("order.created", {"order_id": order.id})

    return order

通知不在这里直接发,而是通过 outbox 或消息队列异步处理。函数调用仍然存在,但它只服务主事务内的业务规则。

让 AI 写模块内调用时,可以明确要求:

请把同步核心路径和异步副作用分开。
主流程只包含:校验、价格计算、库存预占、订单创建。
通知、统计、推荐更新写入 outbox,不在请求内直接调用。
请标出每个函数是否允许访问数据库、是否允许外部 I/O。

这比“帮我重构一下”更有约束力。

HTTP 和 RPC:接口不是函数的远程版本

很多系统从单体拆服务时,会把函数调用直接改成 HTTP 调用。函数名变成 URL,参数变成 JSON,返回值变成 response。

这通常会留下隐患。

本地函数调用失败,抛异常就行。远程调用失败,原因可能是超时、连接失败、对方返回 500、网关返回 502、请求发出但响应丢失、对方成功处理但调用方没收到结果。它不是函数调用的远程版本,它是不可靠网络上的协议交互。

一个看起来普通的 HTTP 调用:

def charge(order_id: str, amount: int):
    response = requests.post(
        "https://payment.example.com/charge",
        json={"order_id": order_id, "amount": amount},
    )
    response.raise_for_status()
    return response.json()

如果 AI 只写到这里,基本还停留在 demo。真实系统至少要问:

  • 超时是多少?
  • 失败后能不能重试?
  • 重试会不会重复扣款?
  • 有没有幂等键?
  • 错误码如何分类?
  • 调用方是否能接受未知状态?
  • 对方接口升级时如何兼容?
  • 请求和响应是否记录 trace id?

支付这类接口必须有幂等键:

def charge(order_id: str, amount: int):
    idempotency_key = f"charge:{order_id}"
    response = http_client.post(
        "/charge",
        json={"order_id": order_id, "amount": amount},
        headers={"Idempotency-Key": idempotency_key},
        timeout=3,
    )
    return parse_payment_response(response)

这里的关键不是代码写法,而是协议语义。调用方和服务方都要承认:同一个 Idempotency-Key 的重复请求,只能产生一次扣款效果。

RPC 和 gRPC 也一样。它们有更强的 schema,更适合内部高频调用,但仍然绕不开超时、重试、幂等、限流、熔断、版本兼容。

让 AI 设计远程接口时,可以直接要求它输出契约:

请为这个远程调用设计接口契约:
1. 请求字段、响应字段、必填字段和可选字段。
2. 错误码分类:可重试、不可重试、未知状态。
3. 超时设置和重试策略。
4. 幂等键设计。
5. 版本兼容策略,新增字段和废弃字段怎么处理。
6. 日志和 trace 字段。
7. 调用方在超时后如何查询最终状态。

如果 AI 只给 URL 和 JSON 字段,还不够。远程接口的重点是失败语义。

消息队列:解耦不是丢出去就完了

消息队列经常被用来“异步化”。用户下单后发短信,上传文件后做解析,创建报告后后台生成,调用方把消息丢出去就返回。

这听起来很美,但消息队列不是垃圾桶。它只是把同步问题变成了异步问题。

一个最小消息体可能是这样:

{
  "event_id": "6f01d3b8-5d1d-4df2-a4ce-9bb21a8b22b2",
  "event_type": "order.created",
  "occurred_at": "2026-07-03T10:00:00+08:00",
  "payload": {
    "order_id": "order_123",
    "user_id": "user_456"
  }
}

这里最重要的字段不是 payload,而是 event_id。消费者需要靠它做幂等。

消息队列里常见问题包括:

  • 消息可能重复投递。
  • 消费者处理成功后,提交 offset 或 ack 失败。
  • 消费者处理失败后,消息被反复重试。
  • 消息顺序只在某个 key 或分区内成立。
  • 队列堆积时,延迟越来越高。
  • 消费者版本升级后,老消息 schema 不兼容。
  • 死信队列没人看,失败变成静默积压。

AI 写消费者时,常常只写:

def consume(message):
    data = json.loads(message.body)
    send_email(data["email"], data["template"])
    message.ack()

这段代码缺少幂等,也没有失败分类。更像样的消费者应该先建立处理记录:

def consume(message):
    event = parse_event(message.body)

    if processed_events.exists(event.event_id):
        message.ack()
        return

    try:
        send_email(event.payload["email"], event.payload["template"])
        processed_events.mark_done(event.event_id)
        message.ack()
    except TemporaryError:
        message.retry_later()
    except PermanentError as exc:
        dead_letters.add(event, reason=str(exc))
        message.ack()

真实实现还要考虑事务边界:发送邮件和写处理记录不能天然放在同一个事务里,所以最好让外部动作本身也支持幂等,或者用 outbox/inbox 模式把状态保存下来。

让 AI 写消息队列逻辑时,不要只说“失败重试”。要写清楚:

消费者必须按 event_id 幂等。
同一 order_id 的消息需要按顺序处理。
临时错误最多重试 5 次,指数退避。
永久错误进入死信队列,并记录可人工排查的信息。
消费成功、重复跳过、重试、死信都要打指标。

这些约束会直接改变代码结构。

事件流:不是所有人都要改调用方

消息队列通常偏任务处理,事件流更像事实发布。订单创建、支付完成、用户资料更新、库存变化,这些都可以作为事件被发布出去,多个消费者各取所需。

事件流的好处是扩展性。今天只有一个统计消费者,明天可以加推荐消费者、风控消费者、审计消费者,不一定要改订单服务。

但事件流需要更强的 schema 纪律。

事件应该描述已经发生的事实,而不是命令。order.created 是事实,send_email 是命令。事实可以被多个消费者理解,命令通常只服务某个消费者。

一个事件 schema 可以这样设计:

{
  "event_id": "uuid",
  "event_type": "order.created",
  "schema_version": 2,
  "aggregate_type": "order",
  "aggregate_id": "order_123",
  "occurred_at": "2026-07-03T10:00:00+08:00",
  "producer": "order-service",
  "payload": {
    "user_id": "user_456",
    "total_amount": 12900,
    "currency": "CNY"
  }
}

这里有几个字段很硬:

  • schema_version 用来处理格式演进。
  • aggregate_id 用来做同一对象的顺序和聚合。
  • producer 方便追踪来源。
  • occurred_at 是业务发生时间,不一定等于消息写入时间。

AI 很容易把事件流写成“把当前对象 dump 成 JSON”。这样会泄漏内部字段,也会让消费者绑定生产者的数据库结构。生产者一改表,消费者全跟着坏。

事件 payload 应该是对外稳定的事实模型,而不是内部 ORM 对象。

共享数据库:短期最快,长期最粘

共享数据库是很多系统早期最常见的“通信方式”。A 模块写一张表,B 模块直接读;B 模块需要改状态,也直接 update。

它快,成本低,不需要接口,不需要队列,不需要服务治理。小系统里完全可以用。

问题在于它会绕过业务边界。

假设订单模块有一张表:

orders(id, user_id, status, total_amount, created_at, paid_at)

统计模块直接查它,问题不大。客服模块直接改 status,就危险了。因为订单状态迁移规则可能在订单模块代码里:未支付不能发货,已取消不能支付,退款中不能重复退款。外部模块直接 update 表,就绕过了规则。

共享数据库适合什么?

  • 报表和只读分析。
  • 小系统内部模块。
  • 还没有拆服务的单体。
  • 通过只读视图或汇总表对外提供数据。

不适合什么?

  • 外部模块直接修改核心业务表。
  • 多个服务同时拥有同一张表的写权限。
  • 把数据库表结构当成公共 API。
  • 靠触发器和隐式更新维持业务规则。

如果必须共享数据库,也可以把伤害降下来:

create view order_report_view as
select id, user_id, status, total_amount, created_at, paid_at
from orders;

只开放视图或只读账号,避免别的模块直接依赖全部表结构。更进一步,可以做专门的读模型或同步表,让业务写模型和分析读模型分开。

让 AI 处理共享数据库时,可以明确要求:

其他模块只能读取 order_report_view,不能直接写 orders 表。
订单状态修改必须通过 order_service.change_status()。
请检查代码里是否存在跨模块直接 update 核心表的行为。

这类约束比“保持低耦合”更有用。

选通信方式时,先画失败路径

很多设计图只画成功路径:

用户请求 -> 订单服务 -> 支付服务 -> 通知服务 -> 完成

成功路径当然要画,但真正决定通信方式的是失败路径。

比如订单创建后需要支付和通知:

  • 支付服务超时,订单应该是什么状态?
  • 支付请求发出后网络断了,能不能确认是否扣款?
  • 通知失败是否影响订单创建?
  • 通知重复发送能不能接受?
  • 支付成功事件丢了怎么办?
  • 订单服务重启后,未完成流程怎么恢复?

如果通知失败不能影响订单,就不要同步卡在主链路里。它适合 outbox + 队列。如果支付超时后状态未知,就需要支付查询接口和补偿任务。如果支付请求可能重复,就必须有幂等键。

一个更接近真实系统的流程可能是:

HTTP 请求
  -> 订单服务创建 pending 订单
  -> 调用支付服务,携带幂等键
  -> 支付成功后,订单进入 paid
  -> 写 outbox: order.paid
  -> 通知消费者异步发送消息
  -> 统计消费者异步更新报表

失败路径也要画:

支付超时
  -> 订单标记为 payment_unknown
  -> 后台任务按 order_id 查询支付最终状态
  -> 查到成功则转 paid
  -> 查到失败则转 payment_failed
  -> 长时间未知则进入人工处理队列

这才是通信方式选择背后的工程逻辑。

版本兼容是模块通信的基本礼貌

模块之间一旦通过接口、消息、事件或数据库视图通信,就会有版本问题。

AI 写代码时,常常假设两边同时升级。这在线上系统里通常不成立。服务可能滚动发布,消费者可能落后,移动端可能几周不升级,队列里可能还有旧消息。

几个基本原则很实用:

  • 新增字段尽量可选。
  • 不要随便改字段含义。
  • 不要直接删除消费者仍在使用的字段。
  • 枚举值新增时,旧消费者要能处理未知值。
  • 消息 schema 要带版本。
  • 读旧版本,写新版本时要有迁移窗口。
  • 接口返回字段不应该暴露内部表结构。

比如订单状态新增 payment_unknown。如果旧客户端只认识 pending/paid/cancelled,前端可能直接崩。更好的做法是接口同时返回稳定的展示状态:

{
  "status": "payment_unknown",
  "display_status": "processing",
  "can_cancel": false
}

这样旧客户端至少可以按 display_status 展示“处理中”,而不是把未知枚举当错误。

让 AI 改接口时,可以加一句:

请按滚动发布考虑兼容性:新老服务会同时存在,队列里会有旧消息,旧客户端可能不认识新增枚举。

这句话能挡住很多冒进改法。

可观测性也要跟着通信方式走

模块通信一多,排障就不再是看一个日志文件。

同步调用要有 trace id,能看到请求跨过哪些服务、每一跳耗时多少、在哪里失败。消息队列要有 event id、消费延迟、重试次数、死信数量。事件流要有 offset、lag、schema 版本。共享数据库要能看到慢查询、锁等待和跨模块写入来源。

不同通信方式,至少要留下这些观测点:

通信方式关键观测点
HTTP / RPC请求量、延迟、错误率、超时率、重试次数、trace id
消息队列生产量、消费量、堆积量、消费延迟、重试、死信
事件流offset、consumer lag、schema version、重放进度
共享数据库慢查询、锁等待、连接数、调用来源、表级写入
文件交换文件大小、上传下载耗时、校验结果、清理状态

AI 生成的代码,如果只有业务逻辑,没有这些观测点,出了问题就只能猜。让它补可观测性时,不要泛泛说“加日志”,而要把字段列出来:

每次远程调用日志必须包含:
- trace_id
- caller
- callee
- operation
- request_id 或 event_id
- timeout_ms
- elapsed_ms
- result: success/retryable_error/permanent_error/timeout

结构化日志比一句“调用失败”有用得多。

一份给 AI 的模块通信提示词

写涉及多模块协作的功能时,可以先让 AI 不写代码,只做通信设计:

先不要写代码。

请为这个功能设计模块通信方式:
1. 列出参与模块和每个模块拥有的数据。
2. 区分同步核心路径、异步副作用、只读查询和批处理任务。
3. 为每条交互选择通信方式:函数调用、HTTP/RPC、消息队列、事件流、共享数据库、文件交换。
4. 说明每条交互是否需要等待结果,是否在同一个事务里,失败如何处理。
5. 给出接口或消息 schema,包括版本字段、幂等键、trace 字段。
6. 分析超时、重试、重复调用、乱序、消息丢失、消费者落后的处理方式。
7. 列出不能跨模块直接访问的数据表或内部字段。
8. 给出观测指标和日志字段。
9. 最后再写代码实现计划。

如果 AI 的回答里没有失败路径、幂等、版本兼容和观测字段,说明它还没有真正设计模块通信,只是在生成调用代码。

写代码之前,先问几句话

模块通信方式选得好,系统会清爽很多;选错了,后面每个需求都会带着历史包袱。

动手前可以问自己:

  • 这次交互必须同步等结果吗?
  • 失败会不会影响主流程?
  • 重试会不会造成重复副作用?
  • 有没有幂等键?
  • 接口或消息 schema 是否稳定?
  • 两边能不能独立发布?
  • 消费者落后或下线时会怎样?
  • 数据表是不是被多个模块共同写入?
  • 出问题时能不能按 trace id 或 event id 找到完整路径?

这些问题都不新潮,但它们是真正的工程边界。

AI 可以很快写出 HTTP client、队列消费者、RPC handler、数据库查询和事件发布器。程序员要做的,是先决定这些模块到底该怎么说话,什么时候应该沉默,什么时候应该等待,什么时候应该把事情记下来交给后台慢慢做。

代码会变,边界会留下。