Skip to content

gRPC Java内部原理与生产治理

gRPC生成的Stub让远程调用看起来像Java方法,但一次调用实际要经过生成代码、拦截器、Channel、名称解析、服务配置、负载均衡、Subchannel、Transport、HTTP/2 Stream、服务端调度、业务事务和Trailers。只记住“gRPC基于HTTP/2和Protobuf”无法解释为什么调用会先排队、为什么一次逻辑RPC会出现多个物理Attempt、为什么Deadline到了服务端仍可能提交,以及为什么Kubernetes扩容后流量仍集中。

本章以可复核版本为样本:

技术线Java / SpringgRPC Java说明
JDK 7遗留线Java 71.41.x维护分支官方主线已不再直接支持Java 7,只适合被迫维护
JDK 8存量线JDK 8、Boot 2.7.x官方支持Java 8的兼容版本协议原理相同,代码不能使用Record、文本块、Map.of
现代线Java 17+、Boot 3.x1.82.2源码样本本章源码对象与可运行Demo基线

截至2026-07-19,Maven Central元数据中的gRPC Java发布版为1.82.2,官方对应README声明支持Java 8及以上,并给出Protobuf 3.25.8代码生成样本。版本会继续演进,因此“1.82.2”表示本文可复核样本,不是让未来项目永远锁死;升级时必须重新检查Release Notes、生成插件、Protobuf、Netty和观测组件。

一、学习目标

学完后应能独立回答:

  1. .proto怎样生成MethodDescriptor、Stub和服务端绑定代码。
  2. ManagedChannel为什么不是一条固定TCP连接,也不应每次请求创建。
  3. NameResolver、Service Config、LoadBalancer、Picker、Subchannel怎样协作。
  4. 地址尚未解析或连接尚未READY时,调用为什么可能进入DelayedTransport等待。
  5. ClientCall、逻辑RPC、RetriableStream、Substream和HTTP/2 Stream的关系。
  6. Deadline怎样从CallOptions与Context取更早值,取消为什么只是协作式信号。
  7. 客户端回调为什么按单调用串行化,却不代表所有RPC共用一个业务线程。
  8. Netty EventLoop、gRPC Call Executor和业务数据库线程分别负责什么。
  9. 服务端怎样查找方法、创建Context、执行拦截器并回调业务实现。
  10. 流控、半关闭、GOAWAY、Keepalive、最大消息和优雅停机怎样影响生产。
  11. 如何实现写请求幂等、观测物理Attempt并处理结果未知。
  12. 怎样排查UNAVAILABLE、DEADLINE_EXCEEDED、INTERNAL、RESOURCE_EXHAUSTED和负载倾斜。

二、先建立源码分层

官方README将gRPC Java高层划分为Stub、Channel和Transport;生产系统还必须补上业务层:

mermaid
flowchart TD
    A["业务层:事务、幂等、权限"] --> B["Stub层:类型安全调用方式"]
    B --> C["Channel层:拦截、解析、LB、Call"]
    C --> D["Transport层:Netty与HTTP/2字节"]
典型对象负责什么不负责什么
业务层Service、Repository、状态机事务、幂等、事实查询、补偿网络连接和HTTP/2帧
Stub层生成的Blocking/Async/Future Stub参数类型、调用模式、适配ClientCalls地址解析和TCP连接
Channel层ManagedChannelImplClientCallImplResolver、LB、配置、Call、重试数据库提交原子性
Transport层NettyClientTransport、Netty HandlerTLS、HTTP/2、Frame、Socket I/OProtobuf业务语义

io.grpc.internal以及标记为@Internal的类用于理解实现和排查堆栈,应用代码不应直接依赖。内部类在后续版本可能变化;稳定编程面应优先使用io.grpc公开API。

三、源码对象职责地图

对象作用出问题时常见现象
生成的InventoryServiceGrpc保存MethodDescriptor,创建Stub并绑定服务方法方法名、序列化器、调用模式不匹配
ClientCalls把Blocking/Future/Async调用适配到ClientCall阻塞等待、请求消息数量与Observer适配
ManagedChannelImpl管理Resolver、LB、连接状态、调用入口和空闲状态解析未完成、Channel处于IDLE或TRANSIENT_FAILURE
NameResolver将target URI解析成地址快照和Service Config地址为空、DNS缓存、配置解析失败
LoadBalancer / SubchannelPicker维护Subchannel并为调用给出PickResult无READY后端、drop、buffer或选址倾斜
DelayedClientTransportPicker还不能给出Transport时暂存调用并等待重处理无Deadline时等待调用越来越多
InternalSubchannel管理一组地址、真实Transport和连接状态CONNECTING循环、退避、READY后断连
ClientCallImpl应用Deadline、压缩、消息限制并驱动ClientStreamDeadline已过、取消、回调线程问题
RetriableStream为一次逻辑RPC管理多个物理Substream重试缓冲、Attempt放大、winner提交
NettyClientTransport建立Netty HTTP/2客户端连接TLS、GOAWAY、连接拒绝、EventLoop阻塞
ServerImpl接收新Stream、查方法、创建Context并切到应用ExecutorUNIMPLEMENTED、执行器排队、Deadline传播
ServerCallImpl请求消息、响应Headers/Message/Trailers漏发Headers、重复close、消息过大
ServerCalls将ServerCall适配为生成的业务方法和StreamObserverUnary半关闭、流式请求节奏和自动流控
SerializingExecutor保证单个调用的回调串行且避免并发重入慢回调阻塞同调用后续事件

四、.proto究竟生成了什么

假设契约:

protobuf
syntax = "proto3";

package inventory.v1;
option java_multiple_files = true;
option java_package = "com.example.inventory.v1";

service InventoryService {
  rpc Reserve(ReserveRequest) returns (ReserveReply);
}

message ReserveRequest {
  string request_id = 1;
  int64 sku_id = 2;
  int32 quantity = 3;
}

message ReserveReply {
  string reservation_id = 1;
  string result = 2;
}

代码生成不是只生成两个DTO,还会生成:

  • ReserveRequestReserveReply及Builder、Parser、字段编号常量。
  • InventoryServiceGrpc.getReserveMethod()返回的方法描述符。
  • InventoryServiceBlockingStub
  • InventoryServiceFutureStub
  • InventoryServiceStub异步入口。
  • InventoryServiceImplBase服务端基类。
  • bindService()构建ServerServiceDefinition与方法处理器。
mermaid
flowchart TD
    A["inventory.proto"] --> B["protoc生成消息类"]
    B --> C["protoc-gen-grpc-java生成RPC类"]
    C --> D["MethodDescriptor与三类Stub"]
    D --> E["ImplBase与ServerServiceDefinition"]

4.1 MethodDescriptor保存什么

每个RPC方法描述符至少表达:

  • 完整方法名,例如inventory.v1.InventoryService/Reserve
  • 调用类型:UNARY、SERVER_STREAMING、CLIENT_STREAMING或BIDI_STREAMING。
  • 请求Marshaller。
  • 响应Marshaller。
  • 是否适合安全重试等可选Schema信息。

Stub调用时不是每次反射扫描.proto。生成代码持有静态、延迟初始化的方法描述符,调用过程直接使用它序列化请求和解析响应。

4.2 三类Stub最终都进入ClientCall

生成Blocking Stub的Unary方法本质上类似:

java
return ClientCalls.blockingUnaryCall(
        getChannel(), getReserveMethod(), getCallOptions(), request);

异步Stub调用ClientCalls.asyncUnaryCall,Future Stub调用futureUnaryCall。三者等待方式不同,但都会通过Channel.newCall(MethodDescriptor, CallOptions)创建ClientCall

Blocking Stub不是“服务端同步执行”的意思,只是调用线程阻塞等待;网络I/O仍由Transport事件循环处理。Async Stub也不等于业务自动非阻塞,如果回调中执行慢SQL,照样会占用应用Executor。

4.3 bindService()为什么能找到业务方法

InventoryServiceImplBase.bindService()建立:

text
完整方法名
  -> MethodDescriptor
  -> ServerCallHandler
  -> MethodHandlers方法分发
  -> reserve(request, observer)

服务启动时将该ServerServiceDefinition加入Server注册表。请求到达后,ServerImpl按完整方法名查找定义;找不到就返回UNIMPLEMENTED,而不是进入Spring Controller映射。

五、一次Unary客户端调用的源码主链

mermaid
flowchart TD
    A["Blocking Stub调用Reserve"] --> B["ClientCalls适配ClientCall"]
    B --> C["Channel与客户端拦截器"]
    C --> D["RealChannel选择方法配置"]
    D --> E["ClientCallImpl.start"]
    E --> F["Picker选择Subchannel Transport"]
    F --> G["创建ClientStream并写HTTP/2"]

5.1 Stub准备CallOptions

withDeadlineAfterwithWaitForReadywithCompressionwithMaxInboundMessageSize等不会立刻发网络请求,而是返回带新CallOptions的Stub。Stub通常是轻量不可变包装,可安全按调用派生,底层Channel继续复用。

5.2 Channel先经过客户端拦截器

ManagedChannelImpl.newCall()委托给拦截后的Channel。客户端拦截器可以包装ClientCall,常用于:

  • 加入认证Metadata。
  • 恢复或创建Trace上下文。
  • 指标、日志和审计。
  • 统一Deadline上限。

拦截器顺序会影响Header覆盖、计时范围和异常观察。不要在拦截器中读取完整医疗数据、密码、Token或大请求体写日志。

5.3 RealChannel为什么可能先产生PendingCall

1.82.2样本中,如果首次名称解析和配置选择尚未完成,RealChannel.newCall()会触发退出IDLE,并可能创建PendingCall

mermaid
flowchart TD
    A["新调用到达"] --> B["检查配置选择器"]
    B --> C["尚未解析则退出IDLE"]
    C --> D["建立PendingCall"]
    D --> E["解析完成后reprocess"]
    E --> F["创建真实ClientCallImpl"]

这解释了为什么线程栈看不到真正Socket发送但请求已经在Channel中等待。若没有Deadline,Resolver故障会让Pending调用持续占用内存。

六、ClientCallImpl.start()做了什么

源码主步骤:

  1. 校验Call只能start一次、未提前取消、Listener和Metadata非空。
  2. 若当前Context已取消,不创建真实Stream,异步通知关闭。
  3. 应用Service Config中的方法级timeout、waitForReady、消息大小和重试策略。
  4. 查找压缩器并准备grpc-encoding、可接受编码等Headers。
  5. 计算有效Deadline。
  6. 通过ClientStreamProvider.newStream()创建底层Stream。
  7. 设置Authority、最大消息、Deadline、压缩与解压配置。
  8. 启动Stream Listener。
  9. 安装Deadline定时取消和Context取消监听。

6.1 为什么有效Deadline取更早值

ClientCallImpl.effectiveDeadline()在CallOptions Deadline与当前Context Deadline之间取更早者。方法级Service Config timeout也只能缩短已有Deadline,不应把上游剩余预算重新放大。

mermaid
flowchart TD
    A["入口剩余Deadline 700ms"] --> B["方法配置timeout 1000ms"]
    B --> C["有效值仍为700ms"]
    C --> D["传输和服务端只使用剩余预算"]

如果调用开始时Deadline已经过去,源码创建FailingClientStream并返回DEADLINE_EXCEEDED,不会为了“试一下”再建立真实调用。

6.2 取消为什么不能保证数据库回滚

Deadline定时器或Context取消最终会调用stream.cancel,Transport可发送RST_STREAM等信号;但服务端线程可能已进入JDBC,数据库也可能已经提交。取消是“不要继续浪费工作”的协作信号,不是跨进程事务回滚。

写操作收到DEADLINE_EXCEEDEDCANCELLED后必须用稳定request_id查询事实,而不是自动生成新ID重试。

七、回调线程与SerializingExecutor

ClientCallImpl若不是直接Executor,会用SerializingExecutor包装调用Executor。它保证同一个Call的onHeadersonMessageonCloseonReady按顺序串行执行,避免回调并发重入。

这不表示:

  • 所有RPC只在一个线程执行。
  • 网络I/O与业务回调在同一线程。
  • 回调可以无限阻塞。
  • StreamObserver在任意线程任意并发调用都安全。

一个回调执行慢任务,会延迟该Call后续消息和关闭通知。应把长CPU、阻塞文件和慢数据库工作转移到受控业务Executor,同时保持上下文传播和有界队列。

八、Target URI、NameResolver与Service Config

ManagedChannelBuilder.forTarget("dns:///inventory.example")中的target不是普通URL请求地址。Channel按URI scheme选择NameResolverProvider;Resolver返回:

  • EquivalentAddressGroup地址集合。
  • 地址和解析级Attributes。
  • 可选Service Config。
  • 可选配置选择器。
mermaid
flowchart TD
    A["target URI"] --> B["按scheme选择NameResolver"]
    B --> C["解析地址与Service Config"]
    C --> D["ManagedChannel校验并保存配置"]
    D --> E["LoadBalancer接收地址快照"]

8.1 地址快照不是单个IP字符串

一个EquivalentAddressGroup可以包含一组等价地址与Attributes,例如Authority、地域、安全和代理信息。自定义Resolver应发布不可变完整快照,并在变化时重新通知;不要边遍历边修改共享List。

8.2 Service Config解决什么

Service Config可为服务或方法提供:

  • 负载均衡策略。
  • timeout。
  • wait-for-ready。
  • 最大请求/响应大小。
  • retry policy或hedging policy。
  • retry throttling。

Resolver地址成功不代表Service Config一定有效。1.82.2样本会在首次配置无效且没有默认配置时向LB传播错误;已有有效配置后,新配置解析失败可能继续使用上一个配置。生产要监控“地址解析状态”和“配置版本/错误”两类信号。

九、LoadBalancer、Picker与PickResult

LoadBalancer不是每次RPC临时扫描所有IP。它接收地址与连接状态,维护Subchannel,并向Channel发布SubchannelPicker。新调用使用当前Picker快速选取结果。

Picker可能返回:

PickResult含义调用表现
可用Subchannel找到候选在其READY Transport上创建Stream
无结果当前无法决定调用可暂存等待Picker更新
error当前失败fail-fast通常失败,wait-for-ready可能继续等
drop控制面明确丢弃不应被wait-for-ready无限掩盖

9.1 DelayedClientTransport为什么存在

地址还没解析、Subchannel仍CONNECTING或Picker尚无可用Transport时,Channel不能凭空发送。DelayedClientTransport保存调用参数与延迟Stream;新的Picker发布后执行reprocess,再次Pick并把延迟Stream切换到真实Transport。

mermaid
flowchart TD
    A["调用需要Transport"] --> B["Picker暂时无可用结果"]
    B --> C["DelayedStream进入等待集合"]
    C --> D["Resolver或连接状态更新Picker"]
    D --> E["reprocess再次选择"]
    E --> F["绑定真实Transport或失败"]

Wait-for-Ready允许在Deadline内等待恢复;它不是持久队列。Channel长时间不可用且没有Deadline,会堆积请求对象、Metadata和响应Listener。

十、Subchannel与连接状态机

InternalSubchannel初始为IDLE;需要调用时尝试连接地址,典型状态是:

mermaid
flowchart TD
    A["IDLE"] --> B["CONNECTING"]
    B --> C["READY"]
    C --> D["断连或建连失败"]
    D --> E["进入IDLE或TRANSIENT_FAILURE"]
    E --> F["退避后重新发起连接"]

源码会遍历地址组中的地址。全部连接尝试失败后进入TRANSIENT_FAILURE并按BackoffPolicy重连;Resolver刷新地址时,如果当前地址消失,READY Transport会被有界延迟关闭并重新选择。

10.1 Channel状态不等于业务健康

READY只说明某个Transport可用,不证明:

  • 方法一定存在。
  • 身份有权限。
  • 数据库健康。
  • 请求消息兼容。
  • 业务不会返回失败。

同样,TRANSIENT_FAILURE描述Channel连接层状态,不能直接把订单标记为失败;写操作仍需事实查询。

10.2 pick_firstround_robin

pick_first倾向在地址序列中建立可用连接并优先使用;round_robin在多个READY Subchannel之间为RPC选择。二者都依赖Resolver真的返回多个Endpoint。如果只返回一个Kubernetes ClusterIP,客户端看不到Pod列表,策略名称改成round_robin也无法按Pod选择。

十一、从Subchannel到Netty HTTP/2 Transport

Picker选出Subchannel后,Channel取得其READY ConnectionClientTransport,创建ClientStream。Netty实现大致负责:

  1. 创建或复用EventLoop与Channel。
  2. TCP连接。
  3. TLS握手与ALPN协商HTTP/2。
  4. 发送HTTP/2 Preface与SETTINGS。
  5. 为RPC分配HTTP/2 Stream ID。
  6. 将gRPC Headers、长度前缀消息与Trailers编码为Frame。
  7. 将入站Frame还原为gRPC Stream事件。
mermaid
flowchart TD
    A["ClientStream"] --> B["NettyClientTransport"]
    B --> C["TCP、TLS与HTTP/2连接"]
    C --> D["为RPC创建HTTP/2 Stream"]
    D --> E["HEADERS与DATA Frame"]
    E --> F["服务端Netty Handler"]

使用grpc-netty-shaded可以降低应用与Netty版本冲突概率;它不是“没有Netty”,而是对依赖做重定位。若使用未shaded的grpc-netty,要统一Netty BOM并检查其他框架传递依赖。

十二、逻辑RPC、物理Attempt与RetriableStream

最容易混淆的关系:

text
一次业务请求
  -> 一次逻辑ClientCall
     -> 一个RetriableStream
        -> 一个或多个Substream Attempt
           -> 每个Attempt对应一个Transport HTTP/2 Stream

12.1 为什么重试需要缓冲请求操作

在winner提交前,RetriableStream要让新Substream重放start、request、消息、flush、halfClose、压缩和Deadline等操作,因此维护BufferEntry。请求或并发重试过大时会占用内存;重试不是零成本。

12.2 commit(winningSubstream)是什么意思

当某个Attempt达到不可再切换的提交点后,commit选择winner:

  • 取消计划中的retry与hedging。
  • 取消其他不需要的Substream。
  • 后续操作直接交给winner。
  • 最终只向上层Listener报告一条逻辑结果。

“只报告一条结果”不等于服务端只执行一次。其他Attempt可能已经到达不同实例并提交副作用,因此Hedging只适合只读或严格幂等操作。

12.3 透明重试与配置重试

  • 透明重试用于特定传输失败窗口,框架根据RPC是否到达服务端处理阶段等进度判断。
  • 配置重试由Service Config声明最大尝试、可重试Status和退避。
  • 服务端可用grpc-retry-pushback-ms影响客户端后续重试节奏。
  • retry throttling可在失败过多时抑制重试,防止雪崩。

不能把“gRPC支持重试”理解成所有UNAVAILABLE都一定自动重试。是否启用、是否已提交、Status、Policy、Buffer限制、Throttle和Deadline都会改变结果。

12.4 Service Config重试示例

json
{
  "methodConfig": [
    {
      "name": [
        {
          "service": "inventory.v1.InventoryService",
          "method": "Reserve"
        }
      ],
      "timeout": "0.8s",
      "waitForReady": false,
      "retryPolicy": {
        "maxAttempts": 3,
        "initialBackoff": "0.05s",
        "maxBackoff": "0.2s",
        "backoffMultiplier": 2.0,
        "retryableStatusCodes": ["UNAVAILABLE"]
      }
    }
  ]
}

maxAttempts包含第一次,3表示最多三个物理Attempt。写接口即便只重试UNAVAILABLE,也必须幂等,因为“服务端提交后响应连接断开”仍可能表现为UNAVAILABLE。

十三、服务端从HTTP/2 Stream到业务方法

mermaid
flowchart TD
    A["NettyServerHandler收到新Stream"] --> B["ServerImpl.streamCreated"]
    B --> C["解压配置与方法查找"]
    C --> D["创建Deadline与可取消Context"]
    D --> E["切换到应用SerializingExecutor"]
    E --> F["拦截器链与ServerCallHandler"]
    F --> G["生成的ImplBase业务方法"]

1.82.2样本中ServerImpl.streamCreatedInternal会:

  1. 选择普通应用Executor或直接Executor,并用串行执行器包装。
  2. 根据Headers选择Decompressor,不支持则返回UNIMPLEMENTED
  3. 从主注册表或fallback注册表查找完整方法名。
  4. 创建带Deadline和取消能力的Context
  5. 创建ServerCallImpl
  6. 可按ServerCallExecutorSupplier为不同方法切换Executor。
  7. 调用ServerCallHandler建立Listener。
  8. 后续message、halfClose、cancel、complete和onReady按该调用串行回调。

13.1 方法找不到为什么是UNIMPLEMENTED

完整方法名不在注册表时,Server不会猜测相近方法,也不会进入业务代码,而是关闭Stream返回UNIMPLEMENTED。常见根因:

  • 客户端与服务端使用不同package/service名。
  • 新接口尚未部署到该实例。
  • 连错服务或端口。
  • 网关按HTTP/2路径路由错误。
  • 服务实现没有加入ServerBuilder。

十四、Netty EventLoop与应用Executor边界

Netty EventLoop负责Socket和HTTP/2协议事件;ServerImpl把业务回调跳转到应用Executor。生产上要避免两种极端:

错误后果
在EventLoop执行慢业务同线程上的多个连接和Stream都被阻塞
应用Executor无界队列下游变慢时请求无限排队,Deadline到期后仍占内存

应用Executor应:

  • 有明确最大并发和有界队列。
  • 按CPU型与阻塞I/O型工作设置容量。
  • 监控active、queue、reject和任务等待时间。
  • 在任务执行前检查Context是否已取消。
  • 给数据库和外部调用传递更短剩余Deadline。

Java 21虚拟线程可以降低阻塞线程成本,但本章现代最低基线是Java 17,而且虚拟线程也不能增加数据库连接数、CPU和下游容量,仍需Bulkhead、Deadline和有界准入。

十五、ServerCall、Listener与StreamObserver

生成的ServerCalls把低层ServerCall转换为开发者熟悉的StreamObserver

15.1 Unary为什么在onHalfClose调用业务方法

Unary服务端先请求并接收请求消息,客户端发送完请求后half-close发送方向。UnaryServerCallHandleronHalfClose()确认请求结束,再调用生成业务方法。若客户端发送超过一个请求消息,属于协议错误。

15.2 客户端流与双向流

Streaming Handler先把响应Observer交给业务方法,获得请求Observer;随后每个请求消息触发onNext,客户端half-close触发onCompleted。双向流中响应可在请求结束前发送,但业务必须自己定义序号、确认和断线恢复。

15.3 Headers、Message与Trailers顺序

服务端正常响应通常是:

text
sendHeaders
  -> sendMessage一次或多次
  -> close(Status.OK, trailers)

observer.onError会转换为非OK Status与Trailers,onCompleted完成OK。不能在onCompleted后继续onNext,也不能多次终止同一Observer。

十六、入站流控与出站背压

HTTP/2窗口控制网络字节,gRPC API还提供消息级请求节奏:

  • ClientCall.request(n)表示客户端准备再接收n条响应。
  • ServerCall.request(n)表示服务端准备再接收n条请求。
  • isReady()表示当前出站方向是否适合继续写。
  • setOnReadyHandler在可写状态恢复时通知。
  • disableAutoInboundFlowControl后应用自己请求消息。
mermaid
flowchart TD
    A["收到onReady通知"] --> B["检查业务队列与isReady"]
    B --> C["发送有限批消息"]
    C --> D["不可写时暂停生产"]
    D --> E["下次onReady继续"]

isReady=true不是无限额度,只是当前时刻可继续。检查后到发送之间状态可能变化,所以发送循环还应限制批量、在每轮重新检查,并由有界业务队列兜底。

16.1 为什么自动流控也可能OOM

自动入站流控只约束gRPC交付消息的节奏。如果业务onNext把每条消息复制到无界内存队列后立即返回,gRPC会继续请求,最终仍由业务队列OOM。背压必须贯穿“网络接收 → 业务队列 → 数据库/磁盘确认”。

十七、Metadata、Context和拦截器

17.1 Metadata是什么

Metadata是随Headers或Trailers传递的键值,适合认证、Trace、租户和错误详情。二进制键必须遵循-bin命名规则。Metadata不是大业务载荷容器,Header过大会触发代理或服务端限制。

17.2 Context是什么

Context承载调用范围的Deadline、取消和进程内上下文值。服务端从Transport Headers创建Context并在回调线程附加;异步切换线程时必须使用支持的上下文包装,否则Trace、身份或Deadline可能丢失。

17.3 客户端与服务端拦截器顺序

多个拦截器是嵌套包装,注册顺序与实际请求/响应观察顺序需要通过测试确认。安全原则:认证应在业务前拒绝,审计要记录最终Status,Trace应覆盖后续业务调用,异常映射不能吞掉取消与Deadline语义。

十八、Status、Trailers与异常映射

gRPC最终状态通常位于Trailers。HTTP/2连接成功、收到响应Headers甚至收到部分消息,都不代表最终Status一定是OK。

Status应用含义建议重试结论
INVALID_ARGUMENT请求格式或取值错误修复请求,不重试
UNAUTHENTICATED凭据缺失、过期、无效刷新凭据或拒绝,不盲重试
PERMISSION_DENIED身份无权操作不重试
NOT_FOUND资源不存在由业务决定,不是网络重试
ALREADY_EXISTS幂等资源已创建按request_id查询原结果
FAILED_PRECONDITION当前业务状态不允许状态变化后再操作
ABORTED并发冲突、事务中止幂等且退避后可有限重试
RESOURCE_EXHAUSTED配额、并发、消息大小或内存限制降速并查具体描述
UNAVAILABLE连接或服务临时不可用幂等、有预算时有限重试
DEADLINE_EXCEEDED调用方未按时获得完成结果结果未知,先查事实
INTERNAL内部不变量、序列化或代理异常先定位,不默认可重试

不要把库存不足映射成UNAVAILABLE,否则客户端和Mesh会把稳定业务拒绝放大成重试风暴。结构化错误详情应经过脱敏,不返回服务端堆栈、SQL、Token和个人医疗信息。

十九、消息大小、压缩和内存

最大入站/出站消息限制可在Channel、CallOptions和Server配置。方法级Service Config通常只能进一步收紧,而不应突破应用安全上限。

大消息风险:

  • Protobuf构建完整对象需要堆内存。
  • 压缩前后都可能存在缓冲。
  • 重试要保存请求操作和载荷。
  • 多并发Stream使内存乘法增长。
  • 代理、Ingress和服务端限制可能不同。

几十或几百MB文件不应简单调高上限后用Unary传输。优先使用对象存储加元数据、分块流式协议,并为每块设计序号、校验、重传和断点续传。

压缩节省带宽但消耗CPU,也可能放大压缩炸弹风险。按消息类型、大小阈值和CPU预算启用,监控压缩前后字节和序列化耗时。

二十、Keepalive、GOAWAY与优雅停机

20.1 三种活性不能混淆

机制证明什么
TCP KeepaliveTCP内核探测连接对端是否仍响应
HTTP/2 PINGHTTP/2当前连接往返仍可用
gRPC Health应用协议指定服务是否愿意接收调用

PING成功不证明数据库健康,Health SERVING也不保证某个订单请求会成功。

20.2 Keepalive为什么不能越短越好

大量客户端频繁PING会消耗服务端、LB和网络设备资源;服务端可因过度Keepalive发送GOAWAY。客户端参数必须与服务端最小允许间隔、NAT空闲超时和连接规模共同设计。

20.3 GOAWAY发生时怎样处理

GOAWAY表示连接不再接受更大Stream ID的新调用;已接受Stream可能继续。Channel应在新连接上安排后续调用。业务不能把每个GOAWAY都当成订单失败,也不能在所有客户端同一时刻立即重连形成风暴。

20.4 服务端优雅停机

mermaid
flowchart TD
    A["实例进入下线状态"] --> B["停止接收新流量"]
    B --> C["通知连接迁移或发送GOAWAY"]
    C --> D["等待在途RPC到Deadline"]
    D --> E["超时后取消剩余调用"]
    E --> F["关闭Executor与连接"]

Kubernetes要协调readiness摘流、preStop、terminationGracePeriod、Server shutdown与客户端重试退避。只调用server.shutdownNow()会让大量在途写请求变成结果未知。

二十一、Kubernetes长连接负载为什么倾斜

若客户端Resolver只得到ClusterIP:

  1. Channel向一个VIP建立少量HTTP/2连接。
  2. kube-proxy、eBPF或LB在建连时选择Pod。
  3. 后续大量RPC作为Stream复用旧连接。
  4. 新Pod加入后不会抢走旧连接上的Stream。

解决方向:

  • Headless Service让Resolver获得Pod Endpoint。
  • gRPC xDS或Service Mesh向客户端/代理下发Endpoint。
  • 使用理解HTTP/2 RPC的L7代理。
  • 采用受控最大连接年龄与带抖动的重连。
  • 观测每个Endpoint的RPC数,而不仅是连接数。

增加连接数量可以改善分布,但会增加TLS、内存、文件描述符和LB状态,不能无限加。详见Kubernetes服务发现与连接级负载

二十二、TLS、mTLS、认证与授权

生产客户端应校验证书信任链、有效期和服务名称,不使用usePlaintext()穿越不可信网络。

安全链:

mermaid
flowchart TD
    A["TLS校验服务身份"] --> B["可选mTLS校验客户端工作负载"]
    B --> C["拦截器验证Token或调用身份"]
    C --> D["业务层执行资源级授权"]
    D --> E["审计脱敏后的操作事实"]

mTLS证明连接对端工作负载身份,不自动证明当前用户能读取该患者数据。用户认证、租户隔离、资源授权和审计仍在业务层。

证书轮换要验证新旧信任根重叠窗口、长连接何时重建、GOAWAY和失败回滚。只更新磁盘证书但Transport不重载,现有连接可能一直使用旧证书。

二十三、生产可观测性

至少区分逻辑调用和物理Attempt:

维度关键字段或指标
逻辑RPCservice、method、最终Status、总耗时、request_id
物理Attemptattempt序号、Endpoint、连接、Status、退避、是否透明重试
Channeltarget、Resolver状态、LB策略、Connectivity State
Subchannel地址、READY时间、连接失败、重连退避
TransportTLS、GOAWAY、RST_STREAM、PING、活跃Stream
服务端Executor等待、业务耗时、取消、响应大小
业务幂等命中、事务结果、事实状态、补偿次数

不要把request_id、患者ID等高基数字段放入时序指标标签;它们适合日志或Trace属性,并需脱敏和访问控制。指标标签应使用有限的service、method、status、zone等维度。

Channelz可帮助查看Channel、Subchannel、Socket和调用统计;Reflection方便调试服务描述;Health用于服务可接流量判断。三者用途不同,生产暴露都要鉴权和限制网络范围。

二十四、Java 17与Spring Boot 3可运行Demo

本Demo使用Java 17、Boot 3.2.4、gRPC Java 1.82.2、Protobuf 3.25.8和H2。它不依赖第三方gRPC Starter,直接用公开gRPC Java API,因此自动配置行为清晰可见。

本Demo使用grpc-netty-shaded,因此Netty Builder的导入是io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilderNettyChannelBuilder;若依赖的是未重定位的grpc-netty,包名才是io.grpc.netty.*。坐标和import必须成对,不能混用。

24.1 完整Maven配置

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.4</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>grpc-inventory-demo</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>17</java.version>
        <grpc.version>1.82.2</grpc.version>
        <protobuf.version>3.25.8</protobuf.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.grpc</groupId>
            <artifactId>grpc-netty-shaded</artifactId>
            <version>${grpc.version}</version>
        </dependency>
        <dependency>
            <groupId>io.grpc</groupId>
            <artifactId>grpc-protobuf</artifactId>
            <version>${grpc.version}</version>
        </dependency>
        <dependency>
            <groupId>io.grpc</groupId>
            <artifactId>grpc-stub</artifactId>
            <version>${grpc.version}</version>
        </dependency>
        <dependency>
            <groupId>javax.annotation</groupId>
            <artifactId>javax.annotation-api</artifactId>
            <version>1.3.2</version>
        </dependency>
    </dependencies>

    <build>
        <extensions>
            <extension>
                <groupId>kr.motd.maven</groupId>
                <artifactId>os-maven-plugin</artifactId>
                <version>1.7.1</version>
            </extension>
        </extensions>
        <plugins>
            <plugin>
                <groupId>org.xolstice.maven.plugins</groupId>
                <artifactId>protobuf-maven-plugin</artifactId>
                <version>0.6.1</version>
                <configuration>
                    <protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
                    <pluginId>grpc-java</pluginId>
                    <pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>compile</goal>
                            <goal>compile-custom</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

24.2 Proto契约

src/main/proto/inventory.proto使用本章第四节契约。request_id必须由订单方稳定生成,重试时保持不变。

24.3 本地事务幂等服务

java
@Service
public class ReservationApplicationService {
    private final JdbcTemplate jdbcTemplate;

    public ReservationApplicationService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional
    public ReservationResult reserve(String requestId, long skuId, int quantity) {
        List<ReservationResult> existing = jdbcTemplate.query(
                "SELECT reservation_id, result FROM reservation WHERE request_id = ?",
                (rs, row) -> new ReservationResult(
                        rs.getString("reservation_id"), rs.getString("result")),
                requestId);
        if (!existing.isEmpty()) {
            return existing.get(0);
        }

        int changed = jdbcTemplate.update("""
                UPDATE inventory
                   SET available = available - ?
                 WHERE sku_id = ? AND available >= ?
                """, quantity, skuId, quantity);
        if (changed != 1) {
            return new ReservationResult("", "REJECTED");
        }

        String reservationId = UUID.randomUUID().toString();
        jdbcTemplate.update("""
                INSERT INTO reservation(request_id, reservation_id, result)
                VALUES (?, ?, 'RESERVED')
                """, requestId, reservationId);
        return new ReservationResult(reservationId, "RESERVED");
    }
}

public record ReservationResult(String reservationId, String result) {
}

并发相同request_id时,“先查再插”仍可能竞争。生产表必须对request_id建立唯一键,并捕获唯一冲突后回查第一次结果;还要根据数据库方言和事务隔离验证行为。示例突出业务事务边界,不应把SELECT当作唯一幂等保证。

24.4 gRPC服务实现

java
@Service
public class InventoryGrpcService
        extends InventoryServiceGrpc.InventoryServiceImplBase {
    private final ReservationApplicationService applicationService;

    public InventoryGrpcService(ReservationApplicationService applicationService) {
        this.applicationService = applicationService;
    }

    @Override
    public void reserve(ReserveRequest request,
                        StreamObserver<ReserveReply> observer) {
        if (request.getRequestId().isBlank()
                || request.getSkuId() <= 0
                || request.getQuantity() <= 0) {
            observer.onError(Status.INVALID_ARGUMENT
                    .withDescription("request_id、sku_id和quantity不合法")
                    .asRuntimeException());
            return;
        }
        if (Context.current().isCancelled()) {
            observer.onError(Status.CANCELLED.asRuntimeException());
            return;
        }
        try {
            ReservationResult result = applicationService.reserve(
                    request.getRequestId(), request.getSkuId(), request.getQuantity());
            observer.onNext(ReserveReply.newBuilder()
                    .setReservationId(result.reservationId())
                    .setResult(result.result())
                    .build());
            observer.onCompleted();
        }
        catch (DataAccessException databaseFailure) {
            observer.onError(Status.UNAVAILABLE
                    .withDescription("库存数据库暂时不可用")
                    .withCause(databaseFailure)
                    .asRuntimeException());
        }
    }
}

不要把数据库异常描述、SQL或堆栈返回客户端。这里用withCause保留服务端日志链,线上应由统一异常拦截器记录脱敏错误并返回稳定错误详情。

24.5 Server生命周期

java
@Component
public class GrpcServerLifecycle implements SmartLifecycle {
    private final InventoryGrpcService service;
    private Server server;
    private volatile boolean running;

    public GrpcServerLifecycle(InventoryGrpcService service) {
        this.service = service;
    }

    @Override
    public void start() {
        try {
            server = NettyServerBuilder.forPort(9090)
                    .addService(service)
                    .maxInboundMessageSize(4 * 1024 * 1024)
                    .build()
                    .start();
            running = true;
        }
        catch (IOException failure) {
            throw new IllegalStateException("gRPC Server启动失败", failure);
        }
    }

    @Override
    public void stop() {
        running = false;
        if (server != null) {
            server.shutdown();
            try {
                if (!server.awaitTermination(10, TimeUnit.SECONDS)) {
                    server.shutdownNow();
                }
            }
            catch (InterruptedException interrupted) {
                Thread.currentThread().interrupt();
                server.shutdownNow();
            }
        }
    }

    @Override
    public boolean isRunning() {
        return running;
    }
}

生产还应配置专用有界Executor、TLS、Health、Reflection访问控制、指标和Kubernetes摘流顺序。SmartLifecycle示例展示的是生命周期,不是完整安全配置。

这里使用spring-boot-starter-web,嵌入式Web容器使商业Boot应用保持运行,也便于承载Actuator管理面。若项目是纯gRPC非Web进程,主线程必须在Server启动后调用awaitTermination(),或由明确的非守护进程生命周期托管;不能假设共享Netty线程一定让JVM永久存活。

24.6 客户端调用

java
ManagedChannel channel = NettyChannelBuilder
        .forAddress("127.0.0.1", 9090)
        .usePlaintext()
        .defaultLoadBalancingPolicy("pick_first")
        .build();

try {
    InventoryServiceGrpc.InventoryServiceBlockingStub stub =
            InventoryServiceGrpc.newBlockingStub(channel)
                    .withDeadlineAfter(800, TimeUnit.MILLISECONDS);

    ReserveReply reply = stub.reserve(ReserveRequest.newBuilder()
            .setRequestId("order-9001-inventory")
            .setSkuId(10086L)
            .setQuantity(2)
            .build());
    System.out.println(reply);
}
finally {
    channel.shutdown();
    if (!channel.awaitTermination(5, TimeUnit.SECONDS)) {
        channel.shutdownNow();
    }
}

这里只在独立客户端Main结束时关闭Channel。Web服务中的Channel应作为长生命周期Bean复用,在应用关闭阶段统一shutdown,而不是每个请求创建和销毁。

24.7 H2建表

sql
CREATE TABLE inventory (
    sku_id BIGINT PRIMARY KEY,
    available INT NOT NULL
);

CREATE TABLE reservation (
    request_id VARCHAR(100) PRIMARY KEY,
    reservation_id VARCHAR(64) NOT NULL,
    result VARCHAR(32) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO inventory(sku_id, available) VALUES (10086, 100);

二十五、JDK 7、JDK 8与Boot 2.7边界

25.1 JDK 8

官方1.82.2 README仍声明支持Java 8+,但JDK 8项目必须:

  • 使用普通POJO替代Record。
  • 使用普通字符串替代文本块。
  • 使用HashMap替代Map.of
  • 不使用String.isBlank(),改为trim().isEmpty()并处理null。
  • 编译插件配置source/targetrelease 8
  • 核对Boot 2.7管理的Netty、Guava、Protobuf与gRPC手工版本冲突。

grpc-netty-shaded通常更容易隔离Netty冲突,但Protobuf、Guava和观测集成仍要检查依赖树。

25.2 JDK 7

官方README把Java 7指向1.41.x维护分支。JDK 7系统不应直接升级到1.82.2并假设可运行。更现实的治理是:

  1. 冻结协议字段并保留兼容测试。
  2. 将JDK 7客户端隔离在1.41.x支持范围。
  3. 优先升级运行时到JDK 8或17。
  4. 新服务保持旧字段可读,使用灰度和双版本契约测试。

25.3 Boot 2与Boot 3

gRPC核心不依赖Servlet的javax/jakarta请求模型,因此网络协议不因Boot 3迁移而变化;但Spring Bean生命周期、第三方Starter、Actuator、Micrometer和依赖管理会变化。若使用第三方Starter,必须以该Starter明确支持的Boot版本为准,不能只看grpc-java本身支持Java 8。

二十六、商业场景:订单库存预留

完整设计:

  1. 订单服务用orderId + operation生成稳定request_id。
  2. Channel通过服务发现获得库存Endpoint并复用连接。
  3. 调用携带800ms总Deadline、Trace和工作负载身份。
  4. 库存服务以request_id唯一键和库存扣减同事务提交。
  5. 调用方超时后先调用查询接口确认事实。
  6. 明确未执行且仍有预算时才重试。
  7. 拒绝、预留成功和结果未知是三个不同业务状态。
  8. 对账任务扫描长时间UNKNOWN订单并补偿。
mermaid
flowchart TD
    A["生成稳定request_id"] --> B["发起带Deadline的Reserve"]
    B --> C["服务端本地事务幂等预留"]
    C --> D["调用方收到明确结果"]
    D --> E["超时则按request_id查询事实"]
    E --> F["确认、有限重试或补偿"]

客户端Fallback不能把超时伪装成“库存不足”。库存不足是明确拒绝,超时是UNKNOWN;错误合并会导致用户重复下单或订单状态无法对账。

二十七、关键失败窗口

失败窗口客户端现象服务端事实正确恢复
Resolver未返回地址Pending或UNAVAILABLE未到服务端Deadline、检查发现与DNS
选出Subchannel但建连失败UNAVAILABLE通常未执行幂等且有预算时退避重试
请求只发送部分字节CANCELLED/UNAVAILABLE是否解析不确定根据Attempt进度,写操作仍用同ID
服务端已提交、响应未到DEADLINE_EXCEEDED/UNAVAILABLE已成功查询request_id返回第一次结果
响应消息到达、Trailers失败非OK Status依Status判断不可只取消息忽略最终Status
Hedging两个实例都执行客户端只接受winner两边可能产生副作用只读或服务端强幂等
客户端取消CANCELLED业务可能仍运行服务端检查Context并给下游取消
GOAWAY迁移连接短暂重选或重试旧Stream可能继续区分已接受Stream与新调用
实例强制停机大量UNAVAILABLE部分事务结果未知摘流、优雅等待、事实查询

二十八、生产排查Runbook

28.1 第一步:保存可关联证据

至少保存:时间窗、逻辑request_id、Trace ID、完整方法名、最终Status与description、Deadline、Attempt数、Endpoint、Channel状态、Resolver地址版本、GOAWAY/RST/TLS日志和服务端事务结果。

28.2 UNAVAILABLE升高

  1. 按方法、客户端版本、Endpoint、Node和Zone拆分。
  2. 检查Resolver是否成功、返回多少地址、是否只有ClusterIP。
  3. 查看Channel与Subchannel处于IDLE、CONNECTING还是TRANSIENT_FAILURE。
  4. 区分DNS失败、连接拒绝、TLS/ALPN失败、GOAWAY、RST和代理UH/UF。
  5. 查看物理Attempt是否激增,防止重试掩盖错误率却放大流量。
  6. 到服务端按request_id查事实后再决定写请求是否重试。

28.3 DEADLINE_EXCEEDED升高

  1. 查看客户端实际Deadline分布,不只看服务端处理时间。
  2. 分解Resolver等待、连接、客户端排队、网络、服务端Executor排队、业务、序列化。
  3. 检查Context和CallOptions是否取到正确剩余预算。
  4. 检查某一层是否重新给下游完整timeout。
  5. 检查EventLoop和回调Executor是否被阻塞。
  6. 对写请求查询事实,不能直接循环重试。

28.4 RESOURCE_EXHAUSTED

  1. 读取description和Trailers,区分消息过大、配额、并发或内存。
  2. 对照客户端、服务端、代理和Ingress消息限制。
  3. 查看活跃Stream、重试Buffer和应用队列。
  4. 大对象改分块或对象存储,不只提高阈值。
  5. 降低并发并恢复限速,防止扩容进一步冲垮内存。

28.5 流式RPC卡住

  1. 确认连接、Stream和双方最后序号。
  2. 查看是否收到half-close、onCompleted或取消。
  3. 查看HTTP/2窗口、isReady和onReady回调。
  4. 检查业务队列是否满、消费者是否阻塞。
  5. 检查取消后后台生产任务是否停止。
  6. 用持久确认位点恢复,不能假设连接内消息永久保存。

28.6 扩容后流量仍集中

  1. 看Resolver地址数量,而不是只看Pod数量。
  2. 看每个Subchannel和Endpoint的RPC数。
  3. 确认LB策略及Picker实际发布状态。
  4. 判断连接是否长期绑定旧Pod。
  5. 受控设置连接年龄和重连抖动。
  6. 避免同时重建全量连接造成TLS风暴。

28.7 服务端CPU低但延迟很高

  1. 查看应用Executor queue time和拒绝数。
  2. 查看数据库连接池等待、锁和慢SQL。
  3. 查看客户端是否无Deadline地wait-for-ready。
  4. 检查单Call回调是否执行大任务。
  5. 检查线程栈中是否有同步锁、Future等待或阻塞I/O。

二十九、常见反模式

反模式为什么错后果
每次请求创建Channel重复DNS、TLS、线程和连接延迟、FD和内存上升
没有Deadline故障调用无限等待或排队内存和线程耗尽
Deadline到了就换新ID重试服务端可能已提交重复扣库存、重复支付
所有层各重试3次Attempt乘法故障下游被放大流量打垮
Async回调直接做慢SQL回调Executor被占住同Call后续事件延迟
无视isReady连续onNext待发送对象堆积OOM
只连接ClusterIP却期待RPC均衡长连接只绑定少数Pod扩容无效、热点实例
所有异常映射UNKNOWN丢失错误语义错误重试与无法排查
提高消息上限解决大文件内存和重试Buffer乘法GC、OOM和代理拒绝
强制shutdown替代优雅下线在途调用被切断大量结果未知
用mTLS替代用户授权只认证工作负载越权访问业务数据

三十、1.82.2源码阅读路线

  1. 生成的InventoryServiceGrpc:MethodDescriptor、Stub和bindService。
  2. io.grpc.stub.ClientCalls:Blocking、Future、Async怎样进入ClientCall。
  3. ManagedChannelImpl.newCallRealChannel:拦截器、PendingCall和方法配置。
  4. NameResolverListener.onResult:地址与Service Config怎样生效。
  5. DelayedClientTransportGrpcUtil.getTransportFromPickResult:Picker暂不可用时怎样等待。
  6. InternalSubchannel:连接状态、地址遍历和退避。
  7. ClientCallImpl.start:Deadline、压缩、消息限制和Stream启动。
  8. RetriableStream:Substream、BufferEntry、commit、retry与hedging。
  9. NettyClientTransportNettyClientHandler:HTTP/2客户端传输。
  10. NettyServerHandlerServerImpl.streamCreated:新Stream进入服务端。
  11. ServerCallImplServerCalls:ServerCall、Listener和Observer适配。
  12. SerializingExecutorContextDeadline:回调顺序与取消传播。

源码中io.grpc.internal和Transport内部API不是业务扩展点。若需要自定义名称解析、LB、拦截器或凭据,应从公开的NameResolverProviderLoadBalancerProviderClientInterceptorServerInterceptorCallCredentials扩展,并核对@ExperimentalApi稳定性。

三十一、关联知识点

本章小结

gRPC Java的完整链路是“生成Stub和MethodDescriptor → Channel拦截与方法配置 → Resolver产生地址与Service Config → LoadBalancer发布Picker → Subchannel取得READY Transport → ClientCall创建一个或多个物理Stream → Netty发送HTTP/2 → ServerImpl查方法并切应用Executor → 业务事务 → 消息与Trailers返回”。真正的可靠性不来自“像本地方法”,而来自稳定契约、端到端Deadline、连接与负载观测、写请求幂等、有限重试、优雅停机和结果未知后的事实查询。