Skip to content

网络

TCP 拥塞控制是什么?

tcp-congestion-control

标准答案

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-sliding-window

标准答案

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-retransmission

标准答案

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 算法是什么?

tcp-nagle-algorithm

标准答案

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-packet-loss-handling

标准答案

UDP 本身不保证可靠:可能丢包、乱序、重复、延迟。所以 UDP 丢包不是靠协议自动处理,而是应用层自己决定怎么处理。

核心原则是:不要所有包都重传,要先按消息类型分类。

可靠消息,比如购买、结算、技能确认、进入房间,要做 seq + ACK + 超时重传 + 去重

实时状态,比如位置、朝向、动画快照,通常不补旧包,而是用下一帧新数据覆盖,再配合插值、外推、预测来平滑。

底层思路

UDP 处理丢包常见有几套方案:

  1. 序号:每个包带 seq,接收端判断乱序、旧包、重复包。
  2. ACK:接收端告诉发送端哪些包收到了。
  3. 重传缓存:发送端保存重要消息,超时没收到 ACK 就重发。
  4. 去重和幂等:重复收到同一个可靠消息不能执行两次。
  5. 快照容错:位置同步丢一帧不补,直接等下一帧。
  6. 心跳和重连:长时间收不到包,认为弱网或断线,重连后拉服务端权威状态。

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-protocol

标准答案

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 -> IP

QUIC 自己管理 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 区别是什么?

http2-vs-http11

标准答案

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 FrameDATA 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-handshake-flow

标准答案

HTTPS 握手本质是:先建立 TCP 连接,再通过 TLS 握手完成身份认证和密钥协商,最后用协商出的会话密钥加密 HTTP 数据。

现代常见 TLS 1.3 流程可以这样记:

  1. TCP 三次握手:客户端和服务器先建立 TCP 连接。
  2. ClientHello:客户端发支持的 TLS 版本、密码套件、随机数、SNI、ALPN、key share。
  3. ServerHello:服务器选择 TLS 版本、密码套件,并返回自己的 key share。
  4. Certificate:服务器发送证书,证明“我就是这个域名对应的服务器”。
  5. 客户端校验证书:检查域名、有效期、CA 链、签名。
  6. Finished:双方确认握手内容没被篡改,并生成会话密钥。
  7. 加密传输 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-resolution-flow

标准答案

DNS 解析就是把域名转换成 IP 地址的过程。比如你访问 example.com,程序真正建立 TCP/UDP 连接时需要的是 IP,所以要先通过 DNS 查到它对应的 AAAAA 记录。

一句话记:DNS 解析 = 本地缓存/hosts -> 递归 DNS -> 根 DNS -> TLD -> 权威 DNS -> 返回 IP 并按 TTL 缓存。

底层流程

  1. 应用或浏览器先查自己的 DNS 缓存。
  2. 查不到再查操作系统 DNS 缓存。
  3. 还查不到会看 hosts 文件。
  4. 本地都没有,就请求递归 DNS 服务器,比如运营商 DNS、公司 DNS、公共 DNS。
  5. 递归 DNS 如果也没缓存,会先问根 DNS:.com 去哪里查?
  6. 根 DNS 返回 .com 顶级域 DNS 地址。
  7. 递归 DNS 再问 .com 的 TLD DNS:example.com 去哪里查?
  8. TLD DNS 返回 example.com 的权威 DNS。
  9. 递归 DNS 再问权威 DNS,拿到最终 IP。
  10. 结果返回客户端,并按 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-resource-download-acceleration

标准答案

CDN 加速资源下载的核心是:把资源提前缓存到离玩家更近的边缘节点,客户端下载时就近访问,而不是所有人都去打源站。

资源下载流程通常是:

  1. 客户端请求资源 URL。
  2. DNS 或 CDN 调度系统把客户端分配到较近、较健康的边缘节点。
  3. 如果边缘节点有缓存,直接返回资源。
  4. 如果边缘节点没有缓存,就回源站拉取资源。
  5. 拉到后边缘节点缓存一份,下次其他玩家访问就更快。
  6. 客户端下载后做 hash 校验,防止资源损坏或拿到旧包。

底层原理

CDN 加速主要靠四点:

就近访问:减少 RTT 和跨地域链路传输。

边缘缓存:热门资源不用重复从源站下载。

带宽分摊:大量玩家同时热更新时,不会把源站打爆。

调度容灾:某个节点慢或故障,可以切备用节点或备用 CDN。

Unity/游戏热更新实践

游戏里常把 AssetBundleAddressables 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

标准答案

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 会丢弃这个入站包。 
    } // 方法体结束。 
} // 类体结束。

客户端如何选择最近服务器?

client-nearest-server-selection

标准答案

客户端选择“最近服务器”,不是只看地理距离,而是选真实连接质量最好且业务可用的服务器

通常流程是:

  1. 客户端先请求登录服或调度服。
  2. 调度服返回候选网关列表:区域、IP、负载、版本、维护状态、权重。
  3. 客户端对候选节点做探测:RTT、丢包、抖动。
  4. 过滤不可用节点:版本不匹配、维护中、跨区限制、满载。
  5. 对剩余节点综合打分,选择分数最低的。
  6. 连接失败后切备用节点,必要时重新拉调度列表。

底层原理

“最近”主要看这些指标:

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/丢包探测做最后选择。

这样既能保证体验,也能避免客户端选到不该进的服。

心跳间隔怎么设计?

network-heartbeat-interval-design

一句话定义 心跳间隔不是“越短越好”,而是在“及时发现断线”和“减少流量、电量、服务器压力”之间取平衡;通常要把“发送间隔”和“超时判定”分开设计。

核心设计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.realtimeSinceStartupTime.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);,不要被暂停系统影响。

面试加分说法 我不会把心跳间隔写死成一个值,而是按场景动态调整:战斗中更短,大厅中更长,后台走恢复重连。超时后也不会立刻踢玩家,而是进入弱网提示、重连、状态重拉。这个方案本质是在实时性、服务器压力、移动端电量和弱网容错之间做平衡。

网络消息乱序怎么处理?

network-message-reorder-handling

标准答案 网络消息乱序的核心处理方式是:消息带序号,接收端维护期望序号,把提前到的消息先缓存,等缺失消息补齐后再按顺序交给业务层

但不是所有消息都要强制有序。面试里要说清楚: 背包、交易、结算、技能确认这类消息要可靠有序;位置同步、心跳、快照同步这类消息通常可以乱序,直接丢旧包、用最新包覆盖。

底层原理 如果收到的 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 覆盖。这样既保证关键逻辑一致性,又不会因为少量丢包影响实时手感。

消息重复怎么处理?

network-duplicate-message-handling

标准答案 消息重复一般用三层处理:消息唯一 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 只允许扣款一次、发奖一次、结算一次。客户端去重更多是减少重复表现,比如避免重复弹窗、重复播放奖励动画。服务端去重才是保证数据正确性的核心。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0