ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++ LSP流量控制:令牌桶与有界队列实现防雪崩调度

C++ LSP流量控制:令牌桶与有界队列实现防雪崩调度

简介:这是一份面向 Windows 网络编程开发者的 C++ LSP 流量控制示例工程,围绕 Layered Service Provider 分层服务提供者机制,演示如何从 LSP 层拦截、分析网络数据包,并对数据包实施自定义加密处理。代码覆盖 LSP 的安装、映射、删除与数据包管理流程,同时涉及异步选择、重叠 I/O、扩展函数等 SPI 关键环节,适合想深入理解 Winsock 底层拦截、做本地流量管控或透明加密的读者参考。压缩包共 63 个文件,以 25 个 cpp 源文件、12 个 h 头文件为主体,配合 vcproj/dsp/dsw/sln 等工程文件与 makefile,可同时用 Visual Studio 和命令行构建;另含说明文档与资源脚本,整体约 194KB,结构按 install、common、ifslsp、nonifslsp 等模块划分,便于按需阅读。目前已有 714 人学习下载。通过这些代码,可以拿到一套可编译的 LSP 框架、四类模块的协作关系,以及从 SPI 函数挂接到数据包加解密的完整思路,便于后续做协议分析、流量过滤或安全加密方面的二次开发。

1. 整套C++ LSP流量控制代码:先看懂它为什么不是普通限速器

整套C++ LSP流量控制代码解决的是这样一个场景:你在一个几万行的工程里写代码,保存一个文件,编辑器背后的语言服务器瞬间收到几十条诊断通知,紧接着又有补全、跳转、格式化等请求涌进来,处理不过来时,编辑器和服务器会双双卡住,直到进程被系统杀掉。这套代码不是简单的每秒限几条消息,而是把LSP(Language Server Protocol)里的请求、响应、通知分开对待:请求不能丢,通知可以合并,响应必须最优先,再用令牌桶和有界队列让吞吐平滑下来。适合正在做语言服务器端集成、自研代码分析工具、或者想给现有LSP服务加一层防雪崩能力的人读。

这里说的LSP特指Language Server Protocol,不是网络领域里那个容易混淆的同名缩写。做语言服务器的人都知道,LSP消息跑在stdio或TCP通道上,本质是JSON-RPC格式的字节流,补全、跳转、诊断都靠它转发。这套流量控制模块,做的就是应用层这一层的“闸门”。

2. 项目定位与技术底座:LSP消息帧、请求/通知语义与限流边界

2.1 流量控制在LSP里到底卡在哪一环

语言服务器的流量特征和普通Web服务很不一样。Web接口是“用户点一下、后端响应一次”,频率低但单次耗时高;LSP则是一台编辑器同时打开几十个文件,每个文件的保存、键入、光标移动都会触发消息,一个项目全量重新诊断就能在几秒内灌进来上千条notification。这些消息进入服务器后,要走解析、索引、符号表查询、诊断计算等一系列CPU密集步骤,处理速度往往跟不上到达速度。

卡顿的根源不是某一条消息慢,而是消息队列没有上限。TCP缓冲区堆满之后,内核把数据留在socket缓冲里;用户态如果再开一个无限增长的std::deque,内存就会一路涨上去。最终结果就是语言服务器进程被OOM杀掉,编辑器显示“服务器已崩溃”。流量控制要解决的,就是把“无限积压”改成“有损耗地平滑处理”:限制单位时间流入量,限制缓冲队列长度,对可丢失消息做合并与丢弃。

我拆这套代码时最直观的感受是,作者把流量控制做在了两个层级上。第一层是令牌桶,决定当前这一秒放行多少条消息;第二层是有界队列,决定放行后但还没处理完的消息最多占多少内存。这两层缺一不可,只做限流不做队列限制,突发流量照样会把内存打爆;只做队列限制不做限流,队列反复满、消息反复丢,用户体验会非常差。

2.2 目录结构与模块划分

这套资源拿到的目录结构并不复杂,核心代码大概可以归纳成下面这样的布局:

lsp_flow_control/ ├── CMakeLists.txt # 构建入口,C++17 ├── config/ │ └── flow_control.json # 速率、容量、队列上限配置 ├── include/ │ ├── lsp_frame.h # LSP 帧读取与消息分类 │ ├── token_bucket.h # 令牌桶限流器 │ ├── message_queue.h # 有界队列,背压实现 │ └── dispatcher.h # 消息分流与调度 ├── src/ │ ├── lsp_frame.cpp │ ├── token_bucket.cpp │ ├── message_queue.cpp │ └── dispatcher.cpp └── tests/ ├── mock_lsp_client.cpp # 批量造消息,压测入口 └── frame_parse_test.cpp # 粘包/半包单测

拆这种项目我一般不会按文件名从头读到尾,而是先读dispatcher,再做逆向阅读。原因是dispatcher把帧解析、令牌桶、有界队列全部串在一起,是整个仓库的主干流程;先把主干读通,再回头看不限流组件的内部实现,会顺很多。lsp_frame是边界解析,token_bucket和message_queue是被调度器依赖的底层能力,config则是把参数暴露出来让接入方免改代码。

没有第三方依赖,这是这套代码另一个值得点赞的地方。限流和队列都属于通用基础能力,用标准库的chrono、mutex、deque就能写干净,不需要引Boost或者其它重量级依赖。对二次开发来说,这意味着拿回来就能编,不用先解决依赖地狱。

2.3 消息分类与配置策略

LSP的JSON-RPC消息分三类,分类是流量控制策略的根本依据,因为三类消息的丢弃代价完全不同:

消息类型是否需要响应丢弃后果处理优先级
request是,需返回response客户端超时并重发,放大负载高
response否对应请求的等待方永远挂起最高
notification否客户端下次推送会覆盖旧值低

request不能随便丢,因为客户端等不到响应会按协议超时重发,一条丢了的请求可能变成三条重发请求,流量反而更大;response也不能丢,丢了会让上游请求永远挂起,内存泄漏随之而来;只有notification是相对“廉价”的消息,比如textDocument/diagnostic这种诊断通知,错过一条,客户端下次推送会带最新状态,所以可以合并且丢弃。

config/flow_control.json里暴露的配置项也围绕这三类消息展开:

{ "rate_per_second": 200, "bucket_capacity": 200, "queue_max": 1024, "merge_window_ms": 60, "drop_policy": "merge_and_drop" }

rate_per_second和bucket_capacity是令牌桶参数,决定平稳吞吐和最大突发量;queue_max是有界队列上限,决定积压消息的内存占用上限;merge_window_ms是通知合并窗口,比如60毫秒内同一方法的诊断通知只保留最新一条;drop_policy则指定队列满时的行为,常见取值有drop_oldest、keep_newest、requeue三类。后面的核心代码和运行验证,全都围绕这套配置展开。

3. 核心模块拆解:一个诊断通知从进入到被放行的完整链路

3.1 帧读取与粘包处理

流量控制能工作,前提是先把完整的LSP消息从TCP字节流里切出来。LSP的消息帧格式是头部加body,头部用\r\n分割字段,空行后是JSON-RPC的body。实际网络收包常常一次只收到半个头部,或者一次收到三条完整消息,所以帧读取必须按“累积-解析-留下剩余”的方式做。

// lsp_frame.h #pragma once #include <cstdint> #include <string> struct LspFrame { std::string method; // 方法名,例如 textDocument/diagnostic std::string type; // request | response | notification std::string body; // JSON-RPC 完整消息体 uint32_t message_id = 0; // 请求 ID,通知类为 0 }; // 从 socket 缓冲区读取一帧完整 LSP 消息 // buffer:底层读到的字节流 // pos:当前解析位置,函数执行后移动到帧末尾 bool read_lsp_frame(const std::string& buffer, size_t& pos, LspFrame& out);
// lsp_frame.cpp #include "lsp_frame.h" bool read_lsp_frame(const std::string& buffer, size_t& pos, LspFrame& out) { // 1. 找头部结束标记 \r\n\r\n size_t header_end = buffer.find("\r\n\r\n", pos); if (header_end == std::string::npos) return false; // 半包,继续等 // 2. 解析 Content-Length std::string header = buffer.substr(pos, header_end - pos); size_t len_pos = header.find("Content-Length:"); if (len_pos == std::string::npos) return false; int content_length = std::stoi(header.substr(len_pos + 15)); // 3. 检查 body 是否已经到齐 size_t body_start = header_end + 4; if (buffer.size() < body_start + content_length) return false; // 仍是半包 // 4. 按 JSON-RPC 字段拆出 method / type / id out.body = buffer.substr(body_start, content_length); parse_jsonrpc_fields(out.body, out.method, out.type, out.message_id); pos = body_start + content_length; // 更新游标 return true; }

这段代码是典型的“游标式”帧解析。第一步找\r\n\r\n,是为了确认头部结束;第二步拿Content-Length;第三步比较buffer剩余长度和content_length,如果不够说明TCP半包还没到齐,返回false让外层继续累积;第四步才真正切body并做JSON-RPC字段拆分。pos是传入传出参数,解析成功后指向下一帧的起始位置。

常见做法是把这个函数包在循环里,一次read收上来的buffer可能包含多帧,循环调用直到返回false,这样能把同一批到达的消息全部取出来,避免漏帧。严格按RFC做,头部字段名是不区分大小写的,正式项目里应该逐行转成小写再匹配,示例为了短用了find,接入时注意补一个case-insensitive比较。parse_jsonrpc_fields是内部拆JSON的辅助函数,实际工程里会封装成独立工具,把method、type、id三个字段一次提取完。

3.2 令牌桶限流器实现

限流器选了令牌桶而不是漏桶,是考虑语言服务器“允许突发、限制长期速率”的特征。用户可能在停了几分钟之后突然保存一个巨型文件,这时候需要一次性放行一批诊断计算,令牌桶允许这种突发,只要桶里有积攒的令牌就能放行;漏桶会把突发压成恒定速率,用户会明显感觉到补全变慢。

// token_bucket.h #pragma once #include <chrono> #include <cstdint> class TokenBucket { public: TokenBucket(double rate, double capacity) : rate_(rate), capacity_(capacity), tokens_(capacity), last_refill_(std::chrono::steady_clock::now()) {} // 尝试消费 1 个令牌;消费成功返回 true,失败返回 false bool try_consume(); // 返回当前可用令牌数,调试用 double available() const { return tokens_; } private: void refill(); // 按时间差补充令牌 double rate_; // 每秒新增令牌数 double capacity_; // 桶容量,决定最大突发量 double tokens_; // 当前令牌数 std::chrono::steady_clock::time_point last_refill_; };
// token_bucket.cpp #include "token_bucket.h" #include <algorithm> using namespace std::chrono; void TokenBucket::refill() { auto now = steady_clock::now(); double elapsed_sec = duration_cast<duration<double>>(now - last_refill_).count(); last_refill_ = now; tokens_ = std::min(capacity_, tokens_ + elapsed_sec * rate_); } bool TokenBucket::try_consume() { refill(); if (tokens_ < 1.0) return false; // 令牌不足,本次不放行 tokens_ -= 1.0; return true; }

refill函数是令牌桶的核心,它不依赖定时器,而是用“最后一次补充时间”和当前时间的差来计算这段时间生成了多少令牌,再把令牌数封顶到capacity。这里有一个技术细节:时间源必须用steady_clock而不是system_clock。system_clock会被NTP校准或用户手动改时间,一旦系统时间往前跳,elapsed_sec会算成负数,限流直接失效;steady_clock是单调时钟,只增不减,适合做速率计算。

参数设计上,rate单位是“消息/秒”,决定长期吞吐;capacity决定突发容量,一般建议设为rate的一半或等值。比如rate=200、capacity=200,意味着服务器可以在空闲后一口气放行200条消息,之后稳定在每秒200条以内。如果把capacity设成远大于rate,突发窗口会过长,队列照样会满。

3.3 有界队列与背压策略

令牌桶决定“放不放行”,但放行之后的消息还得有个缓冲地带,否则线程池一旦繁忙,消息会直接丢在调用栈里。这里用了有界队列,而且刻意设计成非阻塞语义:

// message_queue.h #pragma once #include <deque> #include <mutex> #include <optional> template <typename T> class BoundedQueue { public: explicit BoundedQueue(size_t max_size) : max_size_(max_size) {} // 入队:队列未满返回 true,满则返回 false(由调用方决定丢弃或重试) bool try_push(T item) { std::lock_guard<std::mutex> lk(mutex_); if (queue_.size() >= max_size_) return false; queue_.push_back(std::move(item)); return true; } // 出队:空队列返回 nullopt std::optional<T> try_pop() { std::lock_guard<std::mutex> lk(mutex_); if (queue_.empty()) return std::nullopt; T front = std::move(queue_.front()); queue_.pop_front(); return front; } size_t size() const { std::lock_guard<std::mutex> lk(mutex_); return queue_.size(); } private: std::deque<T> queue_; std::mutex mutex_; size_t max_size_; };

为什么不用condition_variable做阻塞队列?因为队满时阻塞push会把网络读线程卡住,客户端持续发消息时,TCP接收缓冲区会接着堆,问题只是从用户态队列转移到了内核态缓冲区,排查起来更难,内存压力也没有真正消除。try_push返回false后,上层调用方可以根据消息类型决定是丢弃、合并还是等待重试,把决策权留给业务层。

这个队列选std::deque而不是std::vector,是因为消息频繁从头部弹出,deque的头部插入删除是常数时间,vector在头部pop_front会搬移所有元素。互斥锁用std::lock_guard而不是unique_lock,因为try_push只在临界区走一次,不需要解锁再锁的条件等待能力。

3.4 调度入口:分流、重试、合并与丢弃

dispatcher是整个模块的调度中枢,它把帧解析、令牌桶、有界队列、线程池串成一条流水线。核心逻辑按消息类型分叉:

// dispatcher.cpp —— 主流程摘录,worker_pool_ 是内部线程池封装 bool Dispatcher::on_message(LspFrame& frame) { // 1. 分流:response 优先,request 其次,notification 最后 if (frame.type == "response") { pending_responses_.try_push(std::move(frame)); return true; } // 2. request 走令牌桶;失败时进入重试队列,最多重排 2 次 if (frame.type == "request") { if (!bucket_.try_consume()) { return try_requeue_request(frame); // 内部对 message_id 去重 } return worker_pool_.submit(std::move(frame)); } // 3. notification:可丢弃,先做窗口合并再入队 if (frame.type == "notification") { if (merge_window_.should_merge(frame.method)) return true; if (notification_queue_.try_push(std::move(frame))) return true; return false; // 队列满直接丢 } return false; }

这段代码体现了三条关键取舍。response直接入高优队列,因为它是上游请求的“结果”,延迟它只会让上游内存占用增加;request被令牌桶拦住后不是简单丢弃,而是走重试队列,并且按message_id去重,避免同一请求被堆积多个副本;notification先经过merge_window做窗口合并,比如60毫秒内同一方法只保留最新一条,合并之后才尝试进入有界队列。

merge_window_是通知合并器,内部按method维度记录最后一次推送时间,如果当前时间和上一次间隔小于merge_window_ms,直接返回true表示“这条已经被最新状态覆盖”。这个设计非常贴合诊断类通知的语义:编辑器要的是“最新诊断结果”,不是“每一份历史诊断结果”。调度层的参数对应config里的drop_policy和queue_max,前者是业务策略,后者是内存边界。

4. 构建与运行:从CMake到一次真实的请求压测

4.1 构建工程

这套代码不依赖第三方库,用CMake组织构建,C++17标准就够。下面是一个最小可用的CMakeLists写法,把限流库编成静态库,再单独出一个mock客户端可执行文件:

cmake_minimum_required(VERSION 3.16) project(lsp_flow_control CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Threads REQUIRED) add_library(lsp_fc src/lsp_frame.cpp src/token_bucket.cpp src/message_queue.cpp src/dispatcher.cpp) target_include_directories(lsp_fc PUBLIC include) add_executable(mock_lsp_client tests/mock_lsp_client.cpp) target_link_libraries(mock_lsp_client PRIVATE lsp_fc Threads::Threads)

编译前先建build目录,然后执行标准CMake流程:

mkdir -p build && cd build cmake -DCMAKE_BUILD_TYPE=Release .. cmake --build . -j ./mock_lsp_client --config ../config/flow_control.json

这里有几个容易踩的小地方。第一,C++17标准必须打开,std::optional和嵌套命名空间都依赖它;第二,find_package(Threads)不能省,因为mock客户端用了std::thread,老一些的glibc下不链pthread会直接编译报错;第三,Release模式对这类限流代码性能影响不小,调试期用Debug没关系,压测一定用Release,否则结果失真。

如果不想用CMake,直接命令行也能编,核心库就几个cpp文件,一条g++命令能搞定。但二次开发大概率会加新文件,还是建议直接用CMake,后续加模块不用维护一长串编译命令。

4.2 配置项说明与调参建议

配置文件里的每个参数都对应代码里一个明确的位置,调参前先搞清楚它们各自影响什么:

配置项默认值作用调参建议
rate_per_second200每秒放行消息数先压测,观察后端CPU再调
bucket_capacity200令牌桶容量,突发上限一般取rate的50%到100%
queue_max1024有界队列上限内存紧张时调到512
merge_window_ms60通知合并窗口低配机器可以调到150
drop_policymerge_and_drop队列满时的处理策略通知用keep_newest更稳

rate_per_second是全局放行速率,不是某个方法单独的速率,也就是说100条diagnostic和100条completion会共享这200的配额。如果某个方法特别重要,可以在二次开发时改成按method分别建桶,后面的改造章节会讲到。queue_max决定积压消息占用的最大内存,一条LSP消息几百字节到几十KB不等,1024条在最坏情况下可能占用几十MB内存,内存敏感的嵌入场景要调小。

merge_window_ms这个参数容易被忽略,它直接影响用户体验。窗口太小,合并效果不明显;窗口太大,诊断结果推送延迟变长。60毫秒一般够用,低配机器上可以调到150毫秒,让同一文件的高频诊断通知合并得更激进一点。

4.3 模拟客户端验证流量控制效果

验证流量控制不能靠肉眼看,需要批量压测。tests/mock_lsp_client.cpp的发送循环用8个线程同时灌消息,模拟编辑器同时打开多个文件、多个诊断请求并发的场景:

// tests/mock_lsp_client.cpp —— 核心发送循环 #include <atomic> #include <iostream> #include <thread> #include <vector> int main() { const int kTotal = 10000; // 模拟 1 万条消息 const int kThreads = 8; // 8 个并发发送线程 std::atomic<int> sent{0}; std::vector<std::thread> workers; auto send = [](const std::string& method) { // 真实接入时,这里把 JSON-RPC 帧写入 socket / stdin return true; }; for (int t = 0; t < kThreads; ++t) { workers.emplace_back([&send, &sent] { for (int i = 0; i < kTotal / kThreads; ++i) { send("textDocument/diagnostic"); sent++; } }); } for (auto& w : workers) { w.join(); } std::cout << "sent=" << sent.load() << "\n"; return 0; }

发送线程只是制造压力,真正的观察点在服务端日志。跑完压测后,在服务端日志里统计三类action的出现次数:

grep "action=accept" lsp.log | wc -l grep "action=merge" lsp.log | wc -l grep "action=drop" lsp.log | wc -l

三组数字分别代表被放行处理的消息数、被合并窗口吞掉的通知数、因为队列满被丢弃的通知数。如果drop数量为0而merge数量很大,说明当前限流参数偏保守;如果drop数量占比很高,说明发送速率远超后端处理能力,要么调小rate,要么优化后端处理逻辑。注意一点,传入的1万条消息里如果混有request,drop数必须为0,request走的是重试队列,丢任何一条都说明有bug。

5. 避坑与排查:LSP流量控制现场最常见的5个翻车点

5.1 诊断通知刷屏导致内存无上限上涨

现象:打开大工程后,语言服务器进程内存线性上涨,最后被系统杀掉。

原因:限流只卡了令牌,没卡队列。令牌桶每秒放行200条,但后端消费速度只有每秒80条,积压消息全堆在无界队列里,内存自然无上限。

解决:有界队列必须和令牌桶同时生效。queue_max设一个硬上限,通知类消息队列满时直接丢弃;同时打开merge_window_ms窗口,把同一文件的重复诊断合并成一条。排查时先看日志里queue_size是不是长期接近上限,如果是,优先加merge窗口长度,而不是加queue_max。

5.2 请求超时重发风暴

现象:编辑器里同一个操作触发多次补全请求,服务端收到大量重复的textDocument/completion,日志显示同一message_id出现多次。

原因:request被令牌桶拦下来后直接丢了,LSP客户端等不到响应,按协议超时重发。一条被丢弃的请求变成三条重发请求,流量被放大三倍。

解决:request不能走丢弃逻辑。调度器里对request单独开一条重试队列,按message_id去重,同一个id最多排一次队。如果重试队列也满,则直接返回JSON-RPC错误响应而不是沉默,至少让客户端知道服务端过载。

5.3 Content-Length粘包与半包解析乱码

现象:帧解析时而正常时而乱码,打印出来的body开头是半个汉字,JSON解析报错。

原因:TCP是字节流,一次read可能只收到半条消息,也可能一次收到三条完整消息。如果代码直接在原始buffer上find头部并stoi Content-Length,半包时会导致解析错位。

解决:用带游标的累积解析,read上来的数据先拼进一个持续buffer,再循环调用read_lsp_frame,直到返回false。Content-Length必须按字节数截取body,不能用strlen,因为body可能包含中文和emoji,strlen按字符数算会切坏UTF-8序列。

5.4 多线程并发写通道顺序乱掉

现象:加了线程池并发处理后,补全结果和跳转结果偶尔对应不上,同一个文件的两次诊断顺序颠倒。

原因:多个worker同时从队列里取消息并发处理,但LSP的部分操作有顺序依赖,比如先open文件再请求completion。并发处理时open还没执行完,completion就抢先跑了。

解决:按文档URI做分片,同一文档的消息哈希到同一个worker,让同一文档的请求串行执行。常见做法是写一个document_uri的哈希函数,worker数量做成可配置项,每个URI固定路由到worker_id = hash(uri) % worker_count。这样可以保持跨文件并行,同时保住单文件内的顺序。

5.5 限流参数调大反而更卡

现象:把rate_per_second从200调到500,把bucket_capacity调到500,结果服务器更卡了,响应时间明显变长。

原因:限流的瓶颈不在网络层,而在后端处理耗时。放行更多消息意味着同时有更多诊断计算在跑,CPU核数没变,单条消息处理时间没变,更多的并发只是让队列里的消息等得更久。

解决:先统计后端平均单条消息处理耗时,比如压测得出平均8毫秒,那么单核每秒最多处理125条。rate不要超过可用核数乘以单位时间处理能力,capacity再取rate的一半到三分之二。限流参数要从后端处理能力反推,不能直接从流量峰值反推。

6. 二次开发:把流量控制换成你自己项目的策略

6.1 把限流策略换成可插拔的策略接口

原代码的令牌桶是硬编码在dispatcher里的,改动时会发现限流逻辑和调度逻辑耦合在一起。我通常的做法是抽一个RateLimitStrategy接口,把令牌桶和滑动窗口都挂到同一套接口下:

// include/rate_limit_strategy.h class RateLimitStrategy { public: virtual ~RateLimitStrategy() = default; virtual bool allow(const LspFrame& frame) = 0; }; class TokenBucketStrategy final : public RateLimitStrategy { public: TokenBucketStrategy(double rate, double capacity); bool allow(const LspFrame& frame) override; }; class SlidingWindowStrategy final : public RateLimitStrategy { public: SlidingWindowStrategy(std::chrono::milliseconds window, size_t limit); bool allow(const LspFrame& frame) override; };

改造dispatcher时,把原来直接调用bucket_.try_consume()的位置换成strategy_->allow(frame),构造函数里根据config的strategy字段创建对应的策略对象。这样后续想换成漏桶、随机丢弃、或者按文件优先级限流,都只需要新增一个实现类,不需要再动调度主流程。

6.2 按方法名配置不同速率并验证

config里的全局rate只能管总量,但真实场景里diagnostic和completion的优先级不同,前者可以合并,后者用户等着看结果。改造方向是给策略map按method配置独立限流器:

std::unordered_map<std::string, std::unique_ptr<RateLimitStrategy>> strategies_; strategies_.emplace("textDocument/diagnostic", std::make_unique<TokenBucketStrategy>(100, 100)); strategies_.emplace("textDocument/completion", std::make_unique<TokenBucketStrategy>(50, 50));

调度时先查map,命中就用方法专属策略,没命中就回落全局策略。验证方式也简单:单测里构造1秒内100条diagnostic和100条completion,分别统计放行数,确认两组速率互不干扰。从那以后我每次接到LSP相关改造,都会先画一张“消息生产、消费、丢弃”三态图再动代码,这个习惯帮我避开了好几次把request当notification丢掉导致的超时风暴,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进