我在一个异步方法上用 .Result 同步等了一下结果,整个请求就这么永远卡死了,没有任何报错也没有超时,我对着 async 配 .Result 造成的死锁这个坑排查大半天的复盘

一个让我对 C# async/await 从会用到敬畏的经典坑,折磨在它不报错不抛异常不超时,只是静悄悄永远卡死在那,请求转圈到天荒地老、日志却干干净净。老项目里要在一个同步方法里调一个异步方法拿数据,图省事没把整条链改异步,直接在异步方法后加了 .Result 想同步等结果:return GetDataAsync().Result。这在控制台可能正常,但放到旧版 ASP.NET、WPF、WinForms 里就直接把请求/界面线程永久卡死,没异常没超时仿佛时间静止。深入研究 async/await 和 SynchronizationContext 才解开死锁之谜:在 UI 程序和旧 ASP.NET 里存在同步上下文代表特定线程,await 一个任务后后续代码默认要调度回原来的同步上下文(原线程)执行;而 .Result 阻塞了当前这个线程死等 Task 完成,Task 内 await 之后的代码又非要回到这个已被阻塞的线程才能继续——代码等线程、线程等 Task、Task 等代码,你等我我等你的循环等待造成永久死锁。控制台没有这种必须回到特定线程的上下文所以不死锁,这正是它换个环境就复现/不复现极其迷惑的原因。这篇从故障现场、SynchronizationContext 死锁真相、正解(async all the way 全程 await、库代码 ConfigureAwait(false)、绝不用 .Result/.Wait()/GetAwaiter().GetResult()、万不得已 Task.Run 绕开)、async/await 其他坑(async void、忘记 await、循环里串行 await 应 WhenAll、async 内阻塞、CPU 密集用 Task.Run、CancellationToken)、同步等异步写法对照表、async 真正解决什么(等待 IO 时不浪费线程而非多线程并行)、用异步决策图与铁律,到附上一段手动装单线程同步上下文能亲手复现死锁并验证修复的代码。核心领悟:异步有传染性,在同步异步边界上别强行把异步变回同步而要 async all the way;学新范式光学语法不够,核心是切换背后的思维模型,否则会用旧思维套新语法写出语法对思维错的代码;能稳定复现是真正理解和解决问题的前提,主动构造最小复现把环境相关 bug 变成可掌控的研究对象。

我在一个异步方法上用 .Result 同步等了一下结果,整个请求就这么永远卡死了,没有任何报错也没有超时,我对着 async 配 .Result 造成的死锁这个坑排查了大半天的复盘

这是一个让我对 C# 的 async/await "从会用到敬畏"的经典坑。它最折磨人的地方在于:它不报错、不抛异常、不超时,只是静悄悄地、永远地卡死在那里——你看着那个请求转圈转到天荒地老,日志里却干干净净,什么线索都没有。

事情起于一个老项目的接口。我需要在一个同步的方法里,调用一个异步的方法拿数据。当时图省事,我没把整条调用链都改成异步,而是直接在那个异步方法后面加了个 .Result,想"同步地等一下它的结果"。代码看起来人畜无害:

// 一个异步方法
public async Task<string> GetDataAsync()
{
    // 去调用一个外部接口(异步IO)
    var response = await httpClient.GetStringAsync("https://api.example.com/data");
    return response;
}

// ★★★ 问题代码: 在同步方法里用 .Result 同步等待异步方法 ★★★
public string GetData()   // 这是个同步方法(比如老的接口/构造函数/事件处理里)
{
    // 我想"同步地拿到异步方法的结果", 就直接 .Result
    return GetDataAsync().Result;   // 💥 在 ASP.NET(旧)/WPF/WinForms 里, 这里会【死锁】!
}

这段代码,在某些环境(比如控制台程序)里可能正常,可一放到旧版 ASP.NET、WPF 或 WinForms 里,GetDataAsync().Result 这一行就直接把整个请求/界面线程永久卡死了:请求一直转圈、永不返回,界面彻底无响应,没有异常、没有超时,仿佛时间静止。我盯着这行简单的 .Result,百思不得其解:不就是"等一个异步操作完成"吗?它为什么会永远等下去?

第一件事:看清真相——.Result 阻塞了 await 完成后要回去的那个线程,互相死等

我去深入研究了 async/await 的执行机制和 SynchronizationContext(同步上下文),才终于解开这个死锁之谜——问题出在一个"互相等待"的死循环上:.Result 阻塞了当前线程去等异步任务完成,而异步任务在 await 之后,又非要回到那个已经被阻塞的线程上才能继续完成——两边互相等对方,谁也动不了

async + .Result 死锁的真相

# 关键角色: SynchronizationContext(同步上下文)
#   - 在 UI 程序(WPF/WinForms)和旧版 ASP.NET 里, 存在一个"同步上下文",
#     它代表"特定的线程"(UI线程 / 处理这个请求的线程)。
#   - 默认情况下, await 一个任务后, 后续代码(await之后的部分)会被"调度回
#     【原来的同步上下文】"去执行——即: 回到原来那个线程上继续。

# 死锁的形成(以 GetDataAsync().Result 为例):
#   1. GetData() 在【UI线程/请求线程】上运行, 走到 GetDataAsync().Result
#   2. .Result 会【阻塞当前这个线程】, 死等 GetDataAsync 这个 Task 完成
#      —— 注意: 这个线程现在被【卡住】了, 啥也干不了, 只在等。
#   3. GetDataAsync 内部 await httpClient.GetStringAsync(...)
#      —— 这个IO在后台进行, 完成后, await 之后的代码("return response")
#         需要【回到原来的同步上下文(也就是那个线程)】上去执行(默认行为)。
#   4. ★ 死锁: await 之后的代码想回到【那个线程】继续,
#      可【那个线程】正被 .Result 阻塞着、在死等 GetDataAsync 完成!
#      → GetDataAsync 要完成, 得先让 await 之后的代码跑完,
#        而 await 之后的代码要跑, 得先拿到那个被.Result占着的线程,
#        而那个线程要释放, 得等 GetDataAsync 完成……
#      → 一个【你等我、我等你】的循环等待 → 永久死锁!

# 为什么控制台程序不死锁:
#   - 控制台程序【没有】这种"必须回到特定线程"的同步上下文,
#     await 之后的代码可以在【任意线程池线程】上跑, 不需要那个被阻塞的线程,
#     所以不死锁。→ 这也是为什么这个坑"换个环境就复现/不复现", 极其迷惑。

# 核心: 在有同步上下文的环境(旧ASP.NET/WPF/WinForms), 用.Result/.Wait()同步阻塞等待
#   async方法, 会造成"线程被阻塞 vs await要回到该线程"的循环等待 → 死锁。

真相大白,我恍然大悟,又惊出一身冷汗。原来这个死锁,源于一个"你等我、我等你"的循环:第一步,GetData() 在 UI 线程/请求线程上运行,走到 GetDataAsync().Result;第二步,.Result 阻塞了当前这个线程,死等任务完成——这个线程现在被卡住、啥也干不了;第三步,GetDataAsync 内部 await 之后的代码(return response),默认要回到原来那个线程上才能执行;第四步,死锁形成——await 之后的代码想回到那个线程,可那个线程正被 .Result 阻塞着、在死等任务完成;任务要完成得先让 await 之后的代码跑完,而代码要跑得先拿到那个被占着的线程……一个谁也不让谁的循环等待,永久死锁。这也解释了为什么控制台程序里不死锁:控制台没有那种"必须回到特定线程"的同步上下文,await 之后的代码可以在任意线程池线程上跑、不需要那个被阻塞的线程——所以这个坑"换个环境就复现/不复现",迷惑性极强。

第二件事:正解——async all the way,别用 .Result/.Wait() 同步阻塞异步

搞懂了原理,正解就清晰了:异步要"一路异步到底"(async all the way)——调用异步方法就用 await,把整条调用链都改成异步;实在要在库代码里减少上下文回切,用 ConfigureAwait(false)

// ====== 正解一(根本正解): async all the way, 全程用 await ======
public async Task<string> GetDataAsync()
{
    var response = await httpClient.GetStringAsync("https://api.example.com/data");
    return response;
}

// 调用方也改成 async, 用 await(而不是 .Result)
public async Task<string> GetDataAsync_Caller()   // ★ 方法签名改成 async Task
{
    return await GetDataAsync();   // ✓ 用 await! 不阻塞线程, 不死锁
}
// 一路往上, 把整条调用链都改成 async/await:
//   Controller action 也写成 public async Task ...
//   → 这是【最正确】的做法: 异步就该从头到尾都异步。

// ====== 正解二: 库/底层代码用 ConfigureAwait(false) ======
public async Task<string> GetDataAsync_Lib()
{
    // ConfigureAwait(false): 告诉运行时"await之后【不需要】回到原来的同步上下文,
    //   随便找个线程池线程跑就行" → 就不会去抢那个被阻塞的线程 → 避免死锁
    var response = await httpClient
        .GetStringAsync("https://api.example.com/data")
        .ConfigureAwait(false);
    return response;
}
// → 库代码(不依赖UI上下文的)应普遍加 ConfigureAwait(false), 既防死锁又提性能。
//   (注意: 这只是缓解, 根本还是应该 async all the way; 且ASP.NET Core已无此上下文)

// ====== ✗ 绝对要避免的几种"同步阻塞异步"写法 ======
//   task.Result          // ✗ 阻塞 + 可能死锁(异常还会被包成AggregateException)
//   task.Wait()          // ✗ 同上
//   task.GetAwaiter().GetResult()  // ✗ 阻塞(异常不包装, 但仍可能死锁)
//   → 这些都是"sync over async", 在有上下文的环境是死锁雷区。

// ====== 如果【真的】被迫在同步上下文里调异步(没法改成async): ======
//   - 万不得已可用 Task.Run(() => GetDataAsync()).GetAwaiter().GetResult();
//     (把异步方法丢到没有上下文的线程池线程上跑, 绕开死锁——但这是下策/有开销)
//   - 根本之道还是: 想办法把调用链改成 async。

// 核心: 异步要 async all the way——调异步方法用await、整条链都改async; 别用.Result/.Wait()/
//   GetAwaiter().GetResult() 同步阻塞异步; 库代码加 ConfigureAwait(false) 缓解并提性能。

修复的核心,是"async all the way——一路异步到底,用 await 而非 .Result"正解一(根本正解):async all the way——调用异步方法就用 await,把调用方的方法签名也改成 async Task,一路往上直到 Controller action 都写成异步;这是最正确的做法,异步就该从头到尾都异步,用 await 不阻塞线程、不死锁正解二:库/底层代码用 ConfigureAwait(false)——它告诉运行时"await 之后不需要回到原来的同步上下文,随便找个线程池线程跑",就不会去抢那个被阻塞的线程、从而避免死锁;库代码应普遍加,既防死锁又提性能(但这是缓解,根本还是 async all the way;ASP.NET Core 已无此上下文)绝对避免的写法:.Result.Wait().GetAwaiter().GetResult() 这些"sync over async"(同步阻塞异步)在有上下文的环境都是死锁雷区万不得已可用 Task.Run(...).GetAwaiter().GetResult()(丢到无上下文的线程池绕开,但是下策)。归根结底:异步要 async all the way,用 await 而非 .Result/.Wait();库代码加 ConfigureAwait(false) 缓解并提性能。

第三件事:async/await 的其他常见坑

排查后我把 async/await 的其他常见坑也系统梳理了一遍,它们一样隐蔽。

async/await 的其他常见坑

# 1. .Result/.Wait() 同步阻塞异步(本文): 死锁。→ async all the way。

# 2. async void: 除事件处理器外别用 async void
#    → async void 的异常无法被catch、无法被await、调用方无法知道它完成没。
#    → 一律用 async Task(无返回)或 async Task。

# 3. 忘记 await: 调了异步方法但没await, 任务在后台"飞"了(fire and forget)
#    → 异常丢失、执行顺序错乱、可能没执行完就继续了。→ 该await就await。

# 4. 在循环里 await(本可并行): foreach 里逐个 await, 串行很慢
#    → 若任务互相独立, 用 Task.WhenAll 并行: await Task.WhenAll(tasks)。

# 5. async 方法里用了阻塞调用: async方法内又 Thread.Sleep / .Result
#    → 占着线程不放, 失去异步的意义。→ 用 await Task.Delay 等异步版本。

# 6. CPU密集型用async: async/await 是为【IO密集】设计的(等待时释放线程)
#    → CPU密集计算用 async 没好处, 该用 Task.Run 丢到线程池。

# 7. 没传/没响应 CancellationToken: 长异步操作无法取消。
#    → 异步方法接受并传递 CancellationToken。

# 共同根源: async/await 是一套关于"如何在等待IO时不阻塞线程"的协作机制;
#   很多坑都源于"用同步的思维/写法去对待异步"(阻塞等待、忽略Task、串行化)。

# 核心: async/await为IO等待而生, 要全程异步、用await别阻塞、独立任务用WhenAll并行、
#   别用async void、别忘await、CPU密集用Task.Run; 核心是"用异步的思维写异步"。

排查让我把 async/await 的其他坑也梳理清了。一、.Result/.Wait() 同步阻塞(本文)。二、async void(除事件处理器外别用,异常无法 catch、调用方无法知道它完成,一律用 async Task)。三、忘记 await(任务在后台"飞"了、异常丢失)。四、循环里逐个 await(本可并行的独立任务,用 Task.WhenAll)。五、async 方法里用阻塞调用(Thread.Sleep/.Result,失去异步意义)。六、CPU 密集型用 async(async 为 IO 密集设计,CPU 密集该用 Task.Run)。七、没传 CancellationToken(无法取消)。它们的共同根源是:async/await 是一套关于"如何在等待 IO 时不阻塞线程"的协作机制;很多坑都源于"用同步的思维/写法去对待异步"核心是:async/await 为 IO 等待而生,要全程异步、用 await 别阻塞、独立任务用 WhenAll 并行;核心是"用异步的思维写异步"下面这张图,是这次 .Result 死锁的成因与解法:

第四件事:同步等待异步的几种写法对照

这次踩坑后,我把"从异步拿结果"的几种写法整理成一张表,写代码时对照避雷。

写法 是否阻塞线程 有上下文环境 评价
await task 否(释放线程) ✓ 安全 ✓ 唯一正解
task.Result ✗ 死锁 ✗ 避免; 异常还被包装
task.Wait() ✗ 死锁 ✗ 避免
task.GetAwaiter().GetResult() ✗ 仍可能死锁 ✗ 避免(异常不包装而已)
Task.Run(()=>asyncFn).Result 是(但绕开上下文) △ 不死锁但占线程 下策, 万不得已
ConfigureAwait(false)+阻塞 △ 缓解 缓解, 非根治

这张表把"怎么从异步拿结果"的对错钉死了。核心是:唯一真正正确的方式是 await(它不阻塞线程、安全);所有"同步阻塞着去等异步"(.Result/.Wait/GetResult)的写法,在有同步上下文的环境里都是死锁雷区它给我的最大启发是:异步和同步,是两套从根本上不兼容的执行模型;在它们的边界上(同步代码要调异步、或反之),是最容易出问题的地方;而最安全的做法,是不要去跨越这个边界、不要试图把异步"变回"同步,而是让异步"一路传染下去"(async all the way),让整条链保持在同一个模型里这其实揭示了一个关于"异步"的本质特点:"异步"具有一种"传染性"——一旦某个底层操作是异步的,调用它的整条链路最好都跟着异步;强行在某一层把它"同步化"(用 .Result 等),不仅别扭,还会引入死锁、性能等一系列问题所以,与其在边界上和异步"较劲"(想方设法把它变回同步),不如顺应它的传染性,让异步贯穿始终。理解异步的传染性、不在边界上强行同步化、而是 async all the way——是和 async/await 和平共处的根本心法。

第五件事:async/await 到底为我们解决了什么

这次也让我重新理解了 async/await 的设计初衷——它不是为了"多线程并行",而是为了"等待时不浪费线程"。

对比 同步阻塞IO 异步IO(async/await)
等IO时线程在干嘛 傻等, 被占着啥也不干 释放回线程池, 去干别的
高并发下线程数 每个请求占一个线程, 线程爆 少量线程服务大量并发
资源利用率 低(线程都在等) 高(线程不浪费在等待上)
吞吐量 受线程数限制 高得多
适用场景 IO密集(网络/文件/DB)

这张表道出了 async/await 的真正价值核心是:async/await 解决的核心问题,是"在等待 IO(网络、磁盘、数据库)的那段时间里,不要让线程傻等着被白白占用",而是把线程释放回去服务别的请求;它让"少量线程"能服务"大量并发的 IO 请求",极大提升了资源利用率和吞吐量它给我的最深启发是:很多人(包括曾经的我)对 async/await 有一个误解,以为它是"让代码并行跑得更快"的多线程工具;但它的本质其实是"提高线程这种宝贵资源的利用效率"——通过"在等待时让出线程"这个机制,避免线程被白白浪费在"干等"上理解了这个本质,很多事情就豁然开朗:为什么 async 适合 IO 密集(IO 有大量"等待"可以让出)、不适合 CPU 密集(CPU 一直在算、没有可让出的等待);为什么 .Result 阻塞线程是反模式(它恰恰是把"本该让出的线程"又给死死占住了,完全违背了 async 的初衷)这让我明白:用好一个工具的前提,是理解它"到底为解决什么问题而生";只有抓住了这个"设计初衷",你才能判断"什么场景该用它、怎么用才对、什么用法是在和它的初衷作对"抓住"async 是为'等待时不浪费线程'而生"这个初衷——是真正用对、用好异步编程的钥匙。

第六件事:用异步时,我现在的判断习惯

现在每当我要调用一个异步方法,我都会按这张图先想清楚:

这张图的精髓,是"能 async 就 async all the way,绝不用 .Result 同步阻塞"当前方法能改 async 就改、用 await 调用、一路往上整条链都异步;实在不能(构造函数/老接口)的事件处理器可用 async void 但要 try-catch,否则尽量重构、万不得已才 Task.Run 绕开库代码加 ConfigureAwait(false),独立任务用 Task.WhenAll 并行。这套习惯,让我用异步时,从"图省事 .Result 一把梭"变成了"让异步一路贯穿、绝不强行同步化"——核心始终是:异步有传染性,async all the way,用 await 不阻塞,绝不 .Result/.Wait() 同步等异步。

我立下的几条规矩

这场".Result 永久死锁"的事故,换来了我写 C# 异步时,刻进骨子里的几条铁律:

  1. 绝不用 .Result/.Wait()/GetResult() 同步等异步。有上下文环境就是死锁。
  2. async all the way。调异步用 await,整条链都改成 async。
  3. 理解异步的传染性。别在边界上强行把异步变回同步。
  4. 库代码加 ConfigureAwait(false)。防死锁、提性能。
  5. 别用 async void(事件除外)。用 async Task,否则异常无处可抓。
  6. 独立任务用 Task.WhenAll 并行。别在循环里逐个 await。
  7. 记住 async 为"等待时不浪费线程"而生。抓住初衷才能用对。

附:一段能亲手复现 .Result 死锁、并验证修复的代码

口说无凭。下面这段代码,模拟了"有同步上下文"的环境,让你能亲手复现这个死锁、再亲眼验证 async all the way 的修复:

using System;
using System.Threading;
using System.Threading.Tasks;

class DeadlockDemo
{
    // 模拟一个异步IO操作
    static async Task<string> FetchAsync()
    {
        await Task.Delay(500);   // 模拟网络IO等待(await后默认要回到原上下文)
        return "data";
    }

    // ✗ 错误版: 同步阻塞等异步
    static string GetSync()
    {
        return FetchAsync().Result;   // 在有同步上下文时, 这里会死锁
    }

    // ✓ 正确版: async all the way
    static async Task<string> GetAsync()
    {
        return await FetchAsync();    // 用 await, 不阻塞, 不死锁
    }

    static void Main()
    {
        // 装一个"单线程同步上下文"来模拟 WPF/WinForms/旧ASP.NET 的环境
        // (控制台默认没有上下文, 不装的话复现不出来——这正是此坑的迷惑点!)
        var ctx = new SingleThreadSyncContext();
        SynchronizationContext.SetSynchronizationContext(ctx);
        ctx.Post(async _ =>
        {
            // 在"有上下文"的线程里:
            // ✗ 取消下面这行的注释, 会【死锁】, 程序卡住永不结束:
            // var bad = GetSync();   // ← DEADLOCK!

            // ✓ 正确写法, 顺利完成:
            var good = await GetAsync();
            Console.WriteLine("拿到结果: " + good);   // 正常打印
            ctx.Complete();
        }, null);
        ctx.RunOnCurrentThread();   // 运行这个单线程上下文的消息循环
    }
}

// (SingleThreadSyncContext 是一个简单的单线程同步上下文实现, 用一个队列+循环模拟
//  "所有await回调都必须排队回到这同一个线程执行"——正是它制造了死锁的条件。)

// 核心: 想亲眼看死锁, 必须在"有同步上下文"的环境复现(控制台默认没有, 所以平时不易察觉);
//   把 GetSync().Result 换成 await GetAsync(), 死锁立刻消失——这就是 async all the way 的力量。

这段可复现的代码,是我这次踩坑后觉得最有教育意义的一份材料。它最大的价值,是戳破了这个坑最迷惑人的那一层伪装:很多人(包括当初的我)在控制台里写 .Result 一切正常,就误以为 .Result 没问题,直到搬到 WPF/旧 ASP.NET 才中招、还百思不得其解。这段代码手动装上了一个"单线程同步上下文",把"所有 await 回调都必须排队回到同一个线程"这个制造死锁的关键条件显式地、可控地复现了出来——于是你能在一个小小的控制台程序里,亲手让 .Result 死锁、再亲眼看着 await 让它消失。这正是我想用这段代码,留给每个学异步的人的核心方法:对于那些"依赖特定运行环境才会出现"的、迷惑性极强的 bug(死锁、并发竞态、上下文相关行为),理解它的最好方式,是想办法把'触发它的关键条件'显式地、最小化地复现出来——一旦你能主动地、稳定地复现一个问题,你就从"被它随机折磨"变成了"能研究它、理解它、并验证修复"因为"能稳定复现"是"真正理解和解决一个问题"的前提;一个你只能"偶尔遇到、无法主动触发"的 bug,你对它的认识永远是模糊和被动的;而当你能亲手搭出最小复现、一遍遍地让它发生和消失时,它背后的机制就会在你面前彻底透明把"难以捉摸的环境相关 bug",通过"显式复现其触发条件"变成"可掌控的研究对象"——这份"主动构造最小复现"的能力,是我整个踩坑系列里,攻克一切疑难问题最锋利的武器。

写在最后

回头看,这场由"一个 .Result"引发的、请求永久死锁的事故,真正教给我的,远不止"别用 .Result"这一个禁忌。它让我对"异步编程"这个看似只是"加几个关键字"、实则有着完全不同思维模型的范式,有了一次脱胎换骨的理解。我栽跟头,根源是我把 async/await 当成了一个"语法糖"、一个"能让我同步地拿到异步结果的便利写法",而完全没有理解它背后那套"协作式、非阻塞、可能跨线程"的执行模型。我用一种"同步思维"(我要在这里停下来、等它、拿到结果、再继续)去使用一个"异步机制"(它的设计前提恰恰是"不要停下来死等,而是让出去、完成后再回来")——这种"思维和机制的根本错配",正是死锁的深层原因。这让我领悟到一个深刻的认知:当你学习一个新的"编程范式"(异步、函数式、响应式、并发……)时,如果只学了它的"语法(关键字、API)",而没有理解它背后那套"思维模型(它假设世界如何运转、它解决问题的根本方式)",你就会不自觉地用"旧范式的思维"去套"新范式的语法",写出那种"语法上对、思维上错"的、会以诡异方式出问题的代码这其实是学习任何新范式的关键:"学语法"只是表层,"切换思维模型"才是核心;真正掌握一个范式,意味着你能用它的方式去思考问题,而不只是用它的关键字去写代码;而在你完成这个思维切换之前,那些"用旧思维套新语法"的坑(比如用同步思维写异步),几乎是必经的"成人礼"透过语法、去真正理解和切换到一个范式背后的思维模型——这,是我用一次死锁的事故,换来的、关于 C# 异步、也关于如何学习任何新编程范式的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次手痒想写 .Result 时,先停一下、改成 await 并把方法变成 async,那我对着那个永远转圈的请求干瞪眼的这大半天,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

用户只点了一次提交,因为网络超时客户端自动重试,结果同一笔订单被下了两次、款也扣了两次,我对着接口没做幂等设计这个坑排查大半天的复盘

2026-6-2 10:25:03

技术教程

我把一个商品 ID 错传给了需要用户 ID 的函数,TypeScript 全程绿灯没有半点警告,直到线上查错了数据才暴露,我对着结构化类型让语义不同的类型随意互换这个坑排查大半天的复盘

2026-6-2 10:36:07

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索