JAVA 后端架构 · J04 网络与 RPC · 场景全景
订单服务调库存服务的一次 RPC
用户 1024 下单买 2 件 88 号商品,订单服务要先让库存服务把这 2 件预占下来。代码里只有一行 stub.reserve(req),看着像调本地方法,底下却过了十站:找到库存服务在哪台机器、挑一台、拿一条连接、把对象编码成 7 个字节、装进 HTTP/2 帧、由 Netty 写进内核、过 TCP、在对面被 epoll 叫醒、切到业务线程扣库存,再沿原路回来。任何一站慢了,都由最后一站的 300 ms 超时来兜底,而超时只管客户端,不管对面。这张全景回答:一次远程调用经过了哪十站,J04 的 6 个知识点各管哪一站,以及哪几站最容易出事。
所在:Java 地图 J04 · 网络与 RPC上一站:一个对象从 new 到被回收第一圈:从输入 URL 到页面展示整条链路在本机真跑过 实测
场景:订单 9527 要预占 2 件库存
- 谁
- 交易靶场的订单服务。用户 1024 买 2 件 88 号机械键盘,生成订单 9527,下单前先调库存服务预占库存。
- 部署
- 库存服务两个实例(
:50051、:50052),登记在注册中心的名字inventory下。双方用 gRPC:HTTP/2 + protobuf,传输层是 Netty。JDK 21,gRPC 1.84.1。 - 调用
InventoryService.Reserve(order_id=9527, sku_id=88, qty=2),deadline 300 ms,轮询分到两台。- 期望
- 平时一次调用 1 ms 左右,连接不随并发涨;库存服务慢了,订单服务 300 ms 内给出答复,不把自己的线程拖死;库存只扣一次。
实测 稳定后 gRPC 一次 p50 0.65 ms;50 个线程同时调,gRPC 每台只用 1 条连接,HTTP/1.1 开了 27~46 条。冷启动的第一次调用 312~329 ms,3 次里 2 次超过了 300 ms 的 deadline;慢订单超时以后,库存那边照样扣了 2 件。
实测环境:本机 2 核 ARM,JDK 21.0.12,订单服务和两个库存实例跑在同一个 JVM 里、走本机回环,延迟只看相对大小。另起一个 HTTP/1.1 + JSON 的库存接口(JDK 自带的 HttpServer 和 HttpClient)做对照。完整跑了 3 次。
这几行代码,各在第几站起作用
左边的号是这一行触发的那一站。
2forTarget("registry:///inventory") // 名字,不是地址2 .defaultLoadBalancingPolicy("round_robin")3// 每个实例一条 HTTP/2 长连接,不是连接池1stub = InventoryServiceGrpc.newBlockingStub(ch);4req = ReserveRequest.newBuilder().setOrderId(9527) .setSkuId(88).setQty(2).build(); // 7 字节10stub.withDeadlineAfter(300, MILLISECONDS)1 .reserve(req); // 看着像本地调用9// 库存那边:serverBuilder.executor(bizPool)
完整代码在 study-labs:java/java/pano-j04-order-calls-inventory →,一条命令跑完全部测量。
全景图:十站
时间花在哪:实测的几次调用
50 个线程同时调:各用了几条连接
实测:这十站在输出和帧日志里长什么样
| 做了什么 | 站 | 看到的(原样摘自输出和 Netty 帧日志) |
|---|---|---|
假注册中心 + round_robin,并发调 5000 次 | 2 | 实例 A 调用 2501 次,实例 B 调用 2501 次 |
| 冷启动后的第一次和第二次调用 | 3 | 第一次 329.2 ms,失败 DEADLINE_EXCEEDED(3 次:329、329、312 ms,前两次超时);第二次 8.5~16.6 ms |
| 同一个请求,两种编码 | 4 | protobuf 7 字节: 08 b7 4a 10 58 18 02;JSON 35 字节: {"orderId":9527,"skuId":88,"qty":2} |
打开 NettyClientHandler 的 FINE 日志,调一次 | 5 | OUTBOUND HEADERS: streamId=5005 … :path: /inventory.InventoryService/Reserve … grpc-timeout: 299936u;OUTBOUND DATA: streamId=5005 endStream=true length=12 bytes=000000000708b74a10581802 |
| 同上,看是谁在写 | 6 | 每行日志都出自线程 grpc-default-worker-ELG-3-2(epoll 版的 EventLoop,不是调用方线程) |
HttpServer 不开 TCP_NODELAY(JDK 默认) | 7 | HTTP/1.1 稳定后 p50 41.244 ms, p99 43.860 ms;打开后 p50 0.835~0.907 ms |
| 服务端记下的线程名 | 8、9 | 连接由 grpc-default-worker-ELG-3-1 接下;业务代码跑在 inventory-biz-7 这类自己配的池里 |
| 回程 | 10 | INBOUND HEADERS :status: 200 → INBOUND DATA length=11 → INBOUND HEADERS grpc-status: 0 endStream=true |
| 订单 9528,库存那边要等 500 ms | 10 | 客户端 302 ms 后收到: DEADLINE_EXCEEDED;服务端:等了 500 ms 才干活;这时 Context.isCancelled()=true,库存照样扣了 2 件 |
实验里订单服务和库存服务在同一个 JVM,所以两边共用 gRPC 默认的那组 Netty EventLoop(线程名都是 grpc-default-worker-ELG-3-*)。分开部署时各有各的,结构一样。回环上没有真正的网络延迟,跨机房时每次调用还要加上一个往返(RTT),第 3、7 站会更显眼。
一步一步走
1Stub:远程调用被包装成一个普通方法
InventoryServiceGrpc 是从 inventory.proto 生成的。它的 reserve(req) 不干业务,只做一件事:把“调 /inventory.InventoryService/Reserve 这个方法、参数是 req、deadline 300 ms”包成一次调用(ClientCall),交给通道,然后让当前的下单线程等结果。Dubbo 用动态代理做同一件事,Feign 用接口加注解。
为什么要它业务代码只管“我要预占库存”,不用知道下面是哪种协议、哪台机器。代价也在这里:它太像本地方法了,人很容易忘记它会慢、会超时、会失败一半。
往下一层RPC 原理:序列化、协议、服务发现、负载均衡(④-1 Stub、④-6 为什么不能完全“像本地”)
常出的事在循环里一次次调远程方法(N+1 次 RPC),或者在持有数据库事务和锁的时候调它:对面慢 300 ms,本地的锁就多拿 300 ms。
2发现与负载均衡:inventory 在哪,挑哪一台
通道的目标是 registry:///inventory,一个名字。名字解析器(NameResolver)去注册中心查,拿到 :50051、:50052 两个地址;负载均衡器(这里是 round_robin)给每个地址维护一个子通道,每次调用轮着挑一个。注册中心里的地址变了,解析器推新列表过来,负载均衡器跟着增删子通道。
实测 5000 次并发调用,实例 A 和 B 各 2501 次(含第一次和帧日志那一次)。不配策略时 gRPC 默认是 pick_first:所有调用都去第一个地址。
为什么要它实例会扩容、缩容、挂掉、换 IP。调用方写死地址,每次变动都要改配置、重启;有了发现,地址是活的。
往下一层RPC 原理:服务发现、负载均衡(含坏实例被最少活跃多分流量的实测) · 注册中心本身在 J10 微服务治理
常出的事实例已经挂了,注册中心还没摘掉它(心跳超时要几秒到几十秒),请求继续被分过去;或者忘了配策略,默认 pick_first,一台累死、一台闲着。
3连接:每台一条长连接,第一次要现建
子通道背后是一条 TCP 连接,上面跑 HTTP/2。连接已经在了,这一步就是“在这条连接上开一个新的 stream”,几乎不花时间。第一次调用时还没有:要 TCP 三次握手、发 HTTP/2 的开场白(连接前言 + SETTINGS 帧),再加上 JVM 第一次碰这些代码时加载类、解释执行。
实测 冷启动的第一次 312~329 ms,3 次里 2 次被自己的 300 ms deadline 判了超时;第二次 8.5~16.6 ms。类都加载过以后,再新建一条连接的第一次是 9~20 ms,HTTP/1.1 是 4.5~8 ms。所以冷启动那 300 ms 里,大头是类加载和 JIT 之前的解释执行,不是握手。
为什么要它建连接贵(跨机房一次握手就是一个 RTT,加 TLS 再多一两个),所以连接要复用。HTTP/1.1 靠连接池复用,HTTP/2 一条连接就够。
往下一层HTTP 客户端与连接池:超时怎么配(池子多大、拿连接超时、连接超时、空闲回收;第一圈在 网络 IO 模型) · TCP 握手:TCP:握手、挥手、TIME_WAIT 与粘包 · 类加载和预热:JIT 与逃逸分析
常出的事服务刚发布,流量一来,头几百个请求全部超时,触发熔断,看起来像“新版本有 bug”。修法是启动时先调几次预热、建好连接,再在注册中心上线。另一个坑:长连接被中间的防火墙或负载均衡器悄悄掐了,客户端不知道,下一次调用才发现,要靠 keepalive 探活(gRPC 客户端默认关着)。
4序列化:对象变成 7 个字节
protobuf 按 .proto 里的字段号编码:08 b7 4a 是“1 号字段,变长整数 9527”,10 58 是“2 号字段,88”,18 02 是“3 号字段,2”。不写字段名,整数用变长编码,小数字只占一两个字节。JSON 要把 "orderId" 这些名字每次都带上,还得按文本解析。
实测 同一个请求:protobuf 7 字节,JSON 35 字节。
为什么要它网络上只能传字节。编码越小、解析越快,同样的带宽和 CPU 能跑更多调用;靠字段号而不是字段名,两边可以各自加字段而不互相打断。
往下一层RPC 原理:序列化(protobuf、JSON、JDK 序列化对比,字段兼容规则)
常出的事改了 .proto 里已有字段的编号或类型,旧版本的调用方还在按老编号发,对面解出来是错的值,而且不报错。删字段要把编号保留下来(reserved),不能挪作他用。
5HTTP/2 帧:一次调用 = 一个 stream 上的两个帧
gRPC 把一次调用映射成 HTTP/2 的一个 stream。先发 HEADERS 帧::path 是方法名,grpc-timeout 把剩下的 deadline 告诉对面;再发 DATA 帧:5 字节前缀(1 字节“是否压缩” + 4 字节长度 00000007)加 7 字节消息,共 12 字节,endStream=true 表示请求发完。头部用 HPACK 压缩,重复的头几乎不占字节。
实测 OUTBOUND HEADERS: streamId=5005 … grpc-timeout: 299936u(还剩 299.936 ms),OUTBOUND DATA: … length=12 bytes=000000000708b74a10581802。客户端发起的 stream 编号是奇数,每次加 2,所以 5005 说明这条连接上已经跑过两千多次调用。
为什么要它HTTP/1.1 一条连接同时只能有一个请求在途,想并发就得多开连接。HTTP/2 给每个请求一个 stream 编号,帧可以交错发,一条连接上同时跑很多个请求。
往下一层HTTP/1.1、HTTP/2 与 HTTPS(第一圈)
常出的事中间的代理或网关只会 HTTP/1.1,gRPC 过不去;或者负载均衡器按连接分流(四层),一条长连接把所有请求都钉在同一台后端上,扩容了也分不过去。
6客户端 Netty:EventLoop 线程把字节写进 socket
下单线程不自己写 socket。它把“发这两个帧”的任务交给这条连接绑定的 EventLoop 线程,然后阻塞等结果。EventLoop 依次经过 Pipeline 上的处理器:HTTP/2 编码器把帧变成字节,放进 ByteBuf(Netty 的字节缓冲,常用堆外内存、可以池化复用),最后一次 write 加 flush 交给内核。
实测 帧日志每一行都出自 grpc-default-worker-ELG-3-2,不是调用方线程。名字里的 default 表示用的是 epoll 版(grpc-netty-shaded 自带 Linux 的 epoll 本地库),否则会叫 grpc-nio-worker-ELG。
为什么要它一条连接只归一个 EventLoop 线程管,读写都在这个线程上,就不用加锁;几个 EventLoop 线程就能管成千上万条连接。
往下一层Netty:EventLoop、Pipeline、ByteBuf · IO 模型与 Reactor
常出的事自己写 Netty 处理器时用完 ByteBuf 不 release,堆外内存慢慢漏光,堆的监控却一切正常(呼应 J03 第 10 站:容器只看进程总内存)。
7TCP:可靠的字节流,没有“消息”这个概念
内核把字节放进这条连接的发送缓冲区,切成 TCP 段发出去;对面收到后回 ACK,丢了就重传,乱了就排好序,最后放进对面的接收缓冲区。TCP 只保证字节按顺序、不丢、不重,不管哪几个字节是一条消息:两次写可能被对面一次读到(粘包),一次写也可能分两次才读完(拆包)。所以上层协议必须自己标边界,第 5 站的长度前缀就是干这个的。
实测 对照组的 HTTP/1.1 服务端(JDK 的 HttpServer)默认不开 TCP_NODELAY,稳定后每次调用 p50 是 41.2 ms;打开以后是 0.84~0.91 ms。gRPC 的 Netty 默认就开着,不受影响。
为什么要它网络会丢包、乱序,应用自己处理太难。TCP 把这些藏起来,代价是握手、慢启动,以及 Nagle 算法这类“攒一攒再发”的优化。
往下一层TCP:握手、挥手、TIME_WAIT 与粘包(第一圈在 从 URL 到页面)
常出的事就是上面那 40 ms:Nagle 算法要等上一段的 ACK 才发下一小段,对面的延迟 ACK 又故意攒着不回,两边互等,等到对面的 ACK 定时器到点。请求响应式的小包协议都应该开 TCP_NODELAY。另一个常见坑是短连接调用量大时,主动关闭的一方堆满 TIME_WAIT。
8服务端 Reactor:epoll 叫醒 EventLoop,切出一条消息
库存服务的 EventLoop 线程平时睡在 epoll_wait 上,管着很多条连接。数据到了,内核把这条连接标成“可读”,线程醒来,一口气读出接收缓冲区里的字节,交给 Pipeline:HTTP/2 解码器认出 HEADERS 帧和 DATA 帧,gRPC 再按 5 字节前缀里的长度,从 DATA 里切出完整的 7 字节消息,凑不够就等下一次读。
实测 服务端记下的连接由 grpc-default-worker-ELG-3-1 接手;50 个线程的并发全落在每台一条连接上,也就是一个 EventLoop 线程在读。
为什么要它一个连接一个线程(BIO),一万条连接就是一万个线程,大部分时间在等数据。Reactor 让少数线程只在“有事”的时候干活,这就是 IO 多路复用。
往下一层IO 模型与 Reactor · Netty 线程模型:Netty:EventLoop、Pipeline、ByteBuf · 同一个模型在 Redis 里:Redis 线程模型与 IO
常出的事在 EventLoop 线程上做了耗时的事(查库、同步调别的服务、大 JSON 解析),这个线程管的所有连接一起卡住,表现是“所有接口一起慢,CPU 却不高”。
9业务线程池:反序列化,扣库存
gRPC 把“执行 reserve”这个任务从 EventLoop 交给服务端配置的 executor,这里是一个 16 线程的 inventory-biz 池。业务线程把 7 字节解回 ReserveRequest,扣 88 号商品 2 件,生成 ReserveReply 交回给 EventLoop 去写。不配 executor 时,gRPC 用一个不限大小的缓存线程池。
实测 业务代码跑在 inventory-biz-15 这类线程上,EventLoop 线程只管读写字节。
为什么要它业务可能会阻塞(等行锁、查数据库),不能占着 EventLoop。IO 线程少而快,业务线程多而慢,两边分开,就是 Netty 常说的“IO 线程和业务线程分离”。
往下一层IO 线程和业务线程分离:Netty:EventLoop、Pipeline、ByteBuf(坑 1) · 线程池怎么配:J02 全景:一个下单请求在线程池里
常出的事用默认的不限大小线程池,库存数据库一慢,请求越积越多,线程数涨到几千,最后 OOM 或者被上下文切换拖垮。业务池要有界,满了快速拒绝,让调用方降级。
10回程与超时:结果回来,或者 300 ms 一到就放弃
正常情况:库存服务回一个 HEADERS(:status 200)、一个 DATA(11 字节的 ReserveReply)、一个只有 grpc-status: 0 的 trailers 帧,沿第 8、7、6 站原路回来,下单线程被唤醒,拿到返回值。慢的情况:客户端的 deadline 到了,直接抛 DEADLINE_EXCEEDED,再给对面发一个 RST_STREAM 取消这个 stream。
实测 订单 9528 在库存那边要等 500 ms:客户端 302 ms 后收到 DEADLINE_EXCEEDED。库存服务 500 ms 后醒来,Context.isCancelled() 已经是 true,但业务代码没看它,库存照样扣了 2 件。
为什么要它没有 deadline,对面一卡,调用方的线程就跟着一直等,线程池被占满,故障从库存服务传染到订单服务。gRPC 默认没有 deadline,必须自己配。
往下一层HTTP 客户端与连接池:超时怎么配(四段超时、读超时不是总超时、总 deadline、超时后重试扣两次的实测) · 第一圈在 网络 IO 模型
常出的事“超时”不等于“没做”。客户端以为失败了,重试一次,库存就扣了 4 件。所以重试只能用在幂等的接口上:库存按 order_id 去重,同一个订单扣过就直接返回上次的结果。第二个坑是超时没有逐级变短:网关 1 s、订单 2 s、库存 3 s,上游早放弃了,下游还在白干。
知识点地图
知识点名照抄学习地图。前三个在第一圈(浏览器打开网页)里讲过,这一次是“下一圈”:同样的 TCP、HTTP、IO 模型,换成服务之间的长连接再看一遍。Netty 和 RPC 是新页。
| 站 | 知识点 | 状态 | 下钻顺序和理由 |
|---|---|---|---|
| 3、7 | TCP:握手、挥手、TIME_WAIT、粘包 懂机制 | 有页 · 第一圈在从 URL 到页面 | 1地基:第 5 站的长度前缀、第 7 站的 40 ms,都要先懂“TCP 是字节流” |
| 3、5 | HTTP/1.1、HTTP/2 与 HTTPS 懂机制 | 有页(第一圈) | 2stream 和帧是 gRPC 的骨架;也解释了为什么 HTTP/1.1 要连接池 |
| 6、8 | IO 模型与 Reactor 懂设计 | 有页 | 3先懂 epoll 和 Reactor,Netty 就只是它的一个实现 |
| 6、8、9 | Netty:EventLoop、Pipeline、ByteBuf 懂机制 | 有页 | 4把 3 落到代码上:一条连接归一个线程、处理器链、池化的堆外缓冲 |
| 1、2、4 | RPC 原理:序列化、协议、服务发现、负载均衡 懂机制 | 有页 | 5站在 1~4 的肩膀上,把“像本地调用”拆开;gRPC 和 Dubbo 对照 |
| 3、10 | HTTP 客户端与连接池:超时怎么配 会用 | 有页 | 6放最后:要知道每一站会在哪里卡住,才配得对连接超时、读超时、deadline 和重试;靶场任务的收尾 |
这张全景没走到的
补给站
- 第一圈全景:从输入 URL 到页面展示(DNS、TCP、TLS、HTTP、渲染)。
- 已有的相邻页面:网络 IO 模型、Redis 线程模型与 IO、J02 线程池全景、JIT 与预热。
回到全景
复述练习
- 合上页面,画出 10 站,标出哪几站在订单服务里、哪一站在内核、哪几站在库存服务里。
- 每站用一句话讲它做了什么;再讲清楚那 7 个字节在第 4、5、7、8 站分别变成了什么样子。
- 挑 3 站往下讲两层。比如第 7 站:TCP 为什么会“粘包”?gRPC 怎么切出一条完整消息?Nagle 和延迟 ACK 为什么会凑出 40 ms?
也可以直接讲给我听,说“回到全景”,我对照这张图帮你查漏。
动手:交易靶场这一站
地图上 J04 给靶场的任务:把订单调库存从 HTTP 换成 RPC,比较延迟和连接数;把超时、重试、连接池配对。
- 把
OrderCallsInventory搬进靶场:同一个接口留 HTTP 和 gRPC 两种实现,压一次,记下 p50、p99 和连接数(第 3、5 站)。 - 埋雷:库存接口偶尔慢 500 ms。先不做幂等,开重试,数一数库存多扣了几件;再按
order_id去重修好(第 10 站)。 - 埋雷:在 EventLoop 线程里直接查库,看所有调用怎么一起变慢(第 8 站)。
预测题
1. 库存实例 B 的机器突然断电(没来得及关连接),接下来几秒内订单服务会看到什么?
已经发到 B、还在等的那些调用,要等到各自的 300 ms deadline 才失败,因为没有 FIN 也没有 RST,客户端不知道连接已经死了。新的调用继续被轮询分一半给 B,也是超时,直到 TCP 重传放弃、keepalive 探测失败,或者注册中心摘掉 B。如果开了客户端 keepalive,能更早发现。实例正常下线时不一样:它会发 GOAWAY,客户端立刻把新调用转去 A,一个都不丢。
从第 2、3、7、10 站推出:轮询只看子通道状态,子通道靠连接状态,连接靠 TCP 和 keepalive 才知道对面没了。
2. 把 50 个调用线程加到 500 个,gRPC 和 HTTP/1.1 的连接数各会怎样?
gRPC 还是每台 1 条,多出来的只是 stream(服务端默认不限一条连接上的并发调用数)。HTTP/1.1 的连接数会涨到接近 500:JDK HttpClient 的连接池默认不设上限,一个请求占一条连接。所以 HTTP/1.1 一定要给池子设上限,而 gRPC 要担心的是那一条连接和它的 EventLoop 会不会成为瓶颈。
从第 3、5、8 站推出:实测 50 线程时 27~46 条对每台 1 条。
3. 慢订单超时后,订单服务自动重试一次,第二次很快成功。库存一共扣了几件?
4 件。第一次调用客户端放弃了,但库存服务 500 ms 后照样扣了 2 件(实测);重试又扣 2 件。要扣对,库存接口必须幂等:按 order_id 记下“这个订单已经扣过”,重复的请求直接返回上次的结果;或者业务代码在动手前检查 Context.isCancelled(),但这只能少做,不能保证不做。
从第 9、10 站推出:deadline 只让客户端停止等待,不会撤销服务端已经开始的事。
4. 订单服务刚发布完,前 10 秒的错误率很高,之后自己好了。最可能是哪一站?怎么验证?
第 3 站的冷启动:第一次调用要加载类、建连接,实测要 312~329 ms,超过了 300 ms 的 deadline。验证:看错误码是不是 DEADLINE_EXCEEDED、是不是集中在每个实例启动后的头几次调用。修法:启动时先预热(自己调几次),连接建好以后再在注册中心上线。
从第 3、10 站推出,和 J03 第 2 站(JIT 预热)是同一件事。
下一圈
库存服务挂了一台,下单还要能下
同样的十站,但对面有一台在超时、一台在拒绝。难在把超时、重试、熔断、降级配成一套:哪些错误能重试,重试会不会把对面打得更死,熔断后订单是排队还是直接失败。这是 J10 的全景。
跨机房调用:RTT 从 0.05 ms 变成 30 ms
回环上看不见的东西全冒出来:每次握手多 30 ms,TCP 慢启动让大响应分好几个来回,丢包重传把一条 HTTP/2 连接上的所有请求一起拖慢(TCP 层的队头阻塞)。难在判断什么时候该用多条连接,什么时候该换 HTTP/3。