简介:一套基于 Windows 平台、使用 Visual Studio 2013 编写的 Socket TCP 通信示例工程,面向具备 C++ 基础、希望从单机练习过渡到网络编程实战的开发者。它要解决的核心问题是服务端如何同时接入多个客户端,并把一条消息广播给全部在线连接,可对照实现类似 QQ 群聊的实时通讯场景。压缩包共 58 个文件、约 16.42MB,除 cpp 与 h 源码以及 sln、vcxproj 工程配置外,还保留了 exe 可执行文件、pdb 调试符号、obj 与 ilk 中间产物和 tlog 编译日志;这些内容既能直接运行以观察效果,也能在 IDE 中重新编译调试。已有 513 人学习。工程内含服务端监听与并发处理、客户端连接管理、消息群发以及文件二进制流传输等模块,读者可据此理解 TCP 连接建立、多线程收发与广播逻辑,也可在此基础上扩展加密传输和聊天室功能;对课程设计与自学网络编程都有较直接的参考价值。
1. 四十台机器同时在线:这套 TCP 通信方案到底要解决什么
一个机房四十台机器同时连上来,服务端要在两秒内把一条通知推给所有人,还得随时知道谁掉线了——这是「Windows 服务端 + 多客户端 Socket TCP 通信」最原始的需求描述,也是大多数课程设计和毕业设计里真正卡人的地方。真正难的地方在于:连接断了怎么发现、消息粘在一起怎么切开、群发时一个客户端卡住会不会拖垮整条广播链路;Socket.Connect那一行从来不是难点。这套方案给出的做法是:自定义长度前缀帧协议 + 服务端会话表 + 每会话独立发送队列 + 客户端收发分离与退避重连,四个部分各自独立又互相呼应。它适合要交一份完整通信课设的学生,也适合需要在局域网内做消息分发、设备状态上报、上位机广播的从业者直接改。
2. 协议与并发模型:长度前缀帧和每会话发送队列怎么定
TCP 提供的是字节流,不是消息流。这一条如果没在动手前想清楚,后面写出来的代码一定会在某个下午突然出错:本地跑得好好的,一上真实网络,中文消息就变成半句。所以协议和并发模型必须最先定,定完再写业务,这部分最终省下来的返工时间比什么都值。
2.1 消息边界:长度前缀帧怎么切
三种常见切法里,只有一种能长期使用。用\n或|做分隔符最省事,但正文里只要出现同样的字符,整条链路当场失效;定长包不会串,但一条“收到”也要占满 512 字节,带宽浪费得离谱;长度前缀把“这一帧有多长”明明白白写在帧头,接收方按长度取,取不够就等下一次Read,多出来的留给下一帧。多客户端场景下这是唯一不需要额外约定的做法。
using System; using System.Buffers.Binary; using System.Text; public static class Frame { // 帧头 = 4 字节正文长度(大端)+ 1 字节消息类型 public const int HeaderSize = 5; public const int MaxBodyLength = 64 * 1024; // 单帧正文上限,挡住异常大包撑爆内存 public static byte[] Encode(byte type, string text) { byte[] body = Encoding.UTF8.GetBytes(text ?? string.Empty); if (body.Length > MaxBodyLength) throw new ArgumentException("正文超过单帧上限"); byte[] frame = new byte[HeaderSize + body.Length]; BinaryPrimitives.WriteInt32BigEndian(frame.AsSpan(0, 4), body.Length); frame[4] = type; // 消息类型放在长度之后 Buffer.BlockCopy(body, 0, frame, HeaderSize, body.Length); return frame; } // 从累积缓冲区里尝试取出一帧;不够一帧返回 false,等下次 Read public static bool TryDecode(byte[] buffer, ref int offset, int count, out byte type, out string text) { type = 0; text = null; if (count - offset < HeaderSize) return false; // 帧头都没收全 int bodyLen = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset, 4)); if (bodyLen < 0 || bodyLen > MaxBodyLength) throw new InvalidDataException("帧长度非法,应主动断开该连接"); if (count - offset - HeaderSize < bodyLen) return false; // 正文还没收全 type = buffer[offset + 4]; text = Encoding.UTF8.GetString(buffer, offset + HeaderSize, bodyLen); offset += HeaderSize + bodyLen; // 游标前移,支持一次读多帧 return true; } }offset是这个设计的核心,它让一个缓冲区可以连续吐出多帧:一次Read可能收到半帧,也可能收到三帧半,TryDecode循环调用直到返回false,剩下的半帧原样留在缓冲区等下一轮。长度统一按字节算而不是按字符算,string.Length在中文字符串上给出的是字符数,用它当长度写进帧头,接收方一定切错位,这是乱码问题里最常见的一种。大端写入是为了跨语言调试时对照抓包数据不费劲,自己两端都用 C# 其实小端也行,但一旦以后要接个 Python 脚本做压测,统一大端更省事。
消息类型用一字节编码,一张表定死就不要再改:
| 类型值 | 名称 | 方向 | 正文格式 | 说明 |
|---|---|---|---|---|
| 1 | Login | C→S | 昵称 | 上线首帧,服务端绑定会话 |
| 2 | Broadcast | C→S→C | 聊天正文 | 群发,服务端转发给除发送者外所有人 |
| 3 | Private | C→S→C | 目标会话ID|正文 | 点对点消息 |
| 4 | Heartbeat | C→S | 空 | 15 秒一次,不参与业务 |
| 5 | OnlineList | S→C | id,nick;id,nick | 有人上线或下线时群发 |
| 6 | System | S→C | 提示文字 | 超时踢人、服务端关闭通知 |
注意:
MaxBodyLength不是随便填的数字。它决定单条消息的上限,也决定接收缓冲区的最小尺寸;取值小于实际业务消息,会在发送端抛异常,取值过大,一个异常连接就能让你内存增长。
2.2 并发模型:一线程一连接还是异步
小规模局域网项目里这个选择其实没有标准答案,但有明确的边界。一线程一连接写起来最直观,四十个客户端就是四十个线程,接收、处理、发送全在一个线程里顺着写,调试时断点一打就知道卡在哪;代价是每个线程默认占 1MB 栈空间,且线程切换在连接数上去以后开始拖慢响应。Select模型在 Windows 上还有个历史限制,FD_SETSIZE默认只有 64,接第 65 个客户端直接失败,这个坑不少人遇到过。
异步模型是更耐久的选择。每个连接一个Task,接收用ReadAsync,发送交给独立的发送循环,线程池负责调度,四十个连接和四百个连接在代码形态上没有任何区别。代价是异常会被吞进Task里,async void一用就等着排查一整晚。所以这套方案在异步的基础上再加一层:每个会话配一个独立的发送队列和发送循环。
这一层的价值在群发时才体现出来。如果所有连接共用一个发送逻辑,某个客户端网络卡住,WriteAsync迟迟不返回,整条广播链路就堵在这里,其余客户端全都收不到消息——这就是典型的“一个慢客户端拖垮全服”。改成每个会话一个容量 256 的有界队列,群发只负责往队列里塞,真正的WriteAsync由各自的发送循环执行,谁慢谁自己慢,队列满了就说明这个客户端已经跟不上,直接摘掉,不影响别人。
2.3 心跳与超时:别指望 TCP KeepAlive
服务端的在线列表和真实连接对不上,十有八九是这里没做。TCP 自带的 KeepAlive 在 Windows 上默认空闲两小时才开始探测,对课堂演示来说等于没有;更麻烦的是拔网线、断电、直接杀进程这几种情况,对端根本发不出 FIN,服务端看到的连接永远是“已建立”。
应用层心跳是唯一可靠的办法:客户端每 15 秒发一个类型 4 的空帧,服务端每次收到任何帧就更新该会话的LastActiveUtc,另起一个扫描任务每 10 秒跑一次,超过 45 秒没动静的直接清理并群发新的在线列表。
// 服务端每 10 秒扫一次,超过 45 秒没有任何帧的会话直接清理 private void SweepIdleSessions() { DateTime deadline = DateTime.UtcNow.AddSeconds(-45); Session[] snapshot; lock (_sync) { snapshot = _sessions.Values.ToArray(); } // 锁内只做拷贝 foreach (Session s in snapshot) if (s.LastActiveUtc < deadline) CloseSession(s, "心跳超时"); // 锁外做关闭,避免长时间持锁 }三个参数的关系要记住:客户端心跳间隔必须明显小于服务端超时阈值,一般取三分之一左右。15 秒发一次、45 秒判超时,意味着允许连续丢两次心跳,短暂网络抖动不会误踢;如果心跳 30 秒发一次、超时也设 30 秒,那每次网络稍微延迟一下就会误判,用户会看到自己莫名其妙被踢下线,然后重连,再被踢。
3. 服务端核心:监听循环、会话表与群发链路的实现
服务端要做的只有四件事:接住连接、记住谁在线、把消息正确转出去、把断掉的连接清理干净。下面四小节按这个顺序拆开,每一段都能直接放进项目里跑起来。
3.1 监听与接入:Accept 循环怎么写才不卡
监听本身不复杂,容易出问题的是“接住之后怎么办”。有人把业务处理直接写在 Accept 循环里,第一个客户端连上以后发一条长消息,服务端就再也接不了第二个人了。
private async Task AcceptLoopAsync(CancellationToken token) { var listener = new TcpListener(IPAddress.Any, 9527); listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); // 重启时避开 TIME_WAIT 导致的端口占用 listener.Start(backlog: 128); // 半连接队列,客户端集中上线时别用默认值 while (!token.IsCancellationRequested) { TcpClient client; try { client = await listener.AcceptTcpClientAsync(); } catch (ObjectDisposedException) { break; } // 正常停止监听 catch (SocketException ex) { Log($"接入失败:{ex.SocketErrorCode}"); continue; } client.NoDelay = true; // 关掉 Nagle,控制类小消息不要攒 _ = HandleClientAsync(client, token); // 每个连接一个独立任务,绝不 await } listener.Stop(); }_ = HandleClientAsync(...)这一行是整段的关键:不await,接入循环立刻回到AcceptTcpClientAsync等下一个连接。代价是这个任务的异常没人接,所以HandleClientAsync内部必须整体包一层try/catch/finally,finally里做会话清理。NoDelay打开是因为心跳和通知这类消息体很小,Nagle 算法会攒着等凑够一定量再发,表现为“消息发出去要等几百毫秒才到”,在演示现场显得特别难解释。
backlog: 128是半连接队列长度。四十台机器在开机脚本里同时连,瞬时会有几十个 SYN 挤在队列里,队列短了就会出现连接被拒。这个值不用调得特别大,但默认值在集中上线时确实不够。
3.2 会话表:字典 + 单锁,锁内不做网络 IO
会话表用一个Dictionary<int, Session>就够,键用自增 ID 而不是昵称,因为昵称会重名、会改,ID 不会。所有对字典的增删查都用同一把锁保护,这点不要图省事用ConcurrentDictionary然后到处TryGetValue——省下来的那点性能在几十个连接的量级上完全体现不出来,反而会让“谁在什么时候改过这张表”变得难以追踪。
public sealed class Session { public int Id { get; } public string UserName { get; set; } public DateTime LastActiveUtc { get; set; } private readonly TcpClient _client; private readonly NetworkStream _stream; // 每个会话一条有界发送队列:满了说明这个客户端消费不过来,直接丢弃并摘除 private readonly Channel<byte[]> _sendQueue = Channel.CreateBounded<byte[]>( new BoundedChannelOptions(256) { FullMode = BoundedChannelFullMode.DropWrite }); public Session(int id, TcpClient client) { Id = id; _client = client; _stream = client.GetStream(); LastActiveUtc = DateTime.UtcNow; } public bool TryEnqueue(byte[] frame) => _sendQueue.Writer.TryWrite(frame); // 整个会话只有这一个循环写 socket,天然不会出现两条消息交错 public async Task SendLoopAsync(CancellationToken token) { await foreach (byte[] frame in _sendQueue.Reader.ReadAllAsync(token)) await _stream.WriteAsync(frame, token); } }“单写者”这个约束值得单独强调。Socket 的Send在多个线程同时调用时,不能保证两条消息整体先后,很可能出现 A 消息的前 10 字节接了 B 消息的后 20 字节,客户端解出来就是一堆乱码——这种问题偶发、难复现,属于典型的需要长时间排查的问题。把写操作收敛到一个循环里,从结构上就不存在这个可能。
BoundedChannelFullMode.DropWrite是明确的取舍:队列满意味着这个客户端已经积压了 256 帧还没发完,再往里塞只会让内存继续涨,不如丢掉这一帧并把它标记为待清理。
3.3 群发链路:快照遍历、失败剔除与入队计数
群发的代码只有十几行,但几乎每一行都对应一个曾经出现过的问题。
public void Broadcast(byte type, string text, int exceptSessionId = -1) { byte[] frame = Frame.Encode(type, text); // 编码一次,所有人共用,别在循环里重复编码 Session[] snapshot; lock (_sync) { snapshot = _sessions.Values.ToArray(); } // 锁内只做拷贝,锁外再做发送 int queued = 0, dropped = 0; foreach (Session s in snapshot) { if (s.Id == exceptSessionId) continue; // 群发不回声给发送者本人 if (s.TryEnqueue(frame)) queued++; else { CloseSession(s, "发送队列堆积"); dropped++; } } Log($"群发 type={type}:入队 {queued},剔除 {dropped}"); }先ToArray()再遍历,是为了避免在遍历过程中另一个线程登录或掉线,导致抛出“集合已修改”异常——这个异常在压测时几乎必然出现,而且抛出位置随机。拷贝的成本是几十个引用,换来的是彻底的可重入性。
编码只做一次也很重要。如果写在循环里,五十个客户端就是五十次 UTF-8 编码外加五十个新数组,群发一百条消息就多出几千次分配,内存涨得快,GC 也跟着频繁触发。
queued和dropped这两个计数不要嫌麻烦,它们是线上排查问题的第一手依据。群发后dropped一直在涨,说明有客户端网络质量差或已经假死;queued明显小于在线人数,说明会话表里混进了已经关闭的会话。
3.4 关闭顺序:Shutdown 再 Close,别上来就 Close
关闭会话的顺序写错了,最直接的表现是客户端偶尔收不到最后一条系统通知,或者服务端报ConnectionReset。
public void Close(string reason) { _sendQueue.Writer.TryComplete(); // 让发送循环自然退出,已入队的帧还能发完 try { _client.Client.Shutdown(SocketShutdown.Both); } // 先发 FIN,走正常四次挥手 catch (SocketException) { } try { _client.Close(); } catch { } }TryComplete()之后ReadAllAsync会把队列里剩下的帧读完再结束,队列里已经排着的消息不会凭空消失。Shutdown的作用是告诉对端“我不再发了”,对端读到 0 字节就知道是正常关闭而不是连接被重置;直接Close则可能触发 RST,接收方缓冲区里还没被应用层读走的数据会被丢弃。这条在发“服务端即将关闭”这种通知时尤其明显,先Shutdown再Close,客户端基本都能收到。
4. 客户端实现:收发分离、UI 线程安全与退避重连
客户端代码量比服务端少,但踩坑的种类一点不少,主要集中在三个地方:连接阶段的等待、接收过程的数据处理、断线之后的重连策略。
4.1 连接与登录握手
连接本身要加超时,否则目标机器没开机时界面会卡住十几秒,用户以为程序挂了。
public async Task<bool> ConnectAsync(string host, int port, string userName, CancellationToken token) { _client = new TcpClient { NoDelay = true }; using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); cts.CancelAfter(TimeSpan.FromSeconds(5)); // 连不上别让界面干等 try { await _client.ConnectAsync(host, port, cts.Token); } catch (OperationCanceledException) { return false; } catch (SocketException) { return false; } _stream = _client.GetStream(); await _stream.WriteAsync(Frame.Encode(MsgType.Login, userName), token); // 首帧即登录 _ = ReceiveLoopAsync(token); // 收和发从这里开始彻底分开 return true; }登录帧紧接着连接发出,服务端收到类型 1 就把昵称绑到会话上,同时把它加进在线表并广播类型 5 的在线列表。这样“连上”和“上线”是两个状态,中间有个明确的握手点,后面处理重连时逻辑会清楚很多。
cts.CancelAfter(5秒)给的是连接阶段的硬超时。局域网里正常连接在毫秒级完成,5 秒还没连上基本可以判定对方没开机、防火墙拦了或者 IP 填错了,三种情况的处理方式对用户来说都是“重试”。
4.2 接收循环:缓冲区压缩与 UI 线程安全
接收循环是全篇最容易写错的一段,因为它同时要处理半包、粘包和跨线程更新界面三个问题。
private async Task ReceiveLoopAsync(CancellationToken token) { byte[] buffer = new byte[8 * 1024]; int offset = 0, count = 0; while (!token.IsCancellationRequested) { int n; try { n = await _stream.ReadAsync(buffer.AsMemory(count), token); } catch (IOException) { break; } // 连接被重置 if (n == 0) break; // 对端正常关闭 count += n; // 一次 Read 可能带来多帧,也可能只有半帧,循环取干净 while (Frame.TryDecode(buffer, ref offset, count, out byte type, out string text)) OnMessage?.Invoke(this, new MessageEventArgs(type, text)); // 消费完整的部分,把剩下的半帧挪到缓冲区开头 if (offset == count) { offset = 0; count = 0; } else if (offset > 0) { Buffer.BlockCopy(buffer, offset, buffer, 0, count - offset); count -= offset; offset = 0; } } OnDisconnected?.Invoke(this, EventArgs.Empty); }ReadAsync(buffer.AsMemory(count), token)里从count位置开始写,是为了不覆盖上次剩下的半帧。循环结束后做一次压缩,把残留数据挪到开头,count更新为剩余字节数,offset归零。不做这一步,缓冲区里的数据会越攒越乱,表现就是前几十条消息正常,后面开始出现错位的乱码。
OnMessage事件里绝对不要直接改控件属性。这个回调跑在线程池线程上,WinForms 控件不是线程安全的,直接赋值在某些情况下看起来没问题,换个机器就抛InvalidOperationException: 线程间操作无效。正确做法是在窗体订阅事件时用BeginInvoke切回 UI 线程:
private void OnClientMessage(object sender, MessageEventArgs e) { if (IsDisposed) return; BeginInvoke(new Action(() => { if (e.Type == MsgType.Broadcast) AppendChat(e.Text); else if (e.Type == MsgType.OnlineList) RefreshUserList(e.Text); })); }用BeginInvoke而不是Invoke,是为了不阻塞接收线程。Invoke是同步等待 UI 线程处理完才返回,界面一卡(比如用户在拖窗口),接收线程就跟着卡,进而队列堆积,最后被服务端判定为慢客户端踢掉。
4.3 重连:指数退避加随机抖动
断线重连最直观的写法是while(true) { Thread.Sleep(1000); if (Connect()) break; },本地测试完全没问题,一旦四十台机器同时掉线,一秒钟后四十个连接同时涌向服务端,backlog队列瞬间打满,一部分连接被拒,被拒的客户端等一秒再一起冲,形成周期性的冲击。这就是为什么要加随机抖动。
private async Task ReconnectLoopAsync(CancellationToken token) { int attempt = 0; while (!token.IsCancellationRequested && !_closedByUser) { // 1,2,4,8,16,30 秒封顶,再叠加 0~500ms 抖动打散重连时刻 int delay = Math.Min(30, (int)Math.Pow(2, attempt)) + Random.Shared.Next(0, 500); try { await Task.Delay(TimeSpan.FromSeconds(delay), token); } catch (OperationCanceledException) { break; } if (await ConnectAsync(_host, _port, _userName, token)) { attempt = 0; break; } attempt++; } }重连成功后必须重新发登录帧,不能只恢复 socket 就以为万事大吉。服务端那边的旧会话早就被扫描线程清掉了,新连接是一个全新的会话对象,ID 不同,昵称也没绑上;如果不重登,这个客户端能收到群发消息,但发出去的消息服务端找不到发送者,处理时会直接丢掉。
还有一个细节:重连期间要禁用发送按钮,或者让待发消息进一个本地队列。用户不知道底层在重连,点发送没反应,多半会连续点十几次,恢复连接后这十几条一起涌向服务端,看起来就像是有人频繁刷屏。
5. 避坑与排查:五类最容易翻车的问题和对应解法
下面这五条都是实际联调时最常见、也最容易被误判成“不会影响系统正常工作”的问题。每条按现象、原因、解决三段写,遇到时可以直接对照。
5.1 消息与连接层
现象:客户端 A 发一句“明天下午三点在实验楼集合”,客户端 B 收到的是“明天下午三点在实”,后半句变成了下一条消息的前缀;或者干脆整条消息变成一串问号。有时还表现为连续发十条,第九条和第十条串成一条。
原因:把一次Read的返回值当成一条完整消息。TCP 不保证一次Read对应一次Write,发送方Write了 30 字节,接收方可能分三次拿到 10+10+10,也可能两次合并成 50 字节一次拿到。乱码的变体还有编码不一致:一端用 UTF-8,另一端用系统默认编码(中文 Windows 上是 GBK),同一条中文消息在两端的字节数都不一样,长度前缀自然对不上。
解决:长度前缀 + 累积缓冲区,只有count - offset >= HeaderSize + bodyLen时才解码,不够就等下一次Read;两端统一 UTF-8,所有长度计算用Encoding.UTF8.GetByteCount而不是string.Length。
现象:服务端重启,控制台报SocketException (10048): 通常每个套接字地址只允许使用一次,等一两分钟再启动又好了。
原因:上一次运行被强行结束(直接关控制台窗口、任务管理器结束进程),被接受的连接还处在TIME_WAIT状态,本地端口仍被占用,新的监听无法绑定。
解决:在listener.Start()之前调用SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true),代码见 3.1 节。更重要的是开发阶段养成优雅关闭的习惯:按 Ctrl+C 或者点停止按钮时先停止 Accept 循环,再逐个CloseSession,别每次都强杀进程。
5.2 并发与状态层
现象:群发时随机抛出InvalidOperationException: 集合已修改;枚举操作可能无法执行,堆栈指向foreach。
原因:在遍历_sessions.Values的过程中,另一个线程执行了登录(Add)或超时清理(Remove)。字典在枚举期间被修改就会抛这个异常,和是否用锁保护无关——锁保护的是写操作之间互斥,如果遍历时不持锁,同样会撞上。
解决:锁内ToArray()拿到快照,锁外遍历快照。锁的持有时间控制在几十微秒以内,绝对不要在锁内做WriteAsync、Thread.Sleep或日志落盘,否则一个响应慢的客户端会把所有登录操作堵在外面。
现象:群发一百条消息,大部分客户端正常,但其中某一台从第二条开始就再也收不到了,它自己还能发消息,服务端也没报错。
原因:那台客户端所在的网络拥塞,或者调试时挂起了接收线程。服务端对它的WriteAsync长时间不返回,如果没有独立发送队列,整条广播链路会一直堵在这个点上,其余客户端一起受影响。
解决:每会话一个容量 256 的有界队列,TryWrite返回false立即摘除该会话并记日志。这样广播链路永远只做内存入队,耗时与客户端网络状况无关。排查时看dropped计数和具体是被哪个会话触发的,基本能定位到机器。
现象:同一台客户端重连之后,在线列表里出现两条同名记录,私聊发给它时对方收不到。
原因:重连产生了一个新会话,旧会话要等服务端扫描线程跑到才被清掉,中间这几十秒里同一个昵称对应两个会话 ID,私聊按昵称查找时命中的是已经死掉的那一条。
解决:登录处理时先按昵称查一遍现有会话,有就先把旧的踢掉再绑定新的;私聊一律按会话 ID 寻址,昵称只用于显示。这条改完之后,“对方明明在线却收不到私聊”这类问题基本绝迹。
6. 验收与答辩:用四组对照实验证明这套通信扛得住
写完之后自己跑一遍“能连上、能发消息”不算验收,那只能证明主流程通。真正要交出去的东西,得能回答“五十个人同时在线会怎样”“网线拔了会怎样”这类问题。我一般会做四组对照实验,每组都有明确的通过标准,写进文档里也是一份像样的测试记录。
| 验收项 | 操作方法 | 通过标准 |
|---|---|---|
| 单连接完整性 | 一个客户端连发 1000 条 1KB 随机中文,含换行符与竖线 | 一条不少、顺序不乱、无乱码,服务端日志入队数与发送数一致 |
| 并发上线 | 用脚本 3 秒内拉起 50 个客户端同时连接 | 全部登录成功,服务端在线数等于 50,无 10048 / 10061 错误 |
| 群发时延 | 50 个客户端在线,服务端连续群发 100 次 | 最慢一条端到端不超过 200ms(局域网),发送队列无堆积告警 |
| 断线与重连 | 拔掉其中一台的网线 20 秒后插回;另一台直接结束进程 | 超时的那台在 45 秒内从在线列表消失;重连后只保留一份记录,且能正常收发 |
四组里最容易出问题的是第四组。拔网线这一步必须真的拔,用禁用网卡模拟不算数——禁用网卡有时会发一个 RST 出去,服务端立刻就知道连接没了,测不出心跳超时的逻辑;直接结束进程同样会走正常关闭流程。只有物理断开才能验证“服务端靠 45 秒心跳阈值发现掉线”这条链路是否真的生效。
答辩时被问得最多的三个问题,答案是现成的。为什么不用 UDP:群发通知要求可靠性,UDP 丢了就是丢了,要做重传就相当于自己实现一遍 TCP 的确认机制,这个项目里并不划算。为什么不用现成的框架:框架能解决传输问题,但解决不了“会话表怎么维护”“群发时慢客户端怎么办”这些真正要讲清楚的部分,而这恰好是这套方案的重点。能撑多少人:一线程一连接的模型在两百个连接以内问题不大,异步模型加独立发送队列的形式,瓶颈通常在心跳扫描和日志写入上,不在网络层,具体数字拿第四组实验的数据说话,比口头估一个更有说服力。
有一次联调,前面三组测试都通过了,第四组拔网线之后等了整整五分钟,那台机器还在列表里显示在线。原因是我把心跳扫描的周期写成了TimeSpan.FromMinutes(1),超时阈值又设成 90 秒,看代码觉得没问题,实际跑起来才发现响应太慢。从那以后我每次做通信类项目,都会先把这三件事放在同一张纸上核对一遍:客户端多久发一次心跳、服务端多久扫一次、超时阈值是多少,确认前两个加起来明显小于第三个,才动手写业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取