从内存到消息队列:数据交换的进化链

49 阅读 3277 字 · 约 11 分钟

一个系统刚开始的时候,所有数据都在一个进程里。函数调函数,变量传变量——快,简单,不会出错。这时候你不需要什么 REST,不需要什么消息队列,甚至不需要文件。

后来系统长大了。数据量撑破内存,业务逻辑要拆成多个进程,甚至要拆到不同的机器上。这时候你才意识到:数据要跨边界了。跨边界的代价,比你以为的大得多。

从内存开始,到文件,到网络同步调用,再到异步中转——数据交换的每一种方式,都不是设计出来的,都是被前一种的局限逼出来的。

数据交换的进化链:内存内交换 → 文件交换 → 网络同步调用 → 异步中转,每一层都被上一层的局限逼出来

内存内交换:最快的,也是最窄的

内存内交换:同一个进程里函数调用、共享内存,一步寻址

一个四合院里住一家人。厨房在院里,账本在院里,谁要用什么、找谁拿什么,喊一嗓子就到了。

这就是内存内交换。函数调用、共享变量、全局状态——所有数据都在同一个地址空间里,访问就是一步寻址。调用 getUser(id),不是"发请求出去",是程序计数器跳到另一个代码段,取完再跳回来。零网络开销,零序列化,零协议协商。

代价?零。但范围也最窄——出了这个进程就没用。

内存交换最大的好处是"不会失败"。没有网络超时,没有重试,没有消息格式不兼容。数据一致性靠事务——一个 BEGIN 一个 COMMIT,所有操作要么一起成要么一起滚。这是最舒服的一致性模型,以后再也没有这么好的条件了。

具体怎么做的?函数调用——调个方法,参数在栈上,返回值在寄存器里。共享内存(mmap)——两个进程映射同一块物理内存,一个写、另一个立马能读。同一个进程内的 channel、pipe——管道一头写、一头读,同步等待。

这些东西的共同点:它们假设"同一个空间"。同一个地址空间,同一个时间线,调用方和被调方都在一个程序里跑着。这个假设太强了,强到只要一拆进程就不成立。

文件交换:拆了进程,最先想到的不是网络

文件交换:进程 A 写文件、进程 B 读文件,不用同时在线

单进程撑不住了。数据量太大,一个进程装不下。或者业务要拆——订单和用户各跑各的,不拆互相卡。拆了之后怎么办?第一个念头不是网络,是文件。

文件是跨进程最简单的交换方式——连连接都不用建立。一个进程写,另一个读。写完了,文件躺在磁盘上,什么时候读都行。写的人读的人不用同时在线——这是文件比网络调用更根本的优势。

最开始系统集成就是这么干的。财务系统每晚导出 CSV,人力系统第二天早上导入。数据仓库的 ETL,上游把表导成文件,下游用 COPY 批量灌进去。金融机构的清算、供应链的对账,现在还在靠文件。不是他们技术落后,是文件在"解耦时间"这件事上做得太彻底。

技术上一堆格式:CSV 最简单,谁都能打开;JSON 加了个结构,人和机器都读;Parquet 是列存,分析场景读得快;Avro 带 schema,能告诉读的人"这个字段是什么类型"。

但文件的代价也在"简单"里:没有契约。

文件写出来,写的人知道每个字段什么意思——"这个 amount 是分还是元"、"这个 status 的空值是未处理还是已取消"。读的人得猜。猜错了,数据不出错,但逻辑错了。今天改了个字段顺序,明天下游解析就崩——写的人不知道谁在读,读的人不知道什么时候改了。

更致命的是延迟。文件要等写完才能读,要传完才能导。中间每一步都在等——等生成、等传输、等入库。这一等就是分钟级、小时级。对账可以等,实时扣款等不了。

文件被逼出来的地方是"跨进程",逼出下一层的地方是"等不起"。

网络同步调用:不等文件了,问一句答一句

网络同步调用:一问一答,发送方等待结果,等待就是耦合

文件太慢了。问一个问题,要等一整个文件跑完——而且文件里还不一定有你要的答案。

所以换成了网络同步调用:不问文件,问服务。一个请求过去,一个响应回来。问了谁,谁回答。没问的不知道,不关它的事。

这就是 REST、gRPC、GraphQL 的底座。不管协议怎么包装,底层都一样:发送方发起请求,接收方处理完返回结果,发送方等着。等——这个字是关键。发送方没等到回复之前,后续逻辑不往下走。 调用就是等待,等待就是耦合。

同步调用的好处是直观。扣款请给我扣款结果,查余额请返回余额——一问一答,逻辑是线性的,好写,好调试。REST 更简单,HTTP + JSON,人和机器都看得懂,浏览器直接能调。gRPC 更快,Protobuf 二进制序列化,HTTP/2 多路复用,一个连接上能并发发多个请求——适合服务间高频调用。GraphQL 把查询权交给客户端——你要哪个字段就返回哪个字段,不浪费字节。

但同步调用有两个天生的问题:时间和空间。

时间上,调用方在等。被调方慢,调用方跟着慢。被调方挂了,调用方跟着报错——重试、超时、熔断,全是调用方自己兜。一条请求链穿五个服务,每个都等,总延迟是串行累积的。

空间上,调用方要知道被调方是谁。调哪个服务、哪个接口、传什么参数——这些事调用方都提前知道。被调方改了接口,调用方必须跟着改。被调方扩容了,调用方不一定知道。耦合不只是代码层面的,是部署层面的——两个服务必须同时在线。

网络比文件快,但网络会失败。文件不出错——磁盘坏了才出错。网络有超时、丢包、断连、重试、重复——每个都是文件没经历过的问题。你从文件换到网络同步调用,快是快了,但把"可靠性"换成了"复杂度"。

异步/中转:调用方不想等了,也不想知道了

异步中转:发送方经中间人(消息队列/共享表)与多个消费者解耦

同步调用的两个问题——调用方要等,调用方要知道被调方是谁——到了异步这里,全解了。

异步:发送方发出数据就完事,不等结果。 接收方什么时候处理、怎么处理、处理了谁——发送方不关心。消息丢出去,继续往下走。

中转:发送方和接收方之间有个中间人。 发送方不直接找接收方,数据交给中间人——消息队列、事件流、或者一张共享的表。接收方去中间人那里取。发送方不知道谁在取,接收方不知道谁在放。

这就是消息队列(Kafka/RabbitMQ/Pulsar)、事件流(Event Sourcing/CDC)、共享数据库的本质。它们不是具体的协议——具体的协议是 AMQP、是 JDBC、是数据库驱动——它们是一种 模式:解耦时间和空间。

消息队列是推模式。 发送方把消息推进队列,消费者拉走。发送方不知道有几个消费者,消费者不知道有几个发送方。订单服务发出"订单已创建",库存服务接收、发物流的接收、发通知的接收——订单服务不知道谁接了,它也不管。这就是解耦:发送方只关心消息发出去了,不关心谁用它。

共享数据库是拉模式。 两个服务读同一张表——一个写,一个读。没有显式接口,没有序列化,没有协议协商。订单服务写 orders 表,报表服务读 orders 表——报表服务什么时候读、多久读一次,订单服务不知道。数据在流动,但流的方式是数据库在中间当跳板。

推和拉,都是把"直接调用"换成了"中间人"。中间人在,调用就不直接发生。不直接发生,等和知道就不必须。

但异步的代价是:一致性。

同步调用里,数据一致性是事务——一个库,一个 COMMIT,全成或全滚。异步里,数据散在消息、队列、表、多个服务中间——没有一个人替所有人兜底。消息发出去了,但消费者出错了——消息还在队列里,数据不一致了。共享数据库里写了新数据,读的服务读到的是旧数据——中间那段时间差,是异步的代价。

查出错也更难。同步调用,一个请求链断了,顺着链路翻分布式追踪——知道谁调谁、在哪断。异步里,消息发出去了就发出去了——发送方不知道谁在消费、什么时候消费、消费了没有。出了错,你既不知道消息去哪了,也不知道谁处理出错了。

共享数据库更有个隐蔽的代价:隐性约束。 写表的服务知道"这个字段不能为空"、"这个状态不能回滚"。读表的服务不知道——写在代码里,但没人写下来。写的人走了,读的人接手,每一条约束都要自己在排查中撞出来。代码改动小不等于复杂度小——复杂度在那些没人写的隐性约束里。

异步被逼出来的地方是"同步耦合太重",它带来的问题是"一致性没保障"。


每一层交换方式,都在解决上一层的瓶颈,也在为下一层埋种子。

内存装不下→拆进程靠文件。文件等不起→走网络同步调用。同步耦合重→走异步/中转。异步一致性难→这是没有终点的平衡。

选什么交换方式,不是问"哪个更先进",是问 "你现在卡在哪一层"。卡在容量,先拆进程。卡在延迟,别再等文件。卡在耦合,看能不能走异步。卡在一致性,退回去——异步不是非上不可。

没有最优解,只有被上一个撑不住之后的下一个。