
跨语言调用这件事我在几个项目里都趟过。最典型的一次是Java 侧的定时任务批量向一个 FastAPI 服务推送两百多条处理请求本地测试十条八条都正常上了环境跑了四十分钟最后整批任务在日志里变成一堆SocketTimeoutException还有几条Connection reset。当时第一反应是 Python 服务挂了登上去一看服务活得好好的CPU 也不高。问题出在两端对「一次调用」的理解完全不一致——Java 侧把它当成一次函数调用Python 侧把它当成一个随时可能被掐断的 HTTP 请求。FastAPI 用来做 Python 侧的服务网关非常顺手类型校验、自动文档、异步框架一应俱全Java 侧则承担着大部分业务系统的主干。把这两者接起来技术上就是一次 HTTP JSON 的交互但要让它稳定跑在生产上需要考虑的东西远比「发个请求解析个响应」要多。这篇内容面向的是已经会写基本 HTTP 调用、但真正要在项目里落地这套组合的开发者我会把服务端要留的口子、客户端怎么选、数据怎么对齐、长任务怎么处理、出问题怎么查一条条拆开讲。1. 先把这件事定性Java 调 FastAPI 是一次远程依赖很多问题的根源不是代码写错了而是从一开始就没摆正心态。你在 Java 里写userService.getById(1)那是进程内的调用失败了抛异常成功了拿结果中间不存在「网络」这个变量。而httpClient.send(request)完全不同它要经过 DNS、TCP 握手、TLS、反向代理、服务端排队、业务处理、序列化、回传每一步都可能出岔子。1.1 这套组合在真实项目里的分工长什么样我见过最多的场景是Java 那边有一套已经跑了三五年的业务系统订单、权限、计费、审计全在里面数据库表结构都跟业务深度绑定而算法或者数据团队的能力是用 Python 交付的模型推理、图像处理、文档解析、数据清洗这些东西用 Java 重写成本高得离谱也不现实。于是最经济的方案就是 Python 侧起一个 FastAPI 服务暴露几个 HTTP 接口Java 侧在业务链路的某个节点上调用它。这个分工本身很合理但要注意一个隐含前提FastAPI 服务对 Java 侧来说应该被当成一个外部供应商的 API而不是自己项目的另一半。外部 API 意味着什么意味着它可能超时、可能返回你看不懂的错误码、可能在你不知情的时候加了个必填字段、可能因为一次发布短暂不可用。你得为这些情况准备预案而不是假设它永远正确。我一般会要求两边的负责人先把这几件事写进接口文档接口的 SLA平均响应时间和 P99、单次请求的入参上限、失败时的重试策略、错误码的完整清单。这几项没有明确之前不要开始写代码写了也是白写。1.2 把接口契约当成跨团队协议来谈跨语言调用最容易出问题的地方从来不是 HTTP 协议本身而是两边语言习惯的差异被默认忽略了。Python 里字符串和数字随便转字段缺失就是NoneJava 里类型是死的缺一个必填字段直接抛异常。Python 的字典可以随便加 keyJava 的 POJO 多一个字段可能就反序列化失败。我的做法是先出一份 JSON 样例文件双方各自拿它写单测跑通了再接真实网络。这份样例要包含四种典型情况——正常数据、可选字段全为空、字段类型边界值比如超长字符串、负数、0、错误响应。这四份样例就是两边的共同语言后面所有关于字段的争论都以它为准。还有一个细节值得提前约定请求和响应体里要不要包一层统一的信封结构。我倾向于是要的形如{ code: 0, message: ok, data: {...} }。原因很简单FastAPI 原生的校验失败返回的是{detail: [...]}和你业务自己返回的结构完全不一样Java 侧如果不做特殊处理解析器会在两种结构之间反复横跳。包一层信封业务数据放data里错误统一走code和messageJava 侧只需要写一套解析逻辑。2. FastAPI 端要为 Java 调用方多做的四件事服务端的代码通常由 Python 同学写但如果 Java 侧是主要调用方有几个地方一定要提前沟通好否则后面全是补丁。2.1 字段命名snake_case 与 camelCase 的停战协议这是第一个必然遇到的摩擦点。Python 的命名规范是user_nameJava 的规范是userNameJackson 默认按字段名严格匹配一个下划线就能让整个反序列化结果为 null而且不报错——因为 Jackson 遇到不认识的字段只要不开启严格模式就是静默跳过。pydantic v2 里解决这个问题很干脆用一个统一的基类from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel class CamelModel(BaseModel): model_config ConfigDict( alias_generatorto_camel, populate_by_nameTrue, )alias_generatorto_camel让所有字段对外暴露成驼峰形式populate_by_nameTrue允许构造对象时既可以用 python 名也可以用别名。在 FastAPI 里声明response_modelCamelModel的子类后返回给客户端的 JSON 就会自动是驼峰。这里有个很多人不知道的点FastAPI 在序列化response_model时默认就是by_aliasTrue所以你不用额外传参但如果你自己手动调model_dump()默认是by_aliasFalse得显式传。如果你用的还是 pydantic v1配置项名字不一样是allow_population_by_field_name True加alias_generator to_camel别直接复制 v2 的代码会静默失效。另一种思路是 Java 侧全局配置 Jackson 的PropertyNamingStrategies.SNAKE_CASE让 Java 去适配 Python。两种方案都行但只能选一种选完写进文档。我最怕见到的是两边各改一半结果字段名变成了user_name和userName混着来。2.2 把 422 的响应结构掰成业务方看得懂的样子FastAPI 的参数校验失败默认返回 HTTP 422 和这样的结构{detail: [{type: missing, loc: [body, userId], msg: Field required, input: {}}]}这个结构对 Python 调用方很友好对 Java 侧的统一异常拦截器就是灾难。因为你的拦截器大概率是按code字段判断的detail里没有code会被当成未知错误然后打一条没有上下文的日志运维看到只能干瞪眼。推荐直接覆写掉from fastapi import FastAPI, Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse app FastAPI() app.exception_handler(RequestValidationError) async def on_validation_error(request: Request, exc: RequestValidationError): details [] for err in exc.errors(): loc [str(x) for x in err.get(loc, []) if x ! body] details.append({field: ..join(loc), reason: err.get(msg, )}) return JSONResponse( status_codestatus.HTTP_422_UNPROCESSABLE_ENTITY, content{ code: 40001, message: 参数校验不通过: ; .join(d[field] for d in details[:3]), data: None, errors: details, }, )这里有个取舍要讲清楚是保留 422 这个状态码还是改成 400保留 422 语义更准确但 Java 侧如果用 Feign 或者某些网关非 2xx 会被统一包装成异常拿不到原始 body。改成 400 的话大多数网关会原样透传 body。我的建议是——如果 Java 侧有统一的响应解析拦截器保留 422如果走的是第三方网关或者服务网格尽量改成 400省事。同时建议再加一个兜底处理器app.exception_handler(Exception) async def on_unhandled(request: Request, exc: Exception): return JSONResponse( status_code500, content{code: 50000, message: 服务内部错误, data: None}, )注意这个兜底不要在生产环境把str(exc)塞进 message 里堆栈信息可能包含内部路径和数据结构。2.3 uvicorn 的 keep-alive 与 worker 数决定了你后面会不会偶发断连这是我最想强调的一点因为它引起的故障非常隐蔽——不是每次都失败而是「偶尔」失败且重试一次就好了。uvicorn 的--timeout-keep-alive参数默认值是 5 秒意思是服务端在一条空闲连接上最多等 5 秒超时就主动关闭。而 Java 侧的 HTTP 客户端普遍带连接池默认空闲时间动辄 5 分钟甚至更长。于是就会出现这样的时序Java 侧复用了一条它认为还活着的连接发请求但服务端早在 4 分 55 秒前就把它关了TCP 层收到 RST客户端报Connection reset by peer。解决办法有三个任选其一服务端把--timeout-keep-alive调到 65 秒以上大于中间任何一层代理的空闲超时客户端把连接池的空闲存活时间调到小于服务端的 keep-alive客户端开启连接失效检测OkHttp 的retryOnConnectionFailure或者自己发一个轻量的探活请求。我通常选第一条加第三条组合因为改动最小。另一个必须说清楚的点是def和async def的区别。FastAPI 里用def声明的路径函数会被扔到 anyio 的线程池里执行默认上限是 40 个线程用async def声明的直接跑在事件循环上。如果你在async def里调用了阻塞的数据库驱动或者requests那整个 worker 会被卡住其他请求全部排队。这个现象在 Java 调用方看来就是「并发一上来全部超时」但 CPU 和内存都很正常非常难查。所以给一个硬性要求async def里只能出现异步库同步操作一律用def声明。至于 worker 数量用 gunicorn 起多进程是最稳的方式gunicorn main:app \ -w 4 \ -k uvicorn.workers.UvicornWorker \ -b 0.0.0.0:8000 \ --timeout 120 \ --graceful-timeout 30 \ --access-logfile -worker 数不是越多越好。经验值是 CPU 核数的 1 到 2 倍如果接口里全是 IO 等待可以到 2 倍如果有重计算老老实实按核数来多了反而因为上下文切换变慢。2.4 健康检查要分两个别拿 /docs 当探针我见过不止一次运维的探针直接打/docs理由是「这个页面能打开就说明服务正常」。这个判断在 FastAPI 里是不成立的/docs是静态生成的 Swagger 页面不依赖数据库、不依赖模型加载、不依赖任何业务依赖。正经做法是两个接口/health/live只返回服务进程是否活着不查任何外部依赖用于容器重启判断/health/ready检查数据库连接、模型是否加载完成、必要的缓存是否可用用于流量摘除。app.get(/health/live) async def live(): return {status: up} app.get(/health/ready) async def ready(): if not model_loaded.is_set(): return JSONResponse(status_code503, content{status: loading}) ok await db.ping() return JSONResponse( status_code200 if ok else 503, content{status: ready if ok else db_unavailable}, )Java 侧的负载均衡或者服务发现如果支持健康检查一定要指向ready不要指向live否则服务还在加载模型就被打进流量第一批请求全都会失败。3. Java 侧客户端选型别一上来就 RestTemplate客户端选型这件事很多人的默认答案是项目里已经有的那个。Spring 项目就用 RestTemplate 或 WebClient非 Spring 项目就用 HttpURLConnection 或者 Apache HttpClient。这没错但如果你明知要接的是长耗时、可能流式的 Python 服务还硬套默认方案后面会很痛苦。3.1 五种客户端摆在桌面上对比先看一张对照表这张表是我踩过各种坑之后总结的选型时直接对照即可。客户端所属生态同步/异步连接池流式响应支持适合的场景RestTemplateSpring 5 及以前同步需换 HttpComponents 工厂弱已有 Spring 项目、调用量不大RestClientSpring 6.1同步同上中新 Spring 项目、想用同步写法WebClientSpring WebFlux异步非阻塞内置 Reactor Netty强高并发、需要背压控制JDK HttpClientJDK 11同步/异步内置中无第三方依赖要求的项目OkHttp独立库同步/异步内置 ConnectionPool强Android、需要精细控制、multipart几个关键判断如果接口平均耗时超过 3 秒别用 RestTemplate 的默认工厂。SimpleClientHttpRequestFactory底层是HttpURLConnection它虽然有 keep-alive但没有真正意义上的连接池管理并发一高就是不断新建连接遇到 TIME_WAIT 堆积最终表现为连不上。换成HttpComponentsClientHttpRequestFactory配PoolingHttpClientConnectionManager情况立刻不一样。如果要处理流式响应SSE 或者 chunkedOkHttp 和 WebClient 是最省心的。RestTemplate 也能读到InputStream但你要自己处理缓冲区、超时和连接释放稍不注意就泄漏连接。OkHttp 的ResponseBody.source()一行一行读边界清晰。如果只是偶尔调一次接口耗时在 200 毫秒以内JDK 自带的 HttpClient 完全够用。它不需要引任何依赖配置也简单是我在写工具类或者非 Spring 项目时的首选。3.2 超时不是一个参数是三个这是踩坑重灾区。很多人配置超时只配一个readTimeout然后发现请求还是挂死了五分钟。超时至少要分三层连接超时connect timeoutTCP 握手阶段一般 2 到 3 秒足够。跨机房的话给 5 秒。读超时read timeout从发出请求到收到第一个字节之间的等待或者两个数据包之间的间隔。这个要按接口实际耗时设置比如接口 P99 是 8 秒给 15 秒。整体超时call timeout整个调用从开始到结束的总时长上限。这个是最容易被忽略的尤其对流式接口没有总超时的话下游一直不结束你的线程就一直占着。OkHttp 的三个参数OkHttpClient client new OkHttpClient.Builder() .connectTimeout(3, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .callTimeout(90, TimeUnit.SECONDS) .connectionPool(new ConnectionPool(32, 4, TimeUnit.MINUTES)) .retryOnConnectionFailure(true) .build();注意connectionPool的第二个参数是空闲连接存活时间这里设成 4 分钟前提是服务端 keep-alive 至少 65 秒——4 分钟远大于 65 秒是不是又踩了前面说的坑不是。判断依据是「客户端的空闲存活时间要小于服务端的 keep-alive」吗其实准确的判断是客户端自己管理空闲连接的回收只要它在服务端掐断之前主动丢弃就不会复用死连接。所以客户端的空闲时间应该小于服务端的 keep-alive。这里 4 分钟对应服务端应设 5 分钟以上而不是 65 秒。我把前面那句改准确一点客户端的空闲存活时间必须小于服务端的 keep-alive 时长留出足够的安全余量。服务端 5 秒、客户端 4 分钟就是最典型的错配。JDK HttpClient 的写法HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .version(HttpClient.Version.HTTP_1_1) .followRedirects(HttpClient.Redirect.NEVER) .build(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(baseUrl /v1/parse)) .timeout(Duration.ofSeconds(30)) .header(Content-Type, application/json; charsetutf-8) .header(Accept, application/json) .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) .build();这里显式指定HTTP_1_1是有原因的。JDK HttpClient 默认是HTTP_2对http://开头的地址会尝试 h2c 升级虽然升级失败能降级回 1.1但多了一次往返而且某些老版本的服务端在收到 upgrade 头时会处理异常。内网明文调用直接钉死 1.1 最稳。3.3 一份能直接抄的调用封装与其让每个业务方法自己拼请求、自己处理异常不如封装一层。下面这个类覆盖了大部分场景的需求统一超时、统一错误解析、统一日志、可选的幂等重试。public class FastApiClient { private final HttpClient http; private final ObjectMapper mapper; private final String baseUrl; public FastApiClient(String baseUrl) { this.baseUrl baseUrl.endsWith(/) ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; this.http HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .version(HttpClient.Version.HTTP_1_1) .build(); this.mapper new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } public T T post(String path, Object body, TypeReferenceT type, Duration timeout) { try { String payload mapper.writeValueAsString(body); HttpRequest req HttpRequest.newBuilder() .uri(URI.create(baseUrl path)) .timeout(timeout) .header(Content-Type, application/json; charsetutf-8) .POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8)) .build(); HttpResponseString resp http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (resp.statusCode() / 100 ! 2) { throw new RemoteCallException(resp.statusCode(), resp.body()); } return mapper.readValue(resp.body(), type); } catch (IOException e) { throw new RemoteCallException(-1, 调用失败: e.getMessage(), e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RemoteCallException(-1, 调用被中断, e); } } }这段代码里有几个细节值得单独说。FAIL_ON_UNKNOWN_PROPERTIES我显式设成了 false因为 Spring Boot 的自动配置默认就是关掉的但如果你直接new ObjectMapper()默认是 true。这意味着 Python 侧只要加一个新字段你的裸 ObjectMapper 就会抛UnrecognizedPropertyException而且报错信息里只有一个字段名非常容易误判成「对方接口坏了」。BodyHandlers.ofString(StandardCharsets.UTF_8)里的字符集也要显式指定。不指定的话JDK 会按响应头里的 charset 解析如果服务端没写 charset默认是 ISO-8859-1中文全部变问号。这个坑我在两个项目里踩过。至于重试我不建议封装进这层通用逻辑。原因是 POST 请求未必幂等盲目重试可能造成重复处理。正确做法是让业务方明确指定哪些请求可以重试或者服务端提供幂等键idempotency key客户端带上同一个键重试。4. 请求体和响应体上的精度、时间与空值协议通了之后问题会转移到数据上。这三类问题是最常见的几乎每个跨语言项目都会遇到至少一个。4.1 大整数过一遍 JSON 就可能变形Python 的int是任意精度的Java 的long是 64 位有符号。单看这两个类型64 位以内的数值互转没问题。但现实情况是数据往往要经过多个环节日志采集、链路追踪、网关转发、甚至中间有一层前端脚本。JavaScript 的Number是双精度浮点安全整数范围只有 2^53 - 1也就是 9007199254740991。超过这个数JSON.parse出来就已经不准了。所以我的建议非常明确凡是 ID 类的字段全部用字符串传输。不管是雪花算法生成的 19 位 ID还是任何可能增长到很大的数值一律String。这条规矩在 Java 和 Python 之间尤其重要因为两边的默认序列化都不会帮你做转换——Java 的long会原样输出数字Python 收到后能精确解析但一旦这个值再往外传问题就藏不住了。反过来Python 侧如果生成了一个超过Long.MAX_VALUE的值传给 JavaJackson 会直接抛JsonParseException报错信息是Numeric value out of range of long。定位这个问题需要翻到具体的字段如果字段多排查很费时。提前约定用字符串一劳永逸。4.2 时间字段对不齐排查能耗掉半天FastAPI 用 pydantic 序列化datetime输出的是 ISO 8601 格式带时区偏移形如2024-05-12T08:30:0008:00。如果字段是datetime类型但没带时区输出就是2024-05-12T08:30:00没有偏移量。Java 这边如果你用LocalDateTime接带偏移量的字符串Jackson 会直接抛异常。用OffsetDateTime接不带偏移量的也会失败。最稳妥的做法是统一用OffsetDateTime同时在 Python 侧强制所有时间字段都是带时区的datetime。Jackson 侧还要做两件事一是关掉时间戳序列化二是配好格式ObjectMapper mapper new ObjectMapper() .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .registerModule(new JavaTimeModule());WRITE_DATES_AS_TIMESTAMPS如果是开启状态OffsetDateTime会被序列化成一个浮点数Python 侧收到的就是一个数字类型对不上。这个配置在 Spring Boot 里默认是关的但裸 ObjectMapper 默认是开的又是一个「Spring 帮你兜住了出了 Spring 就翻车」的典型案例。还有一个现实问题Python 的datetime.fromisoformat在 3.11 之前对某些 ISO 8601 变体比如末尾带Z支持不完整。如果你的时间字段可能带Z升级到 3.11 以上或者用dateutil.parser.isoparse来解析。4.3 枚举和 null缺失与为空是两回事Python 的Enum序列化出来是它的value比如Status.ACTIVE.value active输出字符串active。Java 侧如果直接用enum接收Jackson 默认按枚举常量名匹配ACTIVE对不上active抛异常。解决办法两个Java 侧用JsonValue标在 getter 上或者用JsonCreator标一个静态工厂方法。public enum Status { ACTIVE(active), INACTIVE(inactive); private final String code; Status(String code) { this.code code; } JsonValue public String getCode() { return code; } JsonCreator public static Status of(String code) { for (Status s : values()) { if (s.code.equals(code)) return s; } throw new IllegalArgumentException(未知状态: code); } }比枚举更容易出事的是 null。Python 里Optional[str] None序列化成 JSON 就是null但如果字段在请求体里根本没出现pydantic 会走默认值逻辑可能是None也可能报必填错误。这两者在业务上含义完全不同「没传这个字段」通常意味着用户没打算改它「传了 null」意味着用户想把它清空。Java 的 POJO 无法区分这两种情况因为字段不管是没出现还是为 null反序列化之后都是null。如果你的业务需要区分就得在 Python 侧用model_fields_set来判断class UpdateReq(CamelModel): nickname: str | None None avatar: str | None None app.patch(/user/{uid}) async def update_user(uid: str, req: UpdateReq): changed req.model_fields_set # 只包含客户端真正传了的字段 if nickname in changed: ...这个细节在做 PATCH 风格的接口时特别关键。如果直接拿req.nickname is not None判断用户传了null想清空昵称结果被当成没改行为就错了。5. 长耗时接口同步硬等、流式推送还是任务轮询Python 侧的接口耗时有天然的分野简单的 CRUD 或者查询通常几十毫秒而模型推理、文档解析、批量计算动辄几秒到几分钟。后者如果还按同步 HTTP 的方式处理迟早出事。5.1 什么时候同步等待会把你拖垮先算一笔账。假设一个接口平均耗时 8 秒Java 侧用 Tomcat默认最大线程数 200。如果 QPS 达到 30理论上需要 240 个线程同时挂在这个调用上已经超过上限后面的请求开始排队Tomcat 的 accept 队列满了之后开始拒绝连接。整个服务看起来「挂了」实际上业务逻辑一点问题没有就是线程被占满了。而且 Java 侧的网关通常有超时设置Nginx 默认proxy_read_timeout是 60 秒Spring Cloud Gateway 默认也差不多。一旦接口耗时接近这个值你会看到大量 504但 Python 服务端的日志显示请求还在正常处理——处理完了也没人收结果纯浪费资源。判断标准很简单接口 P95 耗时超过 3 秒就应该考虑异步化。3 秒是个经验值大致对应人的耐心阈值也大致对应大部分网关的默认超时的一半。5.2 流式响应的对接方式与缓冲陷阱如果业务场景是「结果需要持续输出」比如大模型逐字生成、日志实时回放、进度持续刷新那 SSEServer-Sent Events是最合适的。FastAPI 侧写起来很简单import asyncio, json from fastapi.responses import StreamingResponse app.get(/v1/tasks/{task_id}/stream) async def stream_result(task_id: str): async def event_gen(): last_seq 0 while True: chunks await fetch_new_chunks(task_id, last_seq) for c in chunks: last_seq c[seq] yield fdata: {json.dumps(c, ensure_asciiFalse)}\n\n if await is_finished(task_id): yield data: {\done\: true}\n\n break await asyncio.sleep(0.3) return StreamingResponse( event_gen(), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, }, )X-Accel-Buffering: no这一行非常关键。如果前面有 Nginx它会默认把上游的响应缓冲起来攒够一定大小才往客户端发。对于流式接口这就导致客户端要等很久才能看到第一段数据「流」变成了「一次性」。加上这个头Nginx 会关闭缓冲逐块透传。Java 侧用 OkHttp 读 SSERequest req new Request.Builder() .url(baseUrl /v1/tasks/ taskId /stream) .header(Accept, text/event-stream) .build(); try (Response resp client.newCall(req).execute()) { if (!resp.isSuccessful()) throw new RemoteCallException(resp.code(), 流式接口失败); BufferedSource source resp.body().source(); String line; while ((line source.readUtf8Line()) ! null) { if (line.startsWith(data: )) { String data line.substring(6); if (data.contains(\done\: true)) break; // 交给业务处理 } } }注意这里用的是try-with-resourcesResponse必须关闭否则连接不会归还到连接池跑一段时间就会连接耗尽。这是流式接口最容易犯的错误之一因为很多人习惯了ResponseBody.string()那种自动关闭的写法换成流式之后忘了手动关闭。5.3 任务表方案的表结构与轮询策略大多数场景其实不需要流式任务表就够了。核心思路是提交任务返回一个task_idJava 侧拿这个 ID 去轮询状态。这种方式的好处是解耦彻底服务重启了任务状态还在而且天然支持断线重连。表结构我一般这么设计字段类型说明idbigint自增主键task_idvarchar(36)对外暴露的 UUID唯一索引biz_typevarchar(32)任务类型便于统计和分流biz_keyvarchar(64)业务幂等键唯一索引statustinyint0 待处理 1 处理中 2 成功 3 失败 4 超时paramstext入参快照便于重跑resultmediumtext结果数据error_msgvarchar(512)失败原因retry_countint已重试次数created_atdatetime创建时间updated_atdatetime更新时间expire_atdatetime结果保留截止时间轮询策略上我强烈建议用指数退避而不是固定间隔。固定 1 秒轮询一个耗时 5 分钟的任务要打 300 次请求纯属浪费。退避序列可以是 1 秒、2 秒、4 秒、8 秒、15 秒封顶 30 秒总超时设定 10 分钟。还有一个容易忽略的点任务提交必须幂等。Java 侧重试逻辑一旦触发同一个biz_key会提交两次。服务端要用biz_key做唯一索引插入冲突时直接返回已存在的task_id而不是新建一个任务。async def submit_task(biz_key: str, biz_type: str, params: dict) - str: existing await db.fetch_one( SELECT task_id FROM task WHERE biz_key :k, {k: biz_key} ) if existing: return existing[task_id] task_id str(uuid.uuid4()) try: await db.execute( INSERT INTO task (task_id, biz_key, biz_type, status, params, created_at) VALUES (:tid, :k, :bt, 0, :p, NOW()), {tid: task_id, k: biz_key, bt: biz_type, p: json.dumps(params)}, ) except IntegrityError: row await db.fetch_one(SELECT task_id FROM task WHERE biz_key :k, {k: biz_key}) return row[task_id] return task_id这段代码里先查后插有并发窗口所以必须靠数据库的唯一索引兜底捕获冲突后再查一次。这个模式看起来笨但在高并发下是最可靠的比分布式锁简单得多。6. 联调现场的三条排查链路接口写完、单测跑通不代表能上线。真正的问题往往在联调阶段才暴露而且表现千奇百怪。下面三条排查链路是我处理过最多的问题类型按出现频率从高到低排。6.1 415 和 422 长得像根因完全不同415 Unsupported Media Type意味着服务端不认识你发过来的 Content-Type。FastAPI 里凡是接收 JSON body 的接口都要求Content-Type: application/json。如果你用了某个 HTTP 工具默认发的是text/plain或者干脆没设这个头就会 415。排查顺序先在客户端打印出完整的请求头确认 Content-Type 是否存在、拼写是否正确、有没有多余的空格。我曾经遇到过一个非常隐蔽的情况某个内部封装的工具类在拼接 header 时把application/json拼成了application/json末尾多了一个空格服务端解析失败但日志里肉眼看不出来。422 Unprocessable Entity意味着 Content-Type 对了JSON 也能解析但字段校验没过。常见原因有四个必填字段没传、字段类型不匹配比如传了字符串但期望数字、字段名大小写或命名风格不一致、枚举值不在允许范围内。排查的时候最快的办法是打开 FastAPI 的/docs页面用 Swagger UI 直接试一次。如果 Swagger 能通说明服务端没问题问题在客户端的序列化。如果 Swagger 也不通看服务端日志里 pydantic 报的具体字段。还有一类错误容易被混淆400 Bad Request。如果你的 body 根本不是合法 JSON比如手动拼接少了引号FastAPI 会返回 400 而不是 422因为它在解析阶段就失败了。这三种状态码的含义要分清楚排查方向完全不同。6.2 偶发 Connection reset八成是 keep-alive 时间不对齐这个问题前面提过一次这里给出完整的排查链路因为它的定位过程很有代表性。现象是服务平时正常但每隔几分钟会出现一两个失败请求报错信息是Connection reset或者Unexpected end of file重试一次立刻成功。日志里看不出规律压测也复现不了。第一步确认是不是连接复用的问题。把客户端的连接池大小临时改成 1空闲时间改成 0强制每次新建连接。如果问题消失基本可以确定是 keep-alive 时间不对齐。这一步排查代价很小但结论很硬。第二步量出准确的时长。在服务端把--timeout-keep-alive临时调到 300 秒观察失败间隔是否跟着变化。如果失败从每 5 秒一次变成每 5 分钟一次那就是这个参数。第三步检查中间有没有 Nginx。Nginx 的keepalive_timeout默认 65 秒proxy_read_timeout默认 60 秒还有 upstream 的keepalive配置。这三层的任意一层都可能提前关闭连接。典型的 Nginx 配置长这样upstream fastapi_backend { server 127.0.0.1:8000; keepalive 32; } server { location /api/ { proxy_pass http://fastapi_backend; proxy_http_version 1.1; proxy_set_header Connection ; proxy_read_timeout 120s; proxy_connect_timeout 5s; } }proxy_http_version 1.1和proxy_set_header Connection 这两行必须加否则 Nginx 到上游走的是 1.0而且默认带Connection: close等于每次请求都新建连接连接池形同虚设。第四步把三层的超时调成一个递减的序列。比如客户端连接池空闲 4 分钟Nginx keepalive 5 分钟uvicorn keep-alive 6 分钟。原则是从客户端到服务端超时时间依次增大这样任何一层主动关闭时上一层都已经把连接释放了不会复用死连接。6.3 中文乱码出现在哪一层乱码问题的排查逻辑是先确定乱码发生在哪个环节再针对性修复。第一层Java 发送时。BodyPublishers.ofString(json)如果不传 charset用的是 UTF-8这个没问题但如果用了getBytes()不传参在不同平台上结果不一样。稳妥写法是ofString(json, StandardCharsets.UTF_8)加上 header 里的charsetutf-8。第二层Python 接收时。FastAPI 解析 JSON body 时默认按 UTF-8一般不会出问题。但如果你的接口接收的是表单或者查询参数要注意python-multipart对文件名编码的处理某些版本对非 ASCII 文件名会乱码。第三层Java 接收时。前面说过ofString()不指定 charset 就会按响应头的 charset 解析没有就用 ISO-8859-1。这是最常见的乱码来源。修复方法就一个显式传StandardCharsets.UTF_8。第四层日志和数据库。有时候链路本身没问题是写日志的组件按平台默认编码处理或者数据库连接的characterEncoding没配。这类问题只在中文出现时暴露英文数据一切正常容易被当成「偶发」。排查方法很简单在服务端加一行日志把收到的原始 body 按十六进制打印几个字节出来。中文的 UTF-8 编码是三个字节一组首字节在 0xE4 到 0xE9 之间。如果打印出来是3F问号说明在更早的环节就已经被替换掉了如果是C4 E3这种那是 GBK 编码说明中间有人按 GBK 处理的。我在实际项目里对一个原则越来越确信Python 服务在 Java 眼里就应该是一个外部 API而不是自己系统的一部分。这个定位一旦建立很多决策就变得清晰了——超时要有、重试要有、降级要有、接口变更要提前通知、字段宁可多传不要少传。把它当成自家进程内的方法去调出了问题只会互相甩锅。最后分享一个小技巧。联调阶段我最常做的一件事是在 Java 侧的 HTTP 封装里加一个开关打开之后把每个请求的完整 URL、请求头、请求体、响应码、响应体、耗时全部打印出来写到一个单独的文件里。这个文件不参与日志采集纯粹给排查用。有了它大部分「我这边没问题」的争论可以在五分钟内结束比两边对着屏幕猜要高效得多。