简介:面向C#与BeetleX开发者,这是一份基于JT808协议实现的通信示例项目,覆盖服务端与客户端完整交互场景,适合从事车载GPS监控、物联网接入或需要处理JT808报文的开发者。压缩包共57个文件,以35个C#源码文件为主,配套项目工程、配置文件、测试用例及Beetle.Express.dll依赖库,整体大小仅100KB,结构轻量便于快速阅读。已有291人学习下载,具备一定参考热度。通过该项目可了解JT808协议的消息结构与二进制编解码思路,学习BeetleX框架下的订阅式通信封装,并可直接运行调试服务端、客户端及单元测试工程,适合希望在实际项目中快速接入JT808或扩展自定义协议解析逻辑的开发者。
1. 一张 C# 接入网:Beetle.JT808 到底解决了谁的问题
车载终端走 JT808 协议把位置、状态、报警报文往平台上送,这件事听起来简单,真正动手才发现难的不是协议本身,而是用 C# 写一个能同时接住几千台终端 TCP 长连接的接入服务。Beetle.JT808 就是干这个的:它用 beetleX 做网络底座,把注册、鉴权、心跳、位置上报这些高频报文解析成强类型对象,再通过订阅机制把消息推给业务层。搞上位机或者监管平台的人拿到它,可以跳过 socket 管理、粘包拆包、消息编解码这些重复工作,直接进入业务开发。适合手上有上线期限、不想在通信层耗两个月的团队。如果只是单机 Demo,它可能偏重;但凡面向真实运营,这套方向值得先跑通再谈定制。
2. 从连接到消息:Beetle.JT808 里网络层与协议层如何分工
很多用 C# 接 JT808 的团队,第一步会想自己写 Socket 管理,然后在一个类里塞满接收缓冲区、分包逻辑、消息解析。这做法不是不行,而是越往后越难维护:终端一多,线程模型、半包处理、连接异常重连、按消息类型分发给不同业务模块,每一块都是独立的坑。Beetle.JT808 的思路是分工:beetleX 管连接和字节流,协议层管编解码,业务层只认消息对象,三层之间用订阅机制衔接。
2.1 beetleX 凭什么被选成通信底座:三个客观理由
第一个理由是连接模型。原生 TcpListener 如果按「一个连接一个线程」写,几千个连接就要几千个线程,线程切换开销直接把 CPU 吃满;如果改异步,回调里要自己维护状态机,注册、鉴权、心跳中间任何一个字节没读完,状态就错。beetleX 底层是 IO 完成端口加线程池轮询,连接本身不占线程,只在有数据进出时才唤醒工作线程。这个模型和 C# 上位机领域常见的「管道通信」思路不一样,管道是数据到了再搬运,beetleX 是数据到了才计算,空闲连接几乎零开销。
第二个理由是缓冲区和粘包处理。beetleX 内置缓冲池,每个连接读到的字节流先进缓冲,解码器从缓冲里切帧。这就是 JT808 接入最需要的底层能力,因为 TCP 是字节流,没有消息边界,终端连续发两条报文可能粘在一起,也可能一条报文被拆成两个 TCP 段。自己在原生 Socket 上写这个逻辑,要同时处理缓冲扩容、半包保留、粘包切割,还得保证多线程下不串数据。
第三个理由是事件订阅接口。beetleX 的 TcpServer 提供了 SessionAccept、SessionReceive、SessionDisconnect 这类事件,业务代码只需要挂事件,不用重写网络层。Beetle.JT808 把消息解析完以后直接丢给订阅器,业务方按消息 ID 订阅,跟 MQTT 订阅与发布消息的体验类似:你关心哪类报文就订阅哪类,不关心的报文自然被忽略。
下面这张表可以直观看到差距:
| 关注点 | 原生 TcpListener | beetleX 底座 |
|---|---|---|
| 连接维护 | 自己管理 Socket 列表 | 内置会话池,自动回收 |
| 粘包/半包 | 自己维护字节缓冲区 | 解码器加缓冲池框架 |
| 并发模型 | 每连接一线程,或手写异步状态机 | IO 完成端口加线程池轮询 |
2.2 一个终端报文从字节流到 C# 对象的完整流转
JT808 的报文结构比大多数物联网协议规整:起始符 0x7E,消息头,消息体,校验码,结束符 0x7E。麻烦的是消息体里如果出现 0x7E 或 0x7D,终端发送前会做转义,0x7E 变成 0x7D 0x02,0x7D 变成 0x7D 0x01,服务端要先还原再解析。解析器核心逻辑大概是这个样子:
public bool TryDecode(byte[] buffer, int length, out JT808Message message) { message = default; int start = Array.IndexOf(buffer, 0x7E, 0, length); // 找起始符 if (start < 0) return false; // 半包,等下一个字节流续上 int end = Array.LastIndexOf(buffer, 0x7E, 0, length); // 找结束符 if (end <= start) return false; // 结束符还没到,继续积累 byte[] frame = new byte[end - start - 1]; Array.Copy(buffer, start + 1, frame, 0, frame.Length); // 切出起始符和结束符之间的原始帧 byte[] raw = Unescape(frame); // 先还原转义,再解析 if (raw.Length < 12) return false; // 消息头最短 12 字节 int checksum = 0; for (int i = 0; i < raw.Length - 1; i++) checksum ^= raw[i]; // 校验码前所有字节异或 if (checksum != raw[raw.Length - 1]) return false; // 校验失败,直接丢弃 ushort msgId = (ushort)((raw[0] << 8) | raw[1]); // 消息 ID,如 0x0002 心跳 ushort attr = (ushort)((raw[2] << 8) | raw[3]); // 消息体属性 int bodyLen = attr & 0x03FF; // 低 10 位是消息体长度 if (bodyLen != raw.Length - 1 - 12) return false; // 长度对不上,说明解析错位 message = new JT808Message(msgId, raw, bodyLen); return true; }这里的逻辑说明很重要:第一步先定位 0x7E,是因为粘包发生时一个缓冲里可能有多条报文,必须先找到第一条的边界;找不到起始符直接返回 false,让底层继续收数据,这是处理半包的基本思路。第二步校验失败要丢弃而不是重试拼接,因为异或校验码对不上说明这一帧已经坏了,再往后拼只会污染下一条消息。第三步attr & 0x03FF是取消息体属性字段的低 10 位,这是 JT808 2013 版的规定,如果接入的是 2019 版终端,还要注意属性字段里增加了版本标识位,这个坑后面专门讲。
2.3 注册、鉴权、心跳:先跑通这三板斧再谈业务
任何一台终端上线,顺序都是固定的:注册、鉴权、心跳。注册消息 ID 是 0x0100,服务端回 0x8100 注册应答,应答里带上鉴权码;终端收到后再发 0x0102 鉴权,服务端回 0x8102;之后每 N 秒发一次 0x0002 心跳,服务端回 0x8001 平台通用应答。这三条链路不通,后面位置上报、报警上报全是白搭。
// 收到 0x0100 注册,业务上先落库,再回注册应答并下发鉴权码 if (msgId == 0x0100) { string phone = GetPhoneFromHeader(raw); // 消息头里的终端手机号 string authCode = Guid.NewGuid().ToString("N").Substring(0, 8); session.SetState("TerminalPhone", phone); // 把终端号绑到会话上 session.SetState("AuthCode", authCode); // 鉴权码也放进会话状态 session.SetState("Authed", false); // 鉴权状态初始化为未通过 var ack = BuildResponse(0x8100, phone, msgFlow, 0, authCode); session.Send(ack); } else if (msgId == 0x0102) // 鉴权请求 { string code = ExtractAuthCode(raw); if (code == session.GetState<string>("AuthCode")) // 对比会话里存的鉴权码 { session.SetState("Authed", true); session.Send(BuildResponse(0x8102, phone, msgFlow, 0)); } } else if (msgId == 0x0002) // 心跳 { session.Send(BuildResponse(0x8001, phone, msgFlow, 0x0002)); }这段代码暴露了一个常见设计:用会话自带的 SetState 存终端状态,而不是用静态字典。原因很简单,终端断线重连后会有新会话,静态字典里存的是旧会话的状态,容易串。把状态绑到会话上,会话销毁状态自动清理。这里每个分支的应答流水号必须用请求的流水号,终端靠这个字段匹配应答,填错了终端会一直重发。
3. 订阅机制拆解:beetleX 事件模型如何驱动消息分发
Beetle.JT808 的订阅机制,是把网络层收上来的消息按消息 ID 分发给不同处理器的过程。这层设计直接决定业务代码好不好写:如果分发逻辑是几十个 if-else 堆在大类里,每加一种报文就要动一次核心代码;如果是订阅制,加一种报文就是加一个带特性的方法,主流程完全不用改。
3.1 事件订阅和轮询取消息的本质差别
轮询方式是从一个队列里反复取消息,取不到就空转或者睡一会儿;订阅方式是消息到达时由框架主动调用你挂上的回调。beetleX 的 SessionReceive 事件就是典型的订阅式接口,Beetle.JT808 在这个事件里完成解析,然后把消息对象推给订阅分发器。用 MQTT 订阅与发布消息来对比最好理解:MQTT 里订阅方挂一个 topic 的回调,broker 收到消息后推给你,你不需要循环去拉;这里的事件订阅就是把 topic 换成了消息 ID,0x0002 的心跳、0x0200 的位置上报,各自挂各自的回调。
var server = new TcpServer(); server.Options.DefaultListen.Port = 8088; // 监听端口 server.Options.DefaultListen.Host = "0.0.0.0"; // 允许所有网卡接入 server.SessionReceive += (sender, e) => { // e.Message 是底层解码器还原后的消息对象 if (e.Message is JT808Message msg) { dispatcher.Publish(msg); // 按消息 ID 动态分发给订阅方 } }; server.Open();这里有一个参数容易忽略:Host 不写 0.0.0.0 只监听回环地址,部署到服务器上终端永远连不进来。SessionReceive 里不要做业务操作,这跟后面避坑章节里的线程池饥饿是同一个问题。dispatcher.Publish 内部根据消息 ID 查订阅表,找到处理器就调用,找不到就记录一条未订阅消息日志。
3.2 把消息 ID 映射到处理器的订阅注册表
订阅注册表的设计决定了这套方案能不能支撑业务长期演进。常见做法是用一个 Attribute 标记处理器方法,启动时扫描程序集,把消息 ID 和方法建成字典,收到消息后查表调用。这样业务模块加新功能,只要新增一个方法,不需要改动分发器。
[AttributeUsage(AttributeTargets.Method)] public class MessageHandlerAttribute : Attribute { public ushort MsgId { get; } public MessageHandlerAttribute(ushort msgId) => MsgId = msgId; } // 业务模块里写一个方法,加特性就完成了动态订阅 [MessageHandler(0x0200)] // 位置上报 public void OnLocation(JT808Context ctx) { var loc = ctx.ParseLocation(); // 解析经纬度、速度、方向 _repo.SaveLocation(ctx.Phone, loc); // 写入轨迹库 ctx.Reply(0x8001); // 回平台通用应答 }启动时扫描的程序代码一般长这样:
var handlers = new Dictionary<ushort, Action<JT808Context>>(); foreach (var type in Assembly.GetExecutingAssembly().GetTypes()) { foreach (var method in type.GetMethods()) { var attr = method.GetCustomAttribute<MessageHandlerAttribute>(); if (attr != null) { var instance = Activator.CreateInstance(type); handlers[attr.MsgId] = (Action<JT808Context>)Delegate.CreateDelegate( typeof(Action<JT808Context>), instance, method); } } }这里要重点说明两点:一是消息 ID 是 ushort,字典键不要用 string,高频消息场景下 string 拼接会产生大量临时对象,GC 压力大;二是反射只在启动时做一次,运行时全部走字典查找,这和你写 if-else 的性能差异可以忽略,但代码整洁度和扩展性差别很大。
3.3 一个会话对应一个终端:会话状态与上下文共享
订阅处理器里经常需要知道当前是哪个终端、鉴权过了没有、终端上次心跳是什么时候。这些信息不能每次查数据库,正确做法是存在会话上下文里,就像前面注册流程里用 SetState 存鉴权码一样。Beetle.JT808 的上下文对象会把当前会话、消息、终端号、业务服务一起封装,处理器直接从上下文里取,而不是从静态全局变量里猜。
public class JT808Context { public ISession Session { get; set; } // beetleX 会话,可 Send 数据 public JT808Message Message { get; set; } // 当前消息 public string Phone => Session.GetState<string>("TerminalPhone"); public bool Authed => Session.GetState<bool>("Authed"); }这里有个容易被忽略的点:位置上报、报警这类消息到达时,要先判断ctx.Authed,很多终端在注册应答还没收到时就急着发定位数据,如果服务端不拦,这条消息会被当成合法数据处理,产生脏轨迹。会话层面的这种状态隔离,是订阅机制能安全分发的前提,否则多个终端共用一个处理器实例,处理器里如果有实例字段,数据就会互相串。
4. 把 Beetle.JT808 跑起来:最小可复现工程与关键参数
从 GitHub 下载 master 归档开始,到一台模拟终端把心跳链路跑通,整个过程大概半小时。这一节只讲最小可复现路径,不掺业务定制,先把服务端立住,再谈参数调优。
4.1 用 dotnet 命令还原编译:三个必须检查的配置
下载的是 zip 包,解压后先用命令行还原依赖。Beetle.JT808 依赖 beetleX 的 NuGet 包,不在项目目录里,必须 restore 才能拿到。
unzip Beetle.JT808-master.zip -d beetle-jt808 cd beetle-jt808 dotnet restore dotnet build -c Release --no-restore这里三个检查点:第一,SDK 版本要满足项目目标框架要求,用dotnet --list-sdks先确认,低了会直接报 NETSDK 错误;第二,--no-restore的意思是跳过还原直接编译,前提是你刚跑过 restore,不然会报缺少引用;第三,如果公司内网不能用公共 NuGet 源,需要配置 NuGet.Config 指定内部镜像,否则 restore 会卡在超时。
编译完成后看输出目录里的 dll,确认 beetleX 的依赖已经被拷到输出目录。这一步很容易出问题:如果你把项目发给别人跑,对方只复制了 exe 没复制 dll,运行时会提示找不到 BeetleX 程序集。发布时用dotnet publish -c Release -o ./publish一条命令把所有依赖收拢到一个目录,省掉手工拷贝。
4.2 服务端口、终端密码与心跳超时:参数怎么设
Beetle.JT808 的服务端配置在 JSON 文件里,这几个参数决定接入规模的上限和排错方向。
{ "Server": { "Port": 8088, "TerminalPassword": "123456", "HeartbeatTimeoutSeconds": 60, "MaxConnections": 50000, "SocketThreadCount": 4, "BufferPoolSize": 1000 } }| 参数 | 作用 | 建议值 |
|---|---|---|
| Port | TCP 监听端口,终端连接地址端口 | 按运营商要求,常见 8088 |
| TerminalPassword | 终端鉴权时校验的密码 | 按平台分配,生产环境务必修改 |
| HeartbeatTimeoutSeconds | 超过该时长没收到心跳判离线 | 终端心跳周期的 2 倍,一般 60 |
| SocketThreadCount | 处理 socket 事件的线程数 | 4 到 8,按 CPU 核心数上调 |
| BufferPoolSize | 缓冲池大小,影响高并发下内存复用 | 500 到 1000,别超过 2000 |
HeartbeatTimeoutSeconds 这个参数最容易设错。终端配置的心跳周期是 30 秒,服务端超时设 30 秒,网络抖动一次就会误判离线;按心跳周期的两倍设,能容忍一次丢包。SocketThreadCount 不是越大越好,线程超过 CPU 核心数两倍后,上下文切换成本超过收益,压测的时候会看到吞吐不升反降。
4.3 用 Python 模拟一台终端把链路跑通
没有真机也能验证服务端逻辑。用 Python 写一个最小发包器,按 JT808 报文格式组一个心跳包发出去,观察服务端日志有没有回平台通用应答。
import socket import struct def bcd_phone(phone: str) -> bytes: # 终端手机号 6 字节 BCD,这里简化处理:号码必须是偶数位数字 return bytes.fromhex(phone) def build_packet(msg_id: int, phone: str, body: bytes = b'', serial: int = 1) -> bytes: attr = len(body) & 0x03FF # 消息体属性低 10 位是消息体长度 header = struct.pack('>HH', msg_id, attr) # 消息 ID 2 字节 + 属性 2 字节 header += bcd_phone(phone) # 终端手机号 6 字节 header += struct.pack('>H', serial) # 流水号 2 字节 data = header + body checksum = 0 for b in data: checksum ^= b # 异或校验,得到校验码 # 简化版本:真实终端还需要把 data 里的 0x7E/0x7D 做转义 return b'\x7e' + data + bytes([checksum]) + b'\x7e' s = socket.create_connection(('127.0.0.1', 8088), timeout=5) pkt = build_packet(0x0002, '013800000001', b'', serial=1) s.sendall(pkt) resp = s.recv(1024) print(resp.hex())这个脚本里两个细节值得说。第一,attr = len(body) & 0x03FF把消息体长度写进属性字段的低 10 位,位置上报时 body 长度可能超过 1024,那时候需要对消息头里的分包标志位做处理,脚本里没涉及;第二,真实终端发出的报文会做 0x7E 和 0x7D 的转义,服务端收到后会先还原,所以模拟终端不转义也能测通,但如果拿去测服务端对非法帧的容忍度,就还得把转义加上。
4.4 压测 1000 个连接:线程数与缓冲区怎么调
压测是验证接入服务真正价值的手段。一千个终端同时长连,持续发心跳,看服务端能不能全部应答且不丢消息。
import socket import threading import time def worker(phone: str, base_serial: int): s = socket.create_connection(('127.0.0.1', 8088), timeout=5) for i in range(50): # 每个连接发 50 条心跳 pkt = build_packet(0x0002, phone, b'', base_serial + i) s.sendall(pkt) time.sleep(0.01) s.close() start = time.time() threads = [] for i in range(1000): # 1000 个模拟终端 phone = f'0138{i:08d}' t = threading.Thread(target=worker, args=(phone, 1)) t.start() threads.append(t) for t in threads: t.join() print(f'cost {time.time() - start:.2f}s')跑这个脚本,主要看三个指标:总耗时、服务端日志里有没有解析失败、消息队列有没有积压。如果耗时超过 60 秒,优先调 SocketThreadCount;如果出现大量校验失败日志,查解码器的缓冲区是不是太小,buffer 小于一条最大报文长度时,会被拆成多个片段反复解析。BufferPoolSize 的坑在内存:设得太大,空闲连接多的时候内存白白占用,设太小,并发峰值时又频繁分配新缓冲,GC 频繁。一般从 1000 起,压测时看内存曲线再回调。
5. 避坑指南:JT808 接入服务最常见的 5 个翻车现场
接入服务上线前,这 5 个问题几乎每个团队都会踩一次。每一条按现象、原因、解决三步写清楚,踩到的时候直接对照排查。
5.1 粘包/半包让你解析错位:先找 0x7E 再读长度
现象:终端连续上报时,服务端日志频繁出现校验失败,或者一条消息解析出来消息 ID 是乱码。原因:TCP 是字节流,没有消息边界。终端发了三条报文,服务端一次 recv 可能收到三条粘在一起;也可能一条报文被拆成两段,第一段只有半条。如果按「收到多少字节就解析多少字节」来写,每条都会错位。解决:解码器必须先找起始符 0x7E,找不到就等下一段数据;找到起始符后再找结束符,没找到就保留当前缓冲继续收;找到完整帧之后,用消息头第 4 字节之后的长度位切消息体,而不是用收到的字节数。Beetle.JT808 的解码器已经实现了这个状态机,如果你是自己写的解析,重点检查半包时缓冲有没有被覆盖。
5.2 转义还原做反了:合法报文被当非法丢弃
现象:同一批终端,一部分能正常上线,一部分永远注册失败。抓包对比发现,失败终端的报文里出现了 0x7D 0x02 这类序列。原因:JT808 规定消息体里出现 0x7E 要转义成 0x7D 0x02,出现 0x7D 要转义成 0x7D 0x01。服务端必须在解析消息头之前还原转义,否则消息体长度会算多、位置字段会错位,校验码也永远对不上。解决:解析帧内容时先扫描 7D,遇到 7D 01 还原成 7D,遇到 7D 02 还原成 7E,然后把还原后的数组交给后续解析。注意还原要在找结束符之后做,不能在原始字节流上直接做,否则结束符 7E 会被误当成转义序列。
public static byte[] Unescape(byte[] frame) { using var ms = new MemoryStream(); for (int i = 0; i < frame.Length; i++) { if (frame[i] == 0x7D && i + 1 < frame.Length && frame[i + 1] == 0x01) { ms.WriteByte(0x7D); i++; } else if (frame[i] == 0x7D && i + 1 < frame.Length && frame[i + 1] == 0x02) { ms.WriteByte(0x7E); i++; } else ms.WriteByte(frame[i]); } return ms.ToArray(); }5.3 订阅事件里做耗时操作:线程池饥饿怎么来的
现象:连接数到 2000 后,服务端 CPU 占用不高,但消息处理延迟从 1 毫秒涨到 500 毫秒,心跳应答大面积超时。原因:在 SessionReceive 或订阅处理器里直接写了数据库操作、HTTP 调用这类耗时逻辑。beetleX 的接收线程是共享的,处理器阻塞 100 毫秒,这条线程上的其他连接全部排队。这就是典型的线程池饥饿,不是 CPU 不够,是处理线程被占住了。解决:订阅处理器只做消息解析和路由,耗时操作丢到独立的异步队列里消费。beetleX 处理器里收到消息后,先应答平台,再把业务数据交给 Channel 或者 BlockingCollection,消费者线程去写库。这样网络线程永远只干轻活。
5.4 终端时区与平台时区不一致:轨迹记录全偏
现象:轨迹回放里,车辆夜间行驶数据出现在下午,白天的数据出现在凌晨。原因:JT808 2019 版的消息头里增加了时间字段,部分终端按设备本地时区填,部分终端按 UTC 填,平台如果统一按北京时间解析,就出现几小时的整体偏移。解决:入库前统一转成 UTC 存储,查询展示时再按平台所在地时区转换。这里注意,转时区要在解析完包之后立刻做,不要等消息进了业务队列再做,队列积压会导致时间批次错乱。协议版本识别要放在消息头解析阶段,2013 版和 2019 版的消息体属性字段占用不同,识别错了后续字段全偏。
5.5 鉴权时序反了:注册应答还没回就发定位
现象:部分终端上线后,服务端收到大量位置上报,但终端状态还是未鉴权,导致这些位置被丢进脏数据表。原因:终端在收到注册应答之前不会发鉴权,这是协议规定的正常流程;但有些终端厂商把注册和鉴权合并成快速流程,注册请求发出去后不等应答,直接发位置上报。服务端如果不在处理器入口统一校验鉴权状态,这些消息就会被当成有效数据处理。解决:接入层维护一个会话状态位,收到注册请求后状态是「已注册未鉴权」,收到位置上报时先检查状态,未鉴权的消息只回应答不落库。同时在注册应答处理里把鉴权码写进会话,避免用静态字段存。
// 处理器入口统一做三层校验 if (string.IsNullOrEmpty(ctx.Phone)) return; // 连终端号都没有,直接丢弃 if (!ctx.Authed && ctx.Message.MsgId != 0x0100 && ctx.Message.MsgId != 0x0102) // 没鉴权只放行注册和鉴权 { ctx.Reply(0x8001, 1); // 回一个失败应答,让终端重走流程 return; }6. 上线前怎么验证这套方案值不值得投入:三层点检清单
方案能不能用,不是看它支持多少种报文,而是看核心链路是否可靠、异常时是否能快速定位。我每次接入新平台都会跑一遍三层点检,过了才谈后续定制。
6.1 四条链路的功能点检
| 链路 | 触发方式 | 预期结果 | 耗时上限 |
|---|---|---|---|
| 注册 | 模拟终端发 0x0100 | 收到 0x8100 应答,含鉴权码 | 1 秒内 |
| 鉴权 | 模拟终端发 0x0102 | 收到 0x8102 应答 | 1 秒内 |
| 心跳 | 模拟终端发 0x0002 | 收到 0x8001 应答 | 1 秒内 |
| 位置上报 | 模拟终端发 0x0200 | 收到应答且轨迹入库 | 2 秒内 |
点检用第 4 章的 Python 脚本改消息 ID 和数据体就能跑,不用真实终端。重点观察应答里的流水号是否和请求一致,终端用流水号匹配应答,这个错了必翻车。
6.2 用 beetleX 的统计与事件日志验证吞吐
功能点检过了,还要确认服务在高并发下不会悄悄丢消息。beetleX 的会话事件里挂上计数,每 30 秒打印一次:
server.SessionAccept += (s, e) => Interlocked.Increment(ref onlineCount); server.SessionReceive += (s, e) => Interlocked.Increment(ref recvCount); server.SessionDisconnect += (s, e) => Interlocked.Decrement(ref onlineCount); Task.Run(async () => { while (true) { await Task.Delay(30000); Console.WriteLine($"online={onlineCount}, recv/s={(recvCount - lastRecv) / 30}"); lastRecv = recvCount; } });这样每半分钟能看到在线连接数和收包速率,如果收包速率突然归零而连接数没降,基本可以断定处理线程堵死了,优先查订阅处理器里的耗时操作。我自己第一次压测时,就是在这个监控脚本里发现收包速率在 2000 连接后断崖下跌,定位到是数据库批量写入拖住了处理线程,后来所有订阅处理器只做消息转换,落到异步队列里消费,才把上万台终端撑住。所以这套方案值不值得投入,核心就看你能不能守住「网络线程只做轻活」这条纪律。希望帮到你。
本文还有配套的精品资源,点击获取