C# HttpClient 发送 JSON POST:序列化、超时与重试避坑

发布时间:2026/10/28 11:05:47
C# HttpClient 发送 JSON POST:序列化、超时与重试避坑 简介面向 C# 网络通信与 JSON 数据交互场景整理的 Newtonsoft.Json 多版本程序集包适合需要通过 HTTP POST 向服务端提交 JSON、并解析响应数据的开发者使用也可为处理对象序列化、反序列化及不同 .NET 目标框架兼容问题提供参考。压缩包共 27 个文件约 6.41MB包含 9 个 dll、9 个 xml 和 9 个 pdbdll 为可引用的 Json.NET 运行库xml 提供 API 文档注释pdb 保留调试符号便于在项目中排查异常与调试。内容覆盖 net20、net35、net40、net45、netstandard1.0/1.3/2.0 及 portable 等版本可匹配传统 .NET Framework、.NET Core 与跨平台项目的引用需求。已有 1400 人学习适合希望降低版本适配与 JSON 处理试错成本的 C# 开发者。1. 从一次 400 说起C# 用 POST 传 JSON真正要盯的只有四件事对接外部接口时最常见的场景Postman 里点一下秒回 200把同样的 body 搬进 C# 代码就 400把 json 字符串打印出来逐字符比对看着一模一样。这类 C# 网络协议交互问题八成出在序列化、编码、Content-Type 和超时这四处跟 JSON 语法本身没关系。围绕的正是这一环用 HttpClient 发 HTTP POST请求体和响应体都走 JSON把 DTO 设计、序列化参数、Header 注入、状态码判断、超时与重试串成一条可复现的链路。适合刚接手接口联调的后端和上位机开发者也适合手上堆着一批散装 HttpWebRequest 旧代码、想统一收口的人。这篇不聊协议史只写我怎么搭、怎么排错、哪几行改一次就能少熬两小时。2. 客户端与序列化选型HttpClient 复用、两套序列化器的边界在哪动手之前先把两件事定下来用什么发请求用什么做 JSON 序列化。这两项选错后面所有调试都在替选型还债。很多人觉得发个 POST 而已随便 new 一个 HttpClient 就完事跑单机测试永远没问题一上并发就开始偶发 SocketException日志里全是由于系统缓冲区空间不足。2.1 四种发请求的写法什么场景用哪个我把实际项目里出现过的写法列成一张表选型时直接对号入座写法适用运行时实例复用常见翻车点HttpWebRequest.NET Framework 全系每次新建异步模型别扭GetResponse 同步阻塞连接数受 ServicePointManager 限制HttpClient.NET Framework 4.5 / .NET Core静态单例或 IHttpClientFactory频繁 new 导致 TIME_WAIT 堆积长驻单例不刷新 DNSRestSharp需要 multipart、表单、复杂反序列化单例107 版本前后 API 断层老教程代码直接编译不过Flurl.Http链式写法快速脚本静态依赖链偏长团队规范不好统一.NET Framework 上还有个容易忽略的点ServicePointManager.DefaultConnectionLimit 默认是 2也就是同一个域名最多两条并发连接。压测时发现 QPS 上不去、请求排队十有八九是这里没改。.NET Core 之后默认值跟着 CPU 走一般不用管但单例 HttpClient 的 DNS 缓存问题还在需要靠 SocketsHttpHandler 的 PooledConnectionLifetime 兜底。我一般这么定新项目在 .NET 6 以上用 IHttpClientFactory 注册命名客户端配 PooledConnectionLifetime 五分钟老 Framework 项目统一一个静态 HttpClient启动时顺手把 DefaultConnectionLimit 调到 32 到 64 之间。别在方法体里 new这是血泪经验不是风格洁癖。2.2 一个能直接跑的 POST JSON 骨架骨架不追求功能全追求能一眼看清每一步在干什么using System; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; public static class JsonPostDemo { // 进程内复用同一个实例避免每次 new 造成端口耗尽 private static readonly HttpClient Client new HttpClient { // 这里管的是整个请求到响应读完的总时长 Timeout TimeSpan.FromSeconds(15) }; public static async Taskstring PostAsync(string url, object payload, CancellationToken ct default) { var options new JsonSerializerOptions { PropertyNamingPolicy JsonNamingPolicy.CamelCase, // 输出 userName 而不是 UserName DefaultIgnoreCondition System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; string json JsonSerializer.Serialize(payload, options); // StringContent 第二个参数是编码第三个参数是 media type using var content new StringContent(json, Encoding.UTF8, application/json); using var resp await Client.PostAsync(url, content, ct).ConfigureAwait(false); // 先拿字符串再判断错误体往往不是标准 JSON string body await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!resp.IsSuccessStatusCode) throw new HttpRequestException($HTTP {(int)resp.StatusCode}: {body}); return body; } }逻辑说明序列化放在方法内而不是靠 HttpClient 的扩展方法好处是序列化参数显式可见出问题能立刻定位到是字符串生成错了还是传输过程改了字节。ConfigureAwait(false)在类库和控制台程序里都建议加避免同步上下文回切造成的死锁只有在 UI 事件处理里才需要恢复上下文。参数说明Timeout设为 15 秒是经验值内部服务可以压到 5 秒跨公网对接放到 30 秒PropertyNamingPolicy.CamelCase要和服务端约定一致对方要 PascalCase 就删掉这行DefaultIgnoreCondition设为忽略 null能避免一部分服务端把 null 当成类型错误的校验逻辑。ct一路透传是为了在界面取消或进程退出时能真正中断在途请求而不是干等超时。2.3 序列化器定谁System.Text.Json 与 Newtonsoft.Json 的边界两套都能用但行为差异不小混用还会出现同一个对象序列化两次结果不同的玄学。对比项System.Text.JsonNewtonsoft.Json默认命名保持原样保持原样字段field默认不序列化要设 IncludeFields默认序列化 public 字段null 值默认输出 null默认输出 null日期时间ISO 8601带 T 和偏移默认 ISO 8601但反序列化时会自动把字符串识别成日期循环引用直接抛异常默认抛异常可配 ReferenceLoopHandling大文档性能更好Span 友好一般我踩过最隐蔽的一个坑在 Newtonsoft 的反序列化侧DateParseHandling默认会把长得像时间的字符串自动转成 DateTime于是JObject里那个字段从 string 变成了 DateTime再往下取字符串就炸了。对接那种 ID 恰好是20240115格式的字段时这个坑几乎必现加一行DateParseHandling DateParseHandling.None才消停。选择上我一般这样新项目一律 System.Text.Json少一个依赖性能也够需要和 Newtonsoft 特有的 JObject 动态操作、或者依赖大量特性比如 JsonExtensionData 的高级用法时才引 Newtonsoft。两边都要注意的一点是DTO 里出现循环引用时不要指望序列化器帮你处理直接给反向导航属性打上忽略标记比配参数可靠得多。3. 请求体怎么拼DTO、编码、Header 三处最容易走样的细节骨架跑通只是第一步。真正常见的失败是请求发出去了、服务端也收到了但解析出来的字段是错的。这一章拆三处最容易走样的细节DTO 与序列化参数、Content-Type 和字符集、公共 Header 与签名。3.1 DTO 设计与序列化参数命名、可空、日期DTO 的第一原则是只声明你要发的字段别直接把业务实体丢进去。业务实体往往带着一堆数据库字段和导航属性序列化出来体积大还容易把不该外发的内部 ID 带出去。using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Unicode; public class OrderRequest { [JsonPropertyName(order_no)] // 服务端要下划线风格时用特性兜底 public string? OrderNo { get; set; } public decimal Amount { get; set; } public DateTimeOffset CreatedAt { get; set; } } public static readonly JsonSerializerOptions JsonOpts new JsonSerializerOptions { PropertyNamingPolicy JsonNamingPolicy.CamelCase, DefaultIgnoreCondition JsonIgnoreCondition.WhenWritingNull, // 中文不转成 \u4e2d\u6587抓包时肉眼能比对 Encoder JavaScriptEncoder.Create(UnicodeRanges.All), // 允许服务端把 123 这种字符串数字读进 int/decimal NumberHandling JsonNumberHandling.AllowReadingFromString };逻辑说明JsonPropertyName优先级高于全局命名策略适合那种大部分字段小驼峰、个别字段下划线风格的接口。Encoder是关键一行默认编码器会把非 ASCII 字符转义成 \uXXXX体积膨胀不说抓包对比时基本没法看JavaScriptEncoder.Create(UnicodeRanges.All)只放开非 ASCII仍然会转义、、这些有 XSS 风险的字符比UnsafeRelaxedJsonEscaping更稳一点。参数说明NumberHandling.AllowReadingFromString只影响反序列化不影响写出如果服务端对数字精度敏感decimal 比 double 靠谱别图省事用 double 接金额。日期统一用DateTimeOffset字段带时区偏移能省掉一大类差八小时的扯皮。3.2 Content-Type 与字符集StringContent 的三个参数很多服务端框架对 Content-Type 做严格匹配application/json; charsetutf-8和application/json在它眼里是两种情况。// 写法一StringContent 会自动补 charsetutf-8 var c1 new StringContent(json, Encoding.UTF8, application/json); // 实际发出Content-Type: application/json; charsetutf-8 // 写法二服务端严格校验 media type 时手动构造不带 charset var c2 new ByteArrayContent(Encoding.UTF8.GetBytes(json)); c2.Headers.ContentType new System.Net.Http.Headers.MediaTypeHeaderValue(application/json);逻辑说明StringContent的便捷之处在于它替你设了编码代价是 Content-Type 被追加了 charset 参数。绝大多数服务端能接受但确实有一部分老网关按字符串全等匹配多一个分号就返回 415。这时用ByteArrayContent显式指定头部字节还是 UTF-8 那一份只是不再声明。参数说明编码一律Encoding.UTF8绝对不要用Encoding.Default那在中文 Windows 上是 GBK服务端按 UTF-8 解出来就是一串问号而且这种错误在日志里看起来请求成功了最难查。另外注意序列化后的字符串如果被某处Trim()或者拼接过字节就变了签名校验会直接失败。3.3 公共 Header、Token 与签名需要鉴权的接口签名必须基于实际发出去的那份字节计算而不是基于对象重新序列化一遍。byte[] bytes Encoding.UTF8.GetBytes(json); // 只序列化一次后面都用它 string ts DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); using var hmac new System.Security.Cryptography.HMACSHA256(Encoding.UTF8.GetBytes(secret)); byte[] hash hmac.ComputeHash(Encoding.UTF8.GetBytes(ts \n Encoding.UTF8.GetString(bytes))); // .NET 5 用 ToHexStringFramework 用 BitConverter.ToString(hash).Replace(-, ).ToLowerInvariant() string sign Convert.ToHexString(hash).ToLowerInvariant(); var req new HttpRequestMessage(HttpMethod.Post, url); req.Headers.Add(X-Timestamp, ts); req.Headers.Add(X-Sign, sign); req.Content new ByteArrayContent(bytes); req.Content.Headers.ContentType new System.Net.Http.Headers.MediaTypeHeaderValue(application/json);逻辑说明先把 json 转成 byte 数组存下来签名和请求体都从这同一份 bytes 出发。只要中间没有二次序列化、没有改编码、没有多一个空格服务端算出来的签名就一致。参数说明时间戳建议用 Unix 秒并参与签名服务端做五分钟窗口校验能挡掉重放密钥不要硬编码在源码里从配置中心或环境变量取。请求级 Header 用HttpRequestMessage不要往Client.DefaultRequestHeaders里塞那个是全局共享的多线程下会互相污染。4. 响应与超时状态码判断、CancellationToken 与重试的止损线请求侧拼对了问题往往转移到响应侧状态码判断太粗、超时管不住、重试把一次下单变成三次。这一章按读响应 → 控超时 → 定重试的顺序走。4.1 别用 EnsureSuccessStatusCode 一把梭EnsureSuccessStatusCode()抛出的异常里只有状态码没有响应体而 4xx 的真实原因几乎全在 body 里。using var req new HttpRequestMessage(HttpMethod.Post, url) { Content content }; // ResponseHeadersRead响应头到手就返回内容边读边处理避免大响应全量缓冲 using var resp await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); string raw await resp.Content.ReadAsStringAsync(ct); if (!resp.IsSuccessStatusCode) { string message raw; try { // 错误体可能是 JSON也可能是一段 HTML 网关页 using var doc JsonDocument.Parse(raw); if (doc.RootElement.TryGetProperty(message, out var m)) message m.GetString() ?? raw; } catch (JsonException) { /* 不是 JSON 就原样带回 */ } throw new HttpRequestException($HTTP {(int)resp.StatusCode} {resp.StatusCode}: {message}); }逻辑说明先把响应读成字符串再尝试解析成 JSON 错误结构解析失败就把原文带进异常。这样日志里出现的是参数 amount 精度超限这类人话而不是Response status code does not indicate success: 400。参数说明HttpCompletionOption.ResponseHeadersRead适合响应体可能较大的场景配合流式读取能减少内存峰值如果响应都很小用默认的 ResponseContentRead 更省事。异常里带原始 body 时注意别把手机号、身份证这类字段原样落盘日志脱敏要在这一层做。4.2 超时、CancellationToken 与 HttpClient.Timeout 的关系这三者的关系不搞清楚排查偶发卡死会非常痛苦。.NET Core 上HttpClient.Timeout覆盖到响应内容读完为止.NET Framework 上它只管到响应头后面读 body 是可以无限期挂住的。所以生产代码里别只依赖Timeout。using var cts CancellationTokenSource.CreateLinkedTokenSource(outerToken); cts.CancelAfter(TimeSpan.FromSeconds(10)); // 单次调用的硬上限 try { return await PostInternalAsync(url, json, cts.Token); } catch (OperationCanceledException) when (!outerToken.IsCancellationRequested) { // 外层没取消说明是我们自己超时掐断的 throw new TimeoutException($调用 {url} 超过 10 秒未完成); }逻辑说明CreateLinkedTokenSource把外部取消和内部超时合成一个令牌两者任一触发都会中断请求。catch 里的when过滤是关键用来区分用户主动取消和我们自己等不及了前者不该报警后者该进监控。参数说明CancelAfter的时间要大于服务端承诺的 P99又要小于上游调用方的超时形成递减的漏斗否则超时会被层层传递放大。用OperationCanceledException而不是TaskCanceledException捕获前者是后者的父类覆盖更全。4.3 哪些错误能重试哪些重试就是二次下单重试不是无条件循环。判断标准有两条错误是不是瞬时的请求是不是幂等的。for (int attempt 0; attempt 3; attempt) { try { var resp await SendOnceAsync(req.Clone(), ct); int code (int)resp.StatusCode; // 只对明确的瞬时错误重试400/401/403/422 重试毫无意义 bool transient code 408 || code 429 || code 502 || code 503 || code 504; if (!transient) return resp; // 服务端给了 Retry-After 就听它的 var wait resp.Headers.RetryAfter?.Delta ?? TimeSpan.FromMilliseconds(300 * Math.Pow(2, attempt)); await Task.Delay(wait, ct); } catch (HttpRequestException) when (attempt 2) { await Task.Delay(TimeSpan.FromMilliseconds(300 * Math.Pow(2, attempt)), ct); } }逻辑说明只对连接层失败和 5xx 里的少数几个码重试指数退避加抖动。请求体带Idempotency-Key头时服务端才能安全去重没有这个约定的场景创建类接口下单、提交工单一律不重试。参数说明重试次数 3 是上限再多只会把下游打得更难受。注意HttpRequestMessage不能重复发送重试前必须重建或用Clone()否则会抛请求消息已发送的异常。5. 避坑与排查五条把联调拖到半夜的记录下面五条都是我实际遇到过、并且事后复盘觉得早知道能省三小时的问题。每条按现象、原因、解决写。5.1 现象Postman 通、代码 400现象同一个接口同一个账号Postman 里点发送返回 200C# 代码跑出来 400把 json 打印出来贴进 Postman 又是 200。原因肉眼看到的字符串一致不代表字节一致。常见差异有三种编码不是 UTF-8、字符串被Trim()或拼接改过、序列化多输出了一层{value:{...}}包装。还有一种隐蔽情况是 STJ 把中文转义成了 \uXXXX服务端做了长度或内容校验。解决在发出前把字节打出来用BitConverter.ToString(Encoding.UTF8.GetBytes(json))取前若干字节对比或者直接在ByteArrayContent之前断点看bytes.Length。我一般会写一行日志POST {url} len{bytes.Length}长度对不上就一定有问题。5.2 现象中文变问号或 \uXXXX现象服务端收到的姓名字段是 ?? 或者一串 \u5f20\u4e09英文和数字完全正常。原因用Encoding.Default取字节中文系统是 GBK却声明了 UTF-8或者用了默认编码器把非 ASCII 全转义对方系统按字面量解析。解决序列化和转字节统一走Encoding.UTF8Content-Type 明确声明 utf-8需要可读时加JavaScriptEncoder.Create(UnicodeRanges.All)。反方向也要注意响应读取用ReadAsStringAsync时如果服务端返回的 Content-Type 没写 charsetHttpClient 会按 UTF-8 解遇到 GBK 服务端要在响应头里纠正。5.3 现象偶发 SocketException端口耗尽现象单机压测跑几千次之后开始报 Only one usage of each socket address is normally permitted重启进程又好了。原因方法体里using var client new HttpClient()每次请求都新建实例、新建连接短连接关闭后进入 TIME_WAITWindows 默认动态端口约 16000 个几分钟就耗光。即便using释放了对象底层连接也不会立刻归还。解决HttpClient 复用为静态单例或走IHttpClientFactory服务端支持的话开启长连接。这一条属于必须一次性解决的架构性问题别用重试一下就好了掩盖。5.4 现象Content-Length 与实际字节不符现象服务端报 body 截断或解析到一半失败网关日志里 Content-Length 比实际内容小。原因用流作为 Content 时长度不可知走了 chunked 编码某些老网关不支持或者手工req.Content.Headers.ContentLength设了一个未按字节数计算的字符数Ultra 中文一个字符三字节长度按字符算必然错。解决小请求体一律ByteArrayContent或StringContent由 HttpClient 自己算长度确需流式上传再显式开 chunked。手工设 ContentLength 时长度用bytes.Length而不是json.Length这是最容易写错的一行。5.5 现象读响应卡住一直等到超时现象请求发出去了服务端日志显示已返回 200但客户端一直挂着最后抛超时。原因HttpClient.Timeout在 .NET Framework 上不覆盖读取响应体阶段或者SendAsync用了ResponseHeadersRead之后忘记读 Content连接一直占用连接池被占满后续请求全部排队。解决用CancellationTokenSource.CancelAfter做端到端硬超时并且确保ResponseMessage和HttpContent都被using释放。用ResponseHeadersRead时务必把流读完或显式Dispose否则连接不会归还。这类问题在监控上表现为QPS 掉到 0 但没有异常靠日志挖不出来得看连接池指标。6. 收口一个可复用的 JsonPostClient 与上线前的三步验证散落在各处的 POST 代码最后一定会分叉有人加了日志有人忘了超时有人自己拼 Header。我一般的做法是收成一个泛型入口只暴露地址、请求对象、追踪回调三个参数。public static async TaskTResp? PostJsonAsyncTReq, TResp( HttpClient client, string url, TReq body, Actionstring? trace null, CancellationToken ct default) { string json JsonSerializer.Serialize(body, JsonOpts); trace?.Invoke($POST {url} req{json}); // 只在需要时打开防止日志刷爆 using var content new StringContent(json, Encoding.UTF8, application/json); using var resp await client.PostAsync(url, content, ct).ConfigureAwait(false); string raw await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); trace?.Invoke($POST {url} status{(int)resp.StatusCode} resp{raw}); if (!resp.IsSuccessStatusCode) throw new HttpRequestException(${(int)resp.StatusCode}: {raw}); return string.IsNullOrWhiteSpace(raw) ? default : JsonSerializer.DeserializeTResp(raw, JsonOpts); }上线前我会跑三步验证。第一步把trace打开跑一次真实请求把req后面那段字符串原封不动贴进 Postman通与不通立刻分流通说明是传输层差异不通说明是序列化差异。第二步拿一个必错的入参比如把必填字段置空跑一次确认异常信息里能看到服务端的原始错误描述而不是一个光秃秃的状态码。第三步把超时临时调到 1 秒确认超时路径抛的是TimeoutException而不是TaskCanceledException避免监控规则对不上。还有一个具体技巧JsonOpts一定只保留一个静态实例不要每次调用 new 一个JsonSerializerOptions。STJ 在第一次序列化时会缓存类型元数据反复 new 会让缓存完全失效大对象序列化耗时能差出好几倍这个性能问题在压测里表现得非常明显。从那以后我每次接新接口都强制先跑一遍字节比对 错误入参 缩短超时这三步再写业务逻辑宁可多花二十分钟也不要在半夜对着两段看起来一模一样的 JSON 找差异。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询