Appearance
网络
TCP 拥塞控制是什么?
标准答案
TCP 拥塞控制是 TCP 用来“保护网络”的机制:发送方不能只顾自己一直发,而是要根据 ACK、丢包、超时、RTT 等反馈,动态调整发送速度,避免把链路、路由器队列打爆。
它的核心变量叫 cwnd,也就是拥塞窗口。真正能发送多少数据,通常看:
实际发送窗口 = min(cwnd, rwnd)cwnd 是拥塞控制管的,保护网络;rwnd 是流量控制管的,保护接收方。
底层原理
经典 TCP Reno 里常讲四个阶段:
慢启动:刚开始不知道网络容量,cwnd 从小开始快速增长,通常每个 RTT 近似翻倍。
拥塞避免:到达阈值 ssthresh 后,不再猛增,而是线性增长,慢慢试探网络容量。
快重传:收到 3 个重复 ACK,认为某个包可能丢了,不等超时就重传。
快恢复:丢包后降低 cwnd,但不一定从头开始,尽量保持连接吞吐。
如果是超时,说明网络可能更拥塞,通常会把 cwnd 降得更狠。
简化代码理解
c
public sealed class TcpCongestionToy // 定义一个简化版 TCP 拥塞控制模型类。
{ // 类体开始。
private double cwnd = 1.0; // 拥塞窗口,表示当前网络允许发送方“大胆发送”的程度。
private double ssthresh = 16.0; // 慢启动阈值,低于它走慢启动,高于它走拥塞避免。
public double SendWindow(double rwnd) // 计算实际可发送窗口,rwnd 表示接收方窗口。
{ // SendWindow 函数开始。
return System.Math.Min(cwnd, rwnd); // 实际发送量取拥塞窗口和接收窗口中的较小值。
} // SendWindow 函数结束。
public void OnAck() // 收到正常 ACK 时调用,表示网络暂时能承受当前发送。
{ // OnAck 函数开始。
if (cwnd < ssthresh) // 如果当前还在慢启动阶段。
{ // 慢启动分支开始。
cwnd += 1.0; // 简化表示快速增长,真实 TCP 会按 MSS 和 ACK 规则增长。
} // 慢启动分支结束。
else // 如果已经进入拥塞避免阶段。
{ // 拥塞避免分支开始。
cwnd += 1.0 / cwnd; // 简化表示线性增长,窗口越大每次增长越慢。
} // 拥塞避免分支结束。
} // OnAck 函数结束。
public void OnTimeout() // 发生超时时调用,通常认为拥塞比较严重。
{ // OnTimeout 函数开始。
ssthresh = System.Math.Max(cwnd / 2.0, 2.0); // 把阈值降为当前窗口的一半,最低不低于 2。
cwnd = 1.0; // 超时后窗口降到很小,重新慢启动。
} // OnTimeout 函数结束。
public void OnTripleDuplicateAck() // 收到 3 个重复 ACK 时调用,表示可能有包丢失。
{ // OnTripleDuplicateAck 函数开始。
ssthresh = System.Math.Max(cwnd / 2.0, 2.0); // 先降低阈值,表示网络可能出现拥塞。
cwnd = ssthresh; // 简化表示快恢复,不像超时那样直接回到 1。
} // OnTripleDuplicateAck 函数结束。
} // 类体结束。游戏网络补充
游戏实时战斗同步常用 UDP,不是因为 TCP 不可靠,而是因为 TCP 的重传、拥塞控制、队头阻塞可能带来延迟抖动。登录、聊天、资源下载可以用 TCP;实时位置、输入帧、状态同步更常用 UDP 加自定义可靠机制。
TCP 滑动窗口是什么?
标准答案
TCP 滑动窗口是一种“连续发送 + 按 ACK 推进”的机制。它让 TCP 不用发一个包等一个 ACK,而是在窗口范围内连续发送多段数据,从而提高吞吐量。
一句话记:ACK 往前走,窗口就往前滑;窗口里允许有一批已发送但还没确认的数据。
底层原理
TCP 把数据看成连续字节流,每个字节都有序号。发送端维护几个关键值:
send base:窗口左边界,表示最早还没确认的字节。
next seq:下一个要发送的字节序号。
rwnd:接收方通告窗口,表示接收方还能收多少数据。
cwnd:拥塞窗口,表示网络当前大概能承受多少数据。
实际能发多少通常看:
可发送窗口 = min(rwnd, cwnd)收到 ACK 后,比如 ACK = 220,表示 220 之前的数据都收到了,于是窗口左边界滑到 220,右边也打开新的可发送空间。
和流量控制、拥塞控制的关系
滑动窗口常用来讲 TCP 的流量控制:别把接收方缓冲区撑爆。
拥塞窗口用来讲 TCP 的拥塞控制:别把网络链路打爆。
真正发送时两者都要考虑,所以是 min(rwnd, cwnd)。
简化代码理解
c
public sealed class SlidingWindowToy // 定义一个简化版滑动窗口模型类。
{ // 类体开始。
private int sendBase; // 窗口左边界,表示最早未确认的字节序号。
private int nextSeq; // 下一个准备发送的字节序号。
private readonly int windowSize; // 固定窗口大小,真实 TCP 会动态变化。
public SlidingWindowToy(int windowSize) // 构造函数,传入窗口大小。
{ // 构造函数体开始。
this.windowSize = windowSize; // 保存窗口大小。
} // 构造函数体结束。
public bool CanSend(int bytes) // 判断还能不能继续发送指定字节数。
{ // CanSend 函数体开始。
return nextSeq + bytes <= sendBase + windowSize; // 如果发送后不超过窗口右边界,就允许发送。
} // CanSend 函数体结束。
public void Send(int bytes) // 模拟发送指定字节数的数据。
{ // Send 函数体开始。
if (!CanSend(bytes)) return; // 如果窗口空间不够,就不能继续发送。
nextSeq += bytes; // 发送成功后,下一个发送序号向后移动。
} // Send 函数体结束。
public void OnAck(int ack) // 收到 ACK 时调用。
{ // OnAck 函数体开始。
if (ack > sendBase) sendBase = ack; // ACK 更靠后时,窗口左边界向右滑动。
} // OnAck 函数体结束。
public int InFlight() // 获取当前已经发送但还没确认的数据量。
{ // InFlight 函数体开始。
return nextSeq - sendBase; // 未确认数据量等于 nextSeq 减去 sendBase。
} // InFlight 函数体结束。
} // 类体结束。游戏网络补充
TCP 的滑动窗口能提高可靠传输吞吐,但如果发生丢包,后续数据可能因为队头阻塞不能及时交付。所以游戏里登录、聊天、下载适合 TCP;实时战斗同步、位置同步更常用 UDP 加自定义可靠和重传策略。
TCP 重传机制是什么?
标准答案
TCP 重传机制是 TCP 保证可靠传输的核心机制:发送端会给数据编号,并把“已发送但还没确认”的数据暂存在重传缓存里;如果迟迟收不到 ACK,或者收到多个重复 ACK,就判断某段数据可能丢了,然后重新发送。
一句话记:TCP 不是丢了就随便重发,而是靠序号、ACK、定时器、重复 ACK、SACK 判断该补哪一段。
底层原理
TCP 主要有几种重传方式:
超时重传:发送一个段后启动 RTO 定时器,如果超时还没收到 ACK,就重传这段数据。RTO 会根据 RTT 动态估计,并且超时后通常会指数退避。
快速重传:如果发送端连续收到 3 个重复 ACK,比如一直收到 ACK 200,说明接收端可能收到了后面的数据,但中间 200 开始那段丢了,于是不用等超时,直接重传缺失段。
SACK 选择确认:普通 ACK 是累计确认,只能说“某个序号之前都收到了”;SACK 可以告诉发送端“哪些区间已经收到了”,这样发送端可以更精确地只重传真正缺失的区间。
重传还会影响拥塞控制。超时通常说明网络拥塞更严重,会让 cwnd 降得更狠;快速重传一般配合快恢复,尽量减少吞吐下降。
简化代码理解
c
using System; // 引入基础命名空间,用于使用 Action 等基础类型。
using System.Collections.Generic; // 引入集合命名空间,用于使用 Dictionary 保存未确认数据。
public sealed class TcpRetransmissionToy // 定义一个简化版 TCP 重传模型类。
{ // 类体开始。
private readonly Dictionary<int, string> unacked = new Dictionary<int, string>(); // 保存已发送但还没确认的数据段。
private int duplicateAckCount = 0; // 记录当前重复 ACK 的次数。
private int lastAck = -1; // 保存上一次收到的 ACK 序号。
public void Send(int seq, string data) // 模拟发送一个 TCP 数据段。
{ // Send 函数体开始。
unacked[seq] = data; // 把数据放入重传缓存,直到收到确认才删除。
Console.WriteLine($"send seq={seq}, data={data}"); // 输出发送日志,方便观察流程。
} // Send 函数体结束。
public void OnAck(int ack) // 收到 ACK 时调用。
{ // OnAck 函数体开始。
if (ack == lastAck) // 如果 ACK 和上一次一样,说明可能是重复 ACK。
{ // 重复 ACK 分支开始。
duplicateAckCount++; // 重复 ACK 次数加一。
if (duplicateAckCount >= 3) // 如果连续收到 3 个重复 ACK。
{ // 快速重传分支开始。
Retransmit(ack); // 立即重传 ACK 指向的缺失数据段。
} // 快速重传分支结束。
} // 重复 ACK 分支结束。
else // 如果 ACK 向前推进了。
{ // 新 ACK 分支开始。
duplicateAckCount = 0; // 重复 ACK 计数清零。
lastAck = ack; // 更新最近一次 ACK。
RemoveAckedBefore(ack); // 删除已经被确认的数据段。
} // 新 ACK 分支结束。
} // OnAck 函数体结束。
public void OnTimeout(int seq) // 某个数据段的 RTO 定时器超时时调用。
{ // OnTimeout 函数体开始。
Retransmit(seq); // 超时后重传对应的数据段。
} // OnTimeout 函数体结束。
private void Retransmit(int seq) // 执行真正的重传逻辑。
{ // Retransmit 函数体开始。
if (!unacked.TryGetValue(seq, out string data)) return; // 如果缓存里没有这段数据,说明已经确认或不存在。
Console.WriteLine($"retransmit seq={seq}, data={data}"); // 输出重传日志。
} // Retransmit 函数体结束。
private void RemoveAckedBefore(int ack) // 删除 ACK 之前已经确认的数据。
{ // RemoveAckedBefore 函数体开始。
List<int> removed = new List<int>(); // 临时保存需要删除的序号。
foreach (int seq in unacked.Keys) // 遍历所有未确认数据段的序号。
{ // foreach 循环体开始。
if (seq < ack) removed.Add(seq); // 如果该段序号小于 ACK,就认为已经被累计确认。
} // foreach 循环体结束。
foreach (int seq in removed) // 遍历需要删除的序号。
{ // foreach 循环体开始。
unacked.Remove(seq); // 从重传缓存中删除已确认的数据。
} // foreach 循环体结束。
} // RemoveAckedBefore 函数体结束。
} // 类体结束。游戏网络补充
TCP 重传保证可靠,但代价是延迟和队头阻塞。比如战斗同步里某个旧包丢了,后面的数据即使到了,也可能因为 TCP 保序机制暂时交不上来。所以登录、聊天、资源下载适合 TCP;实时战斗、移动同步、帧同步更常用 UDP,再自己做关键消息重传。
Nagle 算法是什么?
标准答案
Nagle 算法是 TCP 用来减少“小包”的算法。它的核心思想是:如果当前连接里还有未确认的数据,那么后续很小的数据不要立刻发,先放到缓冲区里,等前面的 ACK 回来,或者攒够一个 MSS,再一起发送。
一句话记:Nagle 是“少发小包”的算法,省带宽和系统开销,但可能增加延迟。
底层原理
它的大致规则是:
没有未确认数据:小数据可以直接发送。
有未确认数据:新的小数据先缓存。
满足以下条件之一再发送:收到 ACK,或者缓存数据攒够 MSS。
这样可以避免应用层频繁 send 几个字节,TCP 就发出一堆很小的包。因为一个 TCP 包除了数据,还有 IP 头、TCP 头,包太小的时候头部比例很高,网络和内核处理开销也高。
为什么会有坑
Nagle 最大的问题是延迟。尤其它和 Delayed ACK 放在一起时容易出问题:
发送端:我有未确认数据,先等 ACK。
接收端:我先延迟 ACK,看看能不能一起确认。
结果双方一等,就可能出现几十毫秒甚至更明显的卡顿。
所以实时交互、小消息、游戏输入、战斗指令这类场景,常常会关闭 Nagle,也就是设置 TCP_NODELAY。
C# 示例
c
using System.Net.Sockets; // 引入 TCP 网络相关类型。
public static class TcpNagleExample // 定义一个 TCP Nagle 配置示例类。
{ // 类体开始。
public static void ConfigureForLowLatency(TcpClient client) // 配置低延迟 TCP 连接。
{ // 方法体开始。
client.NoDelay = true; // true 表示关闭 Nagle,小数据尽量立即发送。
} // 方法体结束。
public static void ConfigureForThroughput(TcpClient client) // 配置吞吐优先 TCP 连接。
{ // 方法体开始。
client.NoDelay = false; // false 表示开启 Nagle,让 TCP 尽量合并小包。
} // 方法体结束。
} // 类体结束。游戏网络实践
如果是登录、聊天、资源下载,Nagle 可能没问题,因为这些更看重吞吐和稳定。
如果是实时战斗、输入指令、同步消息,用 TCP 时通常会关掉 Nagle,同时在应用层自己合包。更常见的实时同步方案会用 UDP,再自己做可靠消息、ACK、重传和乱序处理。
UDP 丢包怎么处理?
标准答案
UDP 本身不保证可靠:可能丢包、乱序、重复、延迟。所以 UDP 丢包不是靠协议自动处理,而是应用层自己决定怎么处理。
核心原则是:不要所有包都重传,要先按消息类型分类。
可靠消息,比如购买、结算、技能确认、进入房间,要做 seq + ACK + 超时重传 + 去重。
实时状态,比如位置、朝向、动画快照,通常不补旧包,而是用下一帧新数据覆盖,再配合插值、外推、预测来平滑。
底层思路
UDP 处理丢包常见有几套方案:
- 序号:每个包带
seq,接收端判断乱序、旧包、重复包。 - ACK:接收端告诉发送端哪些包收到了。
- 重传缓存:发送端保存重要消息,超时没收到 ACK 就重发。
- 去重和幂等:重复收到同一个可靠消息不能执行两次。
- 快照容错:位置同步丢一帧不补,直接等下一帧。
- 心跳和重连:长时间收不到包,认为弱网或断线,重连后拉服务端权威状态。
C# 简化代码
c
using System; // 引入基础类型,例如 DateTime 和 Array。
using System.Collections.Generic; // 引入 Dictionary,用来保存待确认的数据包。
public sealed class ReliableUdpToy // 定义一个简化版可靠 UDP 发送器。
{ // 类体开始。
private sealed class PendingPacket // 定义待确认数据包结构。
{ // PendingPacket 类体开始。
public byte[] Payload = Array.Empty<byte>(); // 保存要重传的数据内容。
public float LastSendTime; // 保存上一次发送时间。
public int RetryCount; // 保存已经重传的次数。
} // PendingPacket 类体结束。
private readonly Dictionary<int, PendingPacket> pending = new Dictionary<int, PendingPacket>(); // 保存未收到 ACK 的可靠包。
private int nextSeq = 1; // 下一个可靠包序号。
private const float ResendInterval = 0.2f; // 重传间隔,单位可以理解为秒。
private const int MaxRetry = 5; // 最大重传次数,避免无限重传拖垮网络。
public int SendReliable(byte[] payload, float now) // 发送可靠消息,并返回分配的序号。
{ // SendReliable 函数体开始。
int seq = nextSeq++; // 分配一个新的递增序号。
pending[seq] = new PendingPacket { Payload = payload, LastSendTime = now, RetryCount = 0 }; // 把数据放入重传缓存。
SendPacket(seq, payload); // 立刻发送一次数据。
return seq; // 返回这个可靠包的序号。
} // SendReliable 函数体结束。
public void OnAck(int ackSeq) // 收到 ACK 时调用。
{ // OnAck 函数体开始。
pending.Remove(ackSeq); // 删除已经确认的数据包,避免继续重传。
} // OnAck 函数体结束。
public void Update(float now) // 每帧或定时调用,用来检查是否需要重传。
{ // Update 函数体开始。
foreach (var pair in pending) // 遍历所有未确认的数据包。
{ // foreach 循环体开始。
int seq = pair.Key; // 取出数据包序号。
PendingPacket packet = pair.Value; // 取出待确认数据包对象。
if (now - packet.LastSendTime < ResendInterval) continue; // 如果还没到重传时间,就跳过。
if (packet.RetryCount >= MaxRetry) continue; // 如果超过最大重传次数,就不再继续重传。
packet.LastSendTime = now; // 更新本次重传时间。
packet.RetryCount++; // 重传次数加一。
SendPacket(seq, packet.Payload); // 重新发送这个可靠包。
} // foreach 循环体结束。
} // Update 函数体结束。
private void SendPacket(int seq, byte[] payload) // 真正发送 UDP 包的函数。
{ // SendPacket 函数体开始。
Console.WriteLine($"send seq={seq}, size={payload.Length}"); // 这里用日志模拟发送,真实项目会调用 UDP socket。
} // SendPacket 函数体结束。
} // ReliableUdpToy 类体结束。游戏面试重点
面试里不要说“UDP 丢了就重传”。更好的回答是:重要事件可靠化,实时状态用新包覆盖旧包。
比如玩家移动包丢了,补发旧位置没有意义,因为下一帧新位置更有价值。 但购买道具、领取奖励、技能命中确认丢了,就必须 ACK 和重传,并且服务端要做幂等,防止重复执行。
QUIC 是什么?
标准答案
QUIC 是一种跑在 UDP 之上的传输协议。它不是“裸 UDP”,而是在 UDP 上重新实现了类似 TCP 的可靠传输、拥塞控制、ACK、重传,同时把 TLS 1.3 加密和多路 Stream 也集成进来。
一句话记:QUIC = UDP 外壳 + TCP 类可靠性 + TLS 1.3 + 多路 Stream + 连接迁移。
底层原理
传统 HTTP/2 是:
c
HTTP/2 -> TLS -> TCP -> IP问题是 TCP 是一个可靠有序字节流。如果一个 TCP 包丢了,即使后面其他 HTTP/2 Stream 的数据已经到了,也要等丢失的字节重传回来,这就是 TCP 层队头阻塞。
QUIC 是:
c
HTTP/3 -> QUIC -> UDP -> IPQUIC 自己管理 Packet Number、ACK、重传、拥塞控制、流量控制和 Stream。不同 Stream 之间相对独立,一条 Stream 丢包,主要阻塞这条 Stream 的有序交付,不会像 TCP 那样把所有 Stream 都卡住。
核心优点
握手更快:QUIC 集成 TLS 1.3,首次连接通常更快,恢复连接时还能做 0-RTT,但 0-RTT 要注意重放攻击风险。
连接迁移:QUIC 用 Connection ID 标识连接,不完全依赖 IP + 端口,所以手机从 WiFi 切到 4G 时,连接不一定断。
协议演进快:QUIC 在用户态实现,不像 TCP 那样深度依赖操作系统内核,升级和迭代更灵活。
简化代码理解
c
using System; // 引入基础类型,例如 byte 数组和 ulong。
public sealed class QuicPacketConcept // 定义一个简化版 QUIC 包概念类。
{ // 类体开始。
public ulong ConnectionId { get; init; } // Connection ID 用来识别连接,支持网络切换后的连接迁移。
public long PacketNumber { get; init; } // Packet Number 用来做 ACK、丢包检测和重传判断。
public long StreamId { get; init; } // Stream ID 表示这个数据属于哪一条 QUIC Stream。
public bool RequiresAck { get; init; } // 是否需要确认,用来决定是否进入可靠传输流程。
public byte[] Payload { get; init; } = Array.Empty<byte>(); // Payload 保存真正的业务数据。
} // 类体结束。游戏工程理解
QUIC 适合资源下载、登录、网关长连接、HTTP/3 接口这类场景。 但如果是高频实时战斗帧同步,很多项目仍然会用自定义 UDP,因为它们需要更细的控制:哪些消息可靠、哪些消息丢了就丢、如何预测、如何插值、如何做状态覆盖。
HTTP/2 和 HTTP/1.1 区别是什么?
标准答案
HTTP/1.1 和 HTTP/2 的语义基本一样,都是请求、响应、Header、Body。真正大的区别在于:HTTP/1.1 更像文本请求响应协议;HTTP/2 把数据拆成二进制 Frame,并用 Stream 在一个 TCP 连接上多路复用。
一句话记:HTTP/1.1 是文本请求响应;HTTP/2 是二进制 Frame + Stream 复用 + HPACK 头部压缩。
底层区别
HTTP/1.1 是文本协议,请求和响应可读性强,但一个连接上的响应更偏顺序。为了提高并发,浏览器通常会对同一域名开多个 TCP 连接。HTTP/1.1 也有 Pipelining,但因为队头阻塞等问题,实际很少依赖。
HTTP/2 是二进制分帧协议。它把请求和响应拆成 HEADERS Frame、DATA Frame 等,再用 Stream ID 标记它们属于哪个请求。这样多个请求响应可以在一个 TCP 连接里交错传输。
HTTP/2 还用了 HPACK 头部压缩,重复 Header 不用每次完整发送,对大量小请求、重复 Cookie、Token、User-Agent 这类场景更省流量。
重要坑点
HTTP/2 解决的是 HTTP 应用层的队头阻塞,但它仍然跑在 TCP 上。 如果底层 TCP 丢包,后面的字节还是要等重传,所以 TCP 层队头阻塞仍然存在。HTTP/3/QUIC 才是进一步解决这个问题的方向。
C# 示例:指定 HTTP/2
c
using System; // 引入基础类型。
using System.Net.Http; // 引入 HttpClient 和 HttpRequestMessage。
using System.Threading.Tasks; // 引入 Task 异步类型。
public static class Http2Example // 定义一个 HTTP/2 请求示例类。
{ // 类体开始。
public static async Task RequestWithHttp2() // 定义一个异步方法,用来发起 HTTP/2 请求。
{ // 方法体开始。
using HttpClient client = new HttpClient(); // 创建 HttpClient,用于发送 HTTP 请求。
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); // 创建 GET 请求对象。
request.Version = new Version(2, 0); // 指定希望使用 HTTP/2。
request.VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher; // 表示尽量使用 HTTP/2 或更高版本。
HttpResponseMessage response = await client.SendAsync(request); // 异步发送请求并等待响应。
Console.WriteLine(response.Version); // 输出实际协商到的 HTTP 版本。
} // 方法体结束。
} // 类体结束。游戏工程场景
游戏客户端里,HTTP/2 适合登录、公告、配置、资源 Manifest、热更新查询、商城接口这类请求较多的业务。 但实时战斗同步、帧同步、位置同步通常不用 HTTP/2,而是 UDP 或自定义可靠 UDP,因为 HTTP/2 仍然受 TCP 延迟和丢包影响。
HTTPS 握手流程是什么?
标准答案
HTTPS 握手本质是:先建立 TCP 连接,再通过 TLS 握手完成身份认证和密钥协商,最后用协商出的会话密钥加密 HTTP 数据。
现代常见 TLS 1.3 流程可以这样记:
- TCP 三次握手:客户端和服务器先建立 TCP 连接。
- ClientHello:客户端发支持的 TLS 版本、密码套件、随机数、SNI、ALPN、key share。
- ServerHello:服务器选择 TLS 版本、密码套件,并返回自己的 key share。
- Certificate:服务器发送证书,证明“我就是这个域名对应的服务器”。
- 客户端校验证书:检查域名、有效期、CA 链、签名。
- Finished:双方确认握手内容没被篡改,并生成会话密钥。
- 加密传输 HTTP:后面的请求和响应都走对称加密。
底层原理
HTTPS 不是用证书直接加密全部数据。证书主要用来证明服务器身份。真正传输 HTTP 数据时,用的是握手后生成的对称密钥,因为对称加密速度更快。
TLS 1.3 通常基于 ECDHE 做密钥交换,具备前向安全:就算服务器私钥以后泄露,也不容易解密以前抓到的历史流量。
TLS 1.2 握手轮次通常更多,旧式 RSA 密钥交换也没有前向安全,所以现在更推荐 TLS 1.3。
C# 简化示例:手动触发 TLS 握手
c
using System; // 引入基础类型和控制台输出。
using System.Net.Security; // 引入 SslStream,用来建立 TLS 加密通道。
using System.Net.Sockets; // 引入 TcpClient,用来建立 TCP 连接。
using System.Security.Cryptography.X509Certificates; // 引入证书相关类型。
using System.Threading.Tasks; // 引入 Task,用来写异步代码。
public static class HttpsHandshakeDemo // 定义一个 HTTPS 握手演示类。
{ // 类体开始。
public static async Task ConnectAsync(string host) // 定义异步连接方法,host 是目标域名。
{ // 方法体开始。
using TcpClient tcp = new TcpClient(); // 创建 TCP 客户端对象。
await tcp.ConnectAsync(host, 443); // 连接服务器的 443 端口,完成 TCP 建连。
using SslStream tls = new SslStream(tcp.GetStream(), false, ValidateCertificate); // 在 TCP 流外面套一层 TLS。
await tls.AuthenticateAsClientAsync(host); // 发起 TLS 握手,并校验服务器证书。
Console.WriteLine(tls.SslProtocol); // 输出实际协商出来的 TLS 协议版本。
} // 方法体结束。
private static bool ValidateCertificate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors) // 定义证书校验回调。
{ // 回调函数体开始。
return errors == SslPolicyErrors.None; // 没有证书错误才认为 HTTPS 连接可信。
} // 回调函数体结束。
} // 类体结束。游戏工程场景
登录、支付、账号 Token、热更新 Manifest、配置拉取都应该走 HTTPS。客户端不要随便忽略证书错误,否则中间人代理可以伪造服务器。上线项目还要关注证书过期、系统时间错误、抓包代理、证书固定策略的更新风险。
DNS 解析流程是什么?
标准答案
DNS 解析就是把域名转换成 IP 地址的过程。比如你访问 example.com,程序真正建立 TCP/UDP 连接时需要的是 IP,所以要先通过 DNS 查到它对应的 A 或 AAAA 记录。
一句话记:DNS 解析 = 本地缓存/hosts -> 递归 DNS -> 根 DNS -> TLD -> 权威 DNS -> 返回 IP 并按 TTL 缓存。
底层流程
- 应用或浏览器先查自己的 DNS 缓存。
- 查不到再查操作系统 DNS 缓存。
- 还查不到会看
hosts文件。 - 本地都没有,就请求递归 DNS 服务器,比如运营商 DNS、公司 DNS、公共 DNS。
- 递归 DNS 如果也没缓存,会先问根 DNS:
.com去哪里查? - 根 DNS 返回
.com顶级域 DNS 地址。 - 递归 DNS 再问
.com的 TLD DNS:example.com去哪里查? - TLD DNS 返回
example.com的权威 DNS。 - 递归 DNS 再问权威 DNS,拿到最终 IP。
- 结果返回客户端,并按 TTL 缓存。
常见记录
A:域名对应 IPv4。
AAAA:域名对应 IPv6。
CNAME:别名记录,比如 cdn.game.com 指向另一个域名。
MX:邮件服务器记录。
TXT:文本记录,常用于验证、反垃圾邮件等。
C# 示例
c
using System; // 引入基础类型和控制台输出。
using System.Net; // 引入 Dns 和 IPAddress 类型。
using System.Threading.Tasks; // 引入 Task,用于异步方法。
public static class DnsResolveDemo // 定义 DNS 解析示例类。
{ // 类体开始。
public static async Task ResolveAsync(string host) // 定义异步解析域名的方法。
{ // 方法体开始。
IPAddress[] addresses = await Dns.GetHostAddressesAsync(host); // 向系统 DNS 解析器请求域名对应的 IP 地址列表。
foreach (IPAddress address in addresses) // 遍历返回的所有 IP 地址。
{ // foreach 循环体开始。
Console.WriteLine(address); // 打印解析到的 IP 地址。
} // foreach 循环体结束。
} // 方法体结束。
} // 类体结束。游戏工程坑点
游戏里登录服、热更新、CDN、支付接口经常依赖 DNS。常见问题有 DNS 劫持、DNS 污染、解析超时、某些地区解析到错误节点、IPv6 兼容问题。
另外,HTTPS 不能简单地“解析出 IP 后直接连 IP”就完事。因为证书是绑定域名的,TLS 的 SNI 和 HTTP 的 Host 通常仍然需要域名,否则可能证书校验失败。
CDN 如何加速资源下载?
标准答案
CDN 加速资源下载的核心是:把资源提前缓存到离玩家更近的边缘节点,客户端下载时就近访问,而不是所有人都去打源站。
资源下载流程通常是:
- 客户端请求资源 URL。
- DNS 或 CDN 调度系统把客户端分配到较近、较健康的边缘节点。
- 如果边缘节点有缓存,直接返回资源。
- 如果边缘节点没有缓存,就回源站拉取资源。
- 拉到后边缘节点缓存一份,下次其他玩家访问就更快。
- 客户端下载后做 hash 校验,防止资源损坏或拿到旧包。
底层原理
CDN 加速主要靠四点:
就近访问:减少 RTT 和跨地域链路传输。
边缘缓存:热门资源不用重复从源站下载。
带宽分摊:大量玩家同时热更新时,不会把源站打爆。
调度容灾:某个节点慢或故障,可以切备用节点或备用 CDN。
Unity/游戏热更新实践
游戏里常把 AssetBundle、Addressables Catalog、Patch 包、配置表、音频、视频等放 CDN。关键点是:资源 URL 要版本化或 hash 化。
比如:
c
/bundles/android/v102/role_9a31ab.bundle不要长期用同一个 URL 覆盖内容,否则 CDN 可能返回旧缓存。客户端还要做超时重试、备用 CDN、断点续传、hash 校验和失败回滚。
C# 示例:CDN fallback + hash 校验
c
using System; // 引入 Action 和 Array 等基础类型。
using System.Collections; // 引入 IEnumerator,用于 Unity 协程。
using System.Security.Cryptography; // 引入 SHA256,用于资源完整性校验。
using System.Text; // 引入 StringBuilder,用于拼接 hash 字符串。
using UnityEngine; // 引入 MonoBehaviour 和 SerializeField。
using UnityEngine.Networking; // 引入 UnityWebRequest,用于 HTTP 下载。
public sealed class CdnDownloadExample : MonoBehaviour // 定义一个 CDN 下载示例组件。
{ // 类体开始。
[SerializeField] private string[] cdnBases = Array.Empty<string>(); // 配置多个 CDN 域名,方便失败后切换备用源。
public IEnumerator DownloadBundle(string relativePath, string expectedSha256, Action<byte[]> onOk, Action<string> onFail) // 定义下载资源协程。
{ // 方法体开始。
foreach (string cdn in cdnBases) // 依次尝试每一个 CDN 地址。
{ // foreach 循环体开始。
string url = $"{cdn.TrimEnd('/')}/{relativePath.TrimStart('/')}"; // 拼出完整资源 URL。
using UnityWebRequest request = UnityWebRequest.Get(url); // 创建 GET 请求。
request.timeout = 10; // 设置超时时间,避免弱网下无限等待。
yield return request.SendWebRequest(); // 发起异步下载并等待完成。
if (request.result != UnityWebRequest.Result.Success) // 判断当前 CDN 下载是否失败。
{ // 下载失败分支开始。
continue; // 尝试下一个备用 CDN。
} // 下载失败分支结束。
byte[] data = request.downloadHandler.data; // 取出下载到的资源字节。
if (!VerifySha256(data, expectedSha256)) // 校验资源 hash 是否符合 Manifest。
{ // 校验失败分支开始。
continue; // 资源可能损坏或旧缓存,继续尝试备用 CDN。
} // 校验失败分支结束。
onOk?.Invoke(data); // 下载和校验都成功,把数据回调给业务层。
yield break; // 成功后结束协程。
} // foreach 循环体结束。
onFail?.Invoke("all cdn failed"); // 所有 CDN 都失败后通知上层处理。
} // 方法体结束。
private static bool VerifySha256(byte[] data, string expectedSha256) // 定义 SHA256 校验函数。
{ // 方法体开始。
using SHA256 sha = SHA256.Create(); // 创建 SHA256 计算器。
byte[] hash = sha.ComputeHash(data); // 计算资源字节的 SHA256。
StringBuilder builder = new StringBuilder(hash.Length * 2); // 创建字符串拼接器。
foreach (byte b in hash) // 遍历 hash 中的每个字节。
{ // foreach 循环体开始。
builder.Append(b.ToString("x2")); // 把字节转成两位十六进制字符串。
} // foreach 循环体结束。
return string.Equals(builder.ToString(), expectedSha256, StringComparison.OrdinalIgnoreCase); // 比较实际 hash 和期望 hash。
} // 方法体结束。
} // 类体结束。面试加分点
不要只说“CDN 离用户近”。更完整的回答是:CDN 通过调度、边缘缓存、回源、预热、刷新、版本化 URL、hash 校验、备用节点来保证下载速度和正确性。
NAT 是什么?
标准答案
NAT 是 Network Address Translation,网络地址转换。它的作用是:把内网私有 IP 和端口转换成公网 IP 和端口,让多台内网设备共享一个公网出口访问互联网。
比如玩家设备是:
192.168.1.10:50000经过 NAT 路由器后,服务器看到的可能是:
203.0.113.8:40001服务器回包时发给 203.0.113.8:40001,NAT 网关查映射表,再转回内网的 192.168.1.10:50000。
底层原理
NAT 网关核心维护一张映射表:
c
内网地址:端口 公网地址:端口
192.168.1.10:50000 -> 203.0.113.8:40001
192.168.1.11:50000 -> 203.0.113.8:40002这样多个内网设备虽然都用私有 IP,但对公网服务器来说,都是从同一个公网 IP 出来,只是端口不同。
常见类型:
SNAT:出站时改源地址,家庭路由器最常见。
DNAT:入站时改目标地址,比如端口转发。
PAT / NAPT:地址和端口一起转换,多台设备共享一个公网 IP 的关键。
游戏联机为什么关心 NAT
NAT 最大的问题是:外部很难主动连接内网设备。
如果是客户端主动连服务器,通常没问题,因为 NAT 会创建出站映射。 但如果是 P2P 联机,两个玩家都在 NAT 后面,双方都不能直接被公网访问,就需要 NAT 穿透。
常见方案:
STUN:探测自己在公网看到的 IP 和端口。
UDP Hole Punching:双方同时向对方公网地址发包,尝试打洞。
TURN / Relay:打洞失败时走中继服务器。
对称 NAT 更难穿透,很多时候只能走中继。
C# 简化代码:模拟 NAT 映射表
c
using System; // 引入基础类型,例如 string 和 Console。
using System.Collections.Generic; // 引入 Dictionary,用来保存 NAT 映射表。
public sealed class NatTableToy // 定义一个简化版 NAT 映射表类。
{ // 类体开始。
private readonly Dictionary<string, string> privateToPublic = new Dictionary<string, string>(); // 保存内网地址到公网地址的映射。
private readonly Dictionary<string, string> publicToPrivate = new Dictionary<string, string>(); // 保存公网地址到内网地址的反向映射。
private int nextPublicPort = 40000; // 模拟 NAT 网关分配的下一个公网端口。
public string TranslateOutbound(string privateEndPoint, string publicIp) // 模拟内网设备向公网发包时的地址转换。
{ // 方法体开始。
if (privateToPublic.TryGetValue(privateEndPoint, out string existed)) // 如果这个内网地址已经有映射。
{ // 已存在映射分支开始。
return existed; // 直接返回已有公网地址,保持连接稳定。
} // 已存在映射分支结束。
string publicEndPoint = $"{publicIp}:{nextPublicPort++}"; // 分配一个新的公网 IP 和端口。
privateToPublic[privateEndPoint] = publicEndPoint; // 记录内网到公网的映射。
publicToPrivate[publicEndPoint] = privateEndPoint; // 记录公网到内网的反向映射。
return publicEndPoint; // 返回转换后的公网地址。
} // 方法体结束。
public string TranslateInbound(string publicEndPoint) // 模拟公网回包进入 NAT 时的反向转换。
{ // 方法体开始。
if (publicToPrivate.TryGetValue(publicEndPoint, out string privateEndPoint)) // 查找公网地址对应的内网地址。
{ // 找到映射分支开始。
return privateEndPoint; // 返回真正的内网目标地址。
} // 找到映射分支结束。
return "drop"; // 找不到映射时,NAT 会丢弃这个入站包。
} // 方法体结束。
} // 类体结束。客户端如何选择最近服务器?
标准答案
客户端选择“最近服务器”,不是只看地理距离,而是选真实连接质量最好且业务可用的服务器。
通常流程是:
- 客户端先请求登录服或调度服。
- 调度服返回候选网关列表:区域、IP、负载、版本、维护状态、权重。
- 客户端对候选节点做探测:RTT、丢包、抖动。
- 过滤不可用节点:版本不匹配、维护中、跨区限制、满载。
- 对剩余节点综合打分,选择分数最低的。
- 连接失败后切备用节点,必要时重新拉调度列表。
底层原理
“最近”主要看这些指标:
RTT:往返延迟,越低越好。
Packet Loss:丢包率,实时游戏里非常关键。
Jitter:抖动,延迟不稳定会影响手感。
Load:服务器负载,延迟低但满载也不能选。
Version / Region:版本、大区、账号服、房间服必须匹配。
所以常见公式类似:
score = RTT权重 + 丢包权重 + 抖动权重 + 负载权重分数越低越适合连接。
C# 简化代码
c
using System; // 引入基础类型。
using System.Collections.Generic; // 引入 List 集合。
using System.Linq; // 引入 OrderBy 排序方法。
public sealed class ServerCandidate // 定义服务器候选节点。
{ // 类体开始。
public string Name = ""; // 服务器名字。
public int RttMs; // 客户端探测到的 RTT。
public float LossRate; // 丢包率,例如 0.02 表示 2%。
public int JitterMs; // 延迟抖动值。
public float LoadRate; // 服务器负载,例如 0.8 表示 80%。
public bool VersionMatched; // 客户端版本是否匹配。
public bool InMaintenance; // 服务器是否维护中。
} // 类体结束。
public static class ServerSelector // 定义服务器选择器。
{ // 类体开始。
public static ServerCandidate SelectBest(List<ServerCandidate> servers) // 从候选列表里选择最优服务器。
{ // 方法体开始。
return servers.OrderBy(CalcScore).First(); // 按分数从低到高排序,并返回分数最低的节点。
} // 方法体结束。
private static float CalcScore(ServerCandidate s) // 计算服务器综合评分。
{ // 方法体开始。
if (!s.VersionMatched) return float.MaxValue; // 版本不匹配直接过滤。
if (s.InMaintenance) return float.MaxValue; // 维护中的服务器直接过滤。
float score = 0f; // 初始化分数。
score += s.RttMs * 1.0f; // RTT 越高,分数越高。
score += s.LossRate * 1000f; // 丢包率影响很大,所以权重更高。
score += s.JitterMs * 0.5f; // 抖动会影响手感,也加入评分。
score += s.LoadRate * 100f; // 服务器负载越高,分数越高。
return score; // 返回最终评分。
} // 方法体结束。
} // 类体结束。游戏工程补充
大厂项目通常不会让客户端完全自己选。更常见是:服务端调度先过滤大区、版本、负载、灰度、维护状态,客户端再用 RTT/丢包探测做最后选择。
这样既能保证体验,也能避免客户端选到不该进的服。
心跳间隔怎么设计?
一句话定义 心跳间隔不是“越短越好”,而是在“及时发现断线”和“减少流量、电量、服务器压力”之间取平衡;通常要把“发送间隔”和“超时判定”分开设计。
核心设计heartbeatInterval:多久发一次 Ping。 heartbeatTimeout:多久没收到 Pong 才认为连接异常。 maxMissed:允许连续丢几次心跳。
面试里可以这样答: 前台战斗这种实时性强的场景,可以 3 到 5 秒一次心跳,10 到 15 秒没回应再进入弱网或重连。大厅、聊天、普通长连接可以放宽到 10 到 15 秒一次,30 到 45 秒判超时。移动端后台可能被系统挂起,不能完全依赖后台心跳,通常切回前台后主动重连并拉取最新状态。
底层原理 心跳主要解决两个问题:第一,应用层主动判断连接是否还活着;第二,UDP 或长连接场景下可以维持 NAT 映射。不要丢一次包就断线,因为移动网络会抖动。更稳的做法是记录 lastPongTime,如果 now - lastPongTime > timeout,或者连续丢 N 次,才进入弱网、重连、状态同步流程。
Unity/游戏场景坑点 不要用 Time.time 做网络超时,因为暂停时 timeScale = 0 会影响它。网络心跳应该用 Time.realtimeSinceStartup 或 Time.unscaledTime。另外要加随机抖动,比如 5 秒心跳可以变成 4.5 到 5.5 秒,避免所有客户端同一时间打到服务器。
c
using System; // 引入 Action 委托类型
public sealed class HeartbeatManager // 定义心跳管理器
{ // 类开始
private readonly float intervalSeconds; // 保存心跳发送间隔
private readonly float timeoutSeconds; // 保存心跳超时时间
private readonly Action sendPing; // 保存发送 Ping 的回调
private readonly Action onTimeout; // 保存超时后的回调
private float nextPingTime; // 记录下一次发送 Ping 的时间
private float lastPongTime; // 记录最后一次收到 Pong 的时间
private bool running; // 记录心跳是否正在运行
public HeartbeatManager(float intervalSeconds, float timeoutSeconds, Action sendPing, Action onTimeout) // 构造心跳管理器
{ // 构造函数开始
this.intervalSeconds = intervalSeconds; // 保存外部传入的心跳间隔
this.timeoutSeconds = timeoutSeconds; // 保存外部传入的超时时间
this.sendPing = sendPing; // 保存发送 Ping 的逻辑
this.onTimeout = onTimeout; // 保存超时处理逻辑
} // 构造函数结束
public void Start(float now) // 启动心跳
{ // Start 方法开始
running = true; // 标记心跳开始运行
lastPongTime = now; // 把当前时间当作初始 Pong 时间
nextPingTime = now + intervalSeconds; // 计算第一次发送 Ping 的时间
} // Start 方法结束
public void Tick(float now) // 每帧或定时调用心跳更新
{ // Tick 方法开始
if (!running) return; // 如果没有运行就直接返回
if (now >= nextPingTime) // 如果到达下一次发送 Ping 的时间
{ // 发送分支开始
sendPing?.Invoke(); // 调用发送 Ping 的回调
nextPingTime = now + intervalSeconds; // 重新计算下一次 Ping 的时间
} // 发送分支结束
if (now - lastPongTime >= timeoutSeconds) // 如果距离上次 Pong 已经超过超时时间
{ // 超时分支开始
running = false; // 停止当前心跳
onTimeout?.Invoke(); // 触发弱网或重连逻辑
} // 超时分支结束
} // Tick 方法结束
public void OnPong(float now) // 收到服务器 Pong 时调用
{ // OnPong 方法开始
lastPongTime = now; // 更新最后一次收到 Pong 的时间
} // OnPong 方法结束
} // 类结束Unity 里调用时可以这样:heartbeat.Tick(Time.realtimeSinceStartup);,不要被暂停系统影响。
面试加分说法 我不会把心跳间隔写死成一个值,而是按场景动态调整:战斗中更短,大厅中更长,后台走恢复重连。超时后也不会立刻踢玩家,而是进入弱网提示、重连、状态重拉。这个方案本质是在实时性、服务器压力、移动端电量和弱网容错之间做平衡。
网络消息乱序怎么处理?
标准答案 网络消息乱序的核心处理方式是:消息带序号,接收端维护期望序号,把提前到的消息先缓存,等缺失消息补齐后再按顺序交给业务层。
但不是所有消息都要强制有序。面试里要说清楚: 背包、交易、结算、技能确认这类消息要可靠有序;位置同步、心跳、快照同步这类消息通常可以乱序,直接丢旧包、用最新包覆盖。
底层原理 如果收到的 seq == expectedSeq,说明正好是当前该处理的消息,直接执行,然后 expectedSeq++。 如果收到的 seq > expectedSeq,说明中间缺包了,这个包先放到缓存里,不能立刻执行。 如果收到的 seq < expectedSeq,说明是旧包或重复包,直接丢弃,避免重复执行业务逻辑。
真正的关键是:网络层可以乱序,但业务层看到的顺序必须符合业务规则。
Unity/游戏场景 在 Unity 项目里,我一般会把网络接收线程和主线程逻辑分开:网络线程负责收包、解码、排序缓存,最后把可以执行的消息放进主线程队列;主线程在 Update 里统一消费,避免子线程直接操作 Unity API。
同时会按消息类型拆 Channel: 战斗结算、背包变化走可靠有序 Channel; 角色位置、朝向、动画快照走不可靠或最新覆盖 Channel; 这样可以避免一个背包消息丢包,把所有位置同步都卡住。
c
using System; // 引入 Action 委托类型
using System.Collections.Generic; // 引入 SortedDictionary 集合类型
public sealed class OrderedMessageReceiver // 定义一个按序提交消息的接收器
{ // 类开始
private int expectedSeq; // 记录当前期望收到的消息序号
private readonly int windowSize; // 记录接收窗口大小,防止缓存无限增长
private readonly SortedDictionary<int, byte[]> pending; // 缓存提前到达的乱序消息
public OrderedMessageReceiver(int startSeq, int windowSize) // 构造接收器
{ // 构造函数开始
this.expectedSeq = startSeq; // 设置第一个期望处理的序号
this.windowSize = windowSize; // 设置最大接收窗口
this.pending = new SortedDictionary<int, byte[]>(); // 创建乱序消息缓存
} // 构造函数结束
public void OnReceive(int seq, byte[] payload, Action<int, byte[]> handle) // 收到一条网络消息
{ // OnReceive 方法开始
if (seq < expectedSeq) return; // 如果序号小于期望值,说明是旧包或重复包,直接丢弃
if (seq >= expectedSeq + windowSize) return; // 如果序号超出接收窗口,说明太靠后或可疑,直接丢弃
if (!pending.ContainsKey(seq)) pending.Add(seq, payload); // 如果不是重复包,就先放入缓存
while (pending.TryGetValue(expectedSeq, out byte[] nextPayload)) // 只要缓存里存在当前期望序号,就连续处理
{ // 循环开始
pending.Remove(expectedSeq); // 从缓存里移除即将处理的消息
handle(expectedSeq, nextPayload); // 把按序消息交给业务层执行
expectedSeq++; // 处理完成后,期望序号向后移动一位
} // 循环结束
} // OnReceive 方法结束
} // 类结束常见坑点 不要所有消息共用一个全局顺序队列,否则一个不重要消息丢了,可能把战斗、移动、聊天全部堵住,这就是队头阻塞。 不要重复执行旧包,比如扣金币、发奖励、结算伤害,这些逻辑必须做幂等。 乱序缓存要有窗口大小和超时策略,否则恶意大序号包可能撑爆内存。 如果缺失消息长期不来,要么请求重传,要么断线重连后从服务器重新拉权威状态。
面试加分说法 我会先按业务重要性拆消息通道:可靠有序、可靠无序、不可靠最新覆盖。对必须有序的消息用 seq + expectedSeq + pending buffer;对位置快照这种消息,不等旧包,直接用最新 seq 覆盖。这样既保证关键逻辑一致性,又不会因为少量丢包影响实时手感。
消息重复怎么处理?
标准答案 消息重复一般用三层处理:消息唯一 ID 去重、最近处理缓存、业务幂等兜底。 最关键的一句话是:重复包不可怕,可怕的是重复执行业务逻辑。
比如客户端发送“购买道具”,第一次服务端已经扣金币并加道具了,但回包丢了。客户端超时后重发同一个 requestId。服务端发现这个 requestId 已经处理过,就直接返回上一次结果,不能再扣一次金币。
底层原理 常见重复来源有:ACK 丢失、客户端重试、服务端重发、断线重连补包、网络层可靠 UDP 重传。 所以每条关键消息要带唯一标识,比如:
playerId + requestIdconnectionId + seqbattleId + frame + commandId
接收端保存一段时间内处理过的 ID。 如果没见过,就执行业务并记录结果;如果见过,就说明是重复消息,直接丢弃或返回缓存结果。
Unity/游戏场景 在游戏里,购买、领奖、任务提交、战斗结算、邮件领取这些必须服务端幂等。 位置同步、血量快照、动画状态这类消息不一定要缓存所有重复包,通常只看 seq 或时间戳,旧版本直接丢掉,新版本覆盖。
c
using System; // 引入 Func 委托类型
using System.Collections.Generic; // 引入 Dictionary 和 Queue 集合类型
public sealed class IdempotentRequestCache<TResult> // 定义一个幂等请求缓存类
{ // 类开始
private readonly int capacity; // 保存最多缓存多少个请求结果
private readonly Dictionary<string, TResult> results; // 保存 requestId 到执行结果的映射
private readonly Queue<string> order; // 保存 requestId 的进入顺序,方便淘汰旧数据
public IdempotentRequestCache(int capacity) // 构造幂等缓存
{ // 构造函数开始
this.capacity = capacity; // 记录缓存容量
this.results = new Dictionary<string, TResult>(); // 创建结果字典
this.order = new Queue<string>(); // 创建顺序队列
} // 构造函数结束
public TResult Handle(string requestId, Func<TResult> executeOnce) // 处理一条可能重复的请求
{ // Handle 方法开始
if (results.TryGetValue(requestId, out TResult cachedResult)) // 如果 requestId 已经处理过
{ // 重复请求分支开始
return cachedResult; // 直接返回上一次结果,不重复执行业务
} // 重复请求分支结束
TResult newResult = executeOnce(); // 第一次收到该请求,真正执行业务逻辑
results.Add(requestId, newResult); // 把执行结果缓存起来
order.Enqueue(requestId); // 记录这个 requestId 的顺序
while (order.Count > capacity) // 如果缓存数量超过容量
{ // 淘汰循环开始
string oldRequestId = order.Dequeue(); // 取出最旧的 requestId
results.Remove(oldRequestId); // 从结果字典中删除最旧记录
} // 淘汰循环结束
return newResult; // 返回本次真正执行后的结果
} // Handle 方法结束
} // 类结束面试加分说法 我不会只在客户端去重,因为客户端不可信。关键业务必须在服务端做幂等,比如同一个 requestId 只允许扣款一次、发奖一次、结算一次。客户端去重更多是减少重复表现,比如避免重复弹窗、重复播放奖励动画。服务端去重才是保证数据正确性的核心。