面经

抖音用户产品-后端一面

2026-08-25

1.HTTP和WebSocket区别

HTTP 和 WebSocket 都属于应用层协议,但通信模型不同。HTTP 基于请求-响应模型,通常由客户端发起请求,服务端返回响应;WebSocket 建立连接之后,双方都可以主动发送数据,因此是全双工通信。

HTTP/1.1 的 Keep-Alive 只是复用底层 TCP 连接,并没有改变 HTTP 的请求-响应模型。WebSocket 则是在建立 TCP 连接后,通过 HTTP Upgrade 机制将协议升级为 WebSocket。

sequenceDiagram participant C as Client participant S as Server C->>S: HTTP GET + Upgrade: websocket S-->>C: 101 Switching Protocols C->>S: WebSocket Frame S-->>C: WebSocket Frame S-->>C: Server Push C->>S: WebSocket Frame

WebSocket 的数据传输以 Frame 为基本单位,而不是 HTTP Request/Response。连接建立后,服务端可以直接向客户端推送消息,不需要客户端不断轮询。

因此,HTTP 更适合普通 API、页面资源、CRUD 等请求-响应场景;WebSocket 更适合聊天、实时监控、实时行情、在线协作、游戏状态同步等需要服务端主动推送的场景。

需要注意,WebSocket 本身并不负责可靠传输,它通常运行在 TCP 之上,因此可靠性来自 TCP。WebSocket 解决的是应用层的实时双向通信问题。

另外,HTTP/2 和 HTTP/3 容易被一起追问。HTTP/2 仍然运行在 TCP 上,通过 Stream 实现多路复用;HTTP/3 则运行在 QUIC 之上,而 QUIC 基于 UDP。


2.传输层有哪些协议?有什么区别?

计算机网络中最主要的传输层协议是 TCP 和 UDP。现代网络中还需要了解 QUIC,但严格来说 QUIC 是构建在 UDP 之上的可靠传输协议,而不是传统意义上与 TCP 完全同层次的简单替代。

TCP 是面向连接、可靠、有序的字节流协议。TCP 通过序列号、ACK、超时重传、快速重传、滑动窗口、流量控制和拥塞控制等机制保证可靠传输。

UDP 是无连接、面向数据报的协议,不负责可靠传输、排序、重传、流量控制和拥塞控制。UDP 的优势是协议简单、额外开销较低,而且应用层可以直接控制数据报边界和自己的可靠性机制。

特性 TCP UDP
连接 面向连接 无连接
可靠性 可靠 不保证
数据形式 字节流 数据报
顺序 保证 不保证
重传
流量控制
拥塞控制
开销 较高 较低

TCP 适合 HTTP/1.1、HTTP/2、数据库连接等强调可靠性的场景。UDP 常用于 DNS、实时音视频、游戏等对实时性要求高或者希望自己控制传输机制的场景。

QUIC 在 UDP 之上实现了可靠传输、拥塞控制、流量控制、多路复用和 TLS 等机制。HTTP/3 就是基于 QUIC 实现的。


3.HTTP的完整过程?

访问一个 HTTPS URL 时,可以把整个过程理解为:

sequenceDiagram participant B as Browser participant DNS as DNS participant S as Server B->>B: URL 解析 B->>DNS: DNS 查询 DNS-->>B: IP 地址 B->>S: TCP 三次握手 S-->>B: TCP 建立 B->>S: TLS 握手 S-->>B: TLS 建立 B->>S: HTTP Request S-->>B: HTTP Response B->>B: 解析 HTML/CSS/JS 并渲染

首先浏览器解析 URL,得到协议、域名、端口和路径。例如 https://example.com/index.html 对应 HTTPS、域名 example.com、默认端口 443 和 /index.html

之后进行 DNS 解析,把域名转换为 IP 地址。DNS 查询过程中可能经过浏览器缓存、操作系统缓存、本地 DNS、递归 DNS 和权威 DNS 等。

拿到 IP 后,HTTPS 通常先建立 TCP 连接,通过 TCP 三次握手同步双方的初始序列号并确认双方的收发能力。

TCP 建立后进行 TLS 握手。TLS 主要解决通信的机密性、完整性和身份认证问题,并通过证书验证服务器身份、协商会话密钥。

随后浏览器发送 HTTP 请求,例如:

GET /index.html HTTP/1.1
Host: example.com

服务器收到请求后可能经过 Nginx、网关、应用服务器、Redis、MySQL 等组件处理,最终生成 HTTP Response。

浏览器收到响应后解析 HTML,并根据 HTML 中引用的 CSS、JavaScript、图片等资源继续发起请求,最终完成页面渲染。

如果是 HTTP/2,则多个 HTTP Stream 可以复用一个 TCP 连接;如果是 HTTP/3,则底层变成 QUIC,不再依赖 TCP。


4.TCP如何进行流量控制和拥塞控制

流量控制和拥塞控制解决的是两个不同的问题。

流量控制解决的是“接收方处理不过来”,核心机制是接收窗口 rwnd。接收方拥有自己的 TCP 接收缓冲区,并通过 TCP Header 中的 Window 字段告诉发送方当前还能接收多少数据。

如果接收方处理速度较慢,接收缓冲区逐渐被填满,那么通告窗口 rwnd 就会变小,发送方必须降低发送速率。

拥塞控制解决的是“网络承受不过来”,核心变量是拥塞窗口 cwnd。TCP 根据网络中的 ACK、丢包和超时等信息动态调整 cwnd

经典 TCP 拥塞控制包括慢启动、拥塞避免、快速重传和快速恢复。慢启动阶段 cwnd 快速增长,达到 ssthresh 后进入拥塞避免阶段,通常进行线性增长。

发生三个重复 ACK 时,TCP 通常认为某个报文段丢失,触发快速重传,而不是等待 RTO 超时。如果发生超时,则通常认为网络拥塞更加严重,会大幅降低发送窗口并重新进入慢启动过程。

最终 TCP 实际能够发送的数据量受到接收方和网络状态共同限制:

发送窗口 = min(rwnd, cwnd)

因此可以把两者简单区分为:

流量控制:端到端,限制发送方,保护接收方
拥塞控制:网络整体,限制发送方,避免网络拥塞

5.可执行文件的执行过程,包括有print()

Linux 下典型的可执行文件格式是 ELF。程序执行时,并不是简单地把整个可执行文件复制到物理内存,而是由操作系统建立进程的虚拟地址空间,并根据 ELF 的 Program Header 将相应的程序段映射到虚拟内存中。

典型过程可以概括为:

flowchart LR A[Shell 执行程序] --> B[execve] B --> C[解析 ELF] C --> D[建立进程虚拟地址空间] D --> E[映射代码段和数据段] E --> F[加载动态链接库] F --> G[建立用户栈] G --> H[进入程序入口] H --> I[main] I --> J[执行 printf] J --> K[libc] K --> L[write 系统调用] L --> M[Linux Kernel] M --> N[终端/文件描述符]

ELF 中常见的内容包括 .text.rodata.data.bss 等。.text 保存代码,.rodata 通常保存只读数据,.data 保存已经初始化的全局变量和静态变量,.bss 保存未初始化或者初始化为零的全局变量和静态变量。

程序加载后形成进程的虚拟地址空间,通常包括代码段、只读数据段、数据段、BSS、Heap、共享库以及 Stack。

现代操作系统通常采用 Demand Paging。执行程序时并不需要马上将所有代码和数据读入物理内存。当 CPU 访问尚未建立物理页映射的虚拟地址时,会产生 Page Fault,由操作系统将相应内容加载到物理内存并建立映射。

对于:

printf("hello\n");

printf() 是用户态的 C 标准库函数,并不等于系统调用。它首先可能把数据写入 stdout 缓冲区,在满足刷新条件后通过 write() 等系统调用进入内核。

因此可以理解为:

printf
→ libc
→ write
→ syscall
→ Kernel
→ 文件描述符
→ 终端

这里还需要区分用户态和内核态。执行 syscall 后 CPU 从用户态进入内核态,由内核完成真正的 I/O 操作,然后返回用户态继续执行。


6.MySQL的数据碎片化是什么 怎么解决?存储结构是什么样的?

这里主要针对 InnoDB。

InnoDB 的物理存储可以抽象为:

flowchart LR A[Tablespace] --> B[Segment] B --> C[Extent] C --> D[Page] D --> E[Record]

Page 是 InnoDB 管理磁盘空间和 Buffer Pool 的基本单位,默认通常为 16KB。Extent 通常由多个连续 Page 构成。

InnoDB 的聚簇索引采用 B+Tree。主键索引的叶子节点直接保存完整的数据记录,因此 InnoDB 中的数据本身就是按照聚簇索引组织的。

二级索引的叶子节点通常保存二级索引键和主键值。通过二级索引查询到主键后,再通过聚簇索引获取完整记录,这个过程称为回表。

数据碎片化主要发生在大量 INSERT、DELETE、UPDATE 后。记录删除后,Page 中可能留下空闲空间;随机插入和更新也可能导致数据页利用率下降。长期积累后,磁盘空间利用率降低,查询可能需要访问更多 Page。

因此碎片化可以理解为“数据页中存在较多无法充分利用的空间,以及数据和索引的物理组织效率下降”。

常见的处理方式是:

OPTIMIZE TABLE table_name;

对于 InnoDB,其核心效果通常是重建表和索引,使数据重新组织,从而回收空间、改善 Page 利用率。

生产环境执行时需要考虑表大小、IO 压力、DDL 对业务的影响以及是否支持合适的 Online DDL,不能把 OPTIMIZE TABLE 当成常规操作频繁执行。


7.MySQL的锁有哪些?

MySQL InnoDB 的锁可以从不同维度理解。

按照粒度,可以分成表锁和行锁。按照锁的兼容关系,可以分成共享锁 S 和排他锁 X。

共享锁允许多个事务同时读取,但不能与排他锁共存;排他锁用于修改数据,同一条记录上的 S 和 X 都不能与另一个 X 同时存在。

InnoDB 的行锁主要包括 Record Lock、Gap Lock 和 Next-Key Lock。

Record Lock 锁定具体索引记录。

Gap Lock 锁定索引记录之间的间隙,例如索引值 10 和 20 之间的范围。它主要用于防止其他事务在范围内插入新的记录,从而解决 RR 隔离级别下的一部分幻读问题。

Next-Key Lock 可以理解为 Record Lock 和 Gap Lock 的组合,是 InnoDB 在 RR 隔离级别下进行范围锁定时的重要机制。

一个非常重要的知识点是:

InnoDB 的行锁实际上是加在索引记录上的。

例如:

UPDATE user
SET name = 'Tom'
WHERE id = 10;

如果 id 是索引,则可以锁定对应索引记录。

如果查询条件没有合适的索引:

UPDATE user
SET name = 'Tom'
WHERE age = 20;

可能需要扫描大量记录,从而造成更大范围的锁竞争。因此数据库性能分析中经常需要同时看 SQL、索引和锁。


8.行锁和表锁之间是怎么互斥的?

这里核心考点是 InnoDB 的意向锁。

如果事务准备给某些行加共享锁,就会先获得表级 IS(Intent Shared);如果准备给某些行加排他锁,就会先获得表级 IX(Intent Exclusive)。

例如事务执行:

SELECT *
FROM user
WHERE id = 10
FOR UPDATE;

可以理解为它需要:

表级 IX
+
对应记录上的 X Lock

为什么需要意向锁?假设事务 A 已经在某一行上加了 X Lock,此时事务 B 想获取整个表的 X Lock。如果没有意向锁,数据库需要判断这个表里面是否存在行级锁。

有了 IX 后,表级锁可以直接通过意向锁判断是否存在潜在的行锁冲突。

因此意向锁的核心作用不是锁住具体的数据,而是:

表明事务打算在表中的某些记录上获取哪一种行锁,从而协调表锁和行锁。

可以把关系理解成:

flowchart TD A[事务] --> B[表级意向锁] B --> C[行级锁] C --> D[具体索引记录]

所以不能简单理解成“行锁和表锁天然互斥”。InnoDB 通过意向锁建立表级锁和行级锁之间的兼容性检查机制。


9.Redis并发集群里,热点数据导致负载不均衡,怎么解决?

Redis Cluster 使用 16384 个 Hash Slot 进行数据分片。Key 根据哈希算法映射到 Slot,再由 Slot 映射到 Redis 节点。

正常情况下,不同 Key 可以分散到不同节点。但是如果大量请求集中访问同一个 Key:

hot_key

那么这个 Key 无论访问多少次,都只对应一个 Slot,因此请求会集中到负责这个 Slot 的 Redis 节点。

这属于典型的 Hot Key 问题。

解决方案首先是本地缓存。如果数据允许短时间缓存,可以在应用实例内部增加 Local Cache:

flowchart LR A[Client] --> B[Local Cache] B -->|Miss| C[Redis Cluster] C --> D[Database]

这样绝大部分热点读取直接在应用进程内完成,不进入 Redis。

第二种方法是热点 Key 拆分。例如原来:

product:10086

可以拆成:

product:10086:0
product:10086:1
product:10086:2
...

应用层随机选择一个 Key。由于 Key 不同,它们可能被映射到不同 Slot,从而将访问压力分散到多个 Redis 节点。

第三种方法是使用 Redis Replica 分摊读请求。如果热点数据主要是读,可以增加多个副本,让读取请求分散到不同副本。但如果瓶颈是写请求,Replica 不能从根本上解决 Master 的写热点。

还可以使用多级缓存:

flowchart LR A[Request] --> B[L1 本地缓存] B -->|Miss| C[L2 Redis] C -->|Miss| D[L3 Database]

如果热点数据同时伴随缓存击穿问题,还需要通过互斥锁、SingleFlight 等机制控制回源请求数量,避免大量请求同时访问数据库。

核心要区分:

普通的数据分片不均衡,可以通过 Slot 迁移解决;单个热点 Key 导致的负载集中,需要通过缓存、Key 拆分、Replica 等方式解决。


10.1T的数据 4G的内存,怎么排序

这是经典的外部排序问题。

因为数据量远大于内存容量,所以不能把所有数据一次性加载到内存中排序,需要使用 External Merge Sort。

第一阶段是生成有序 Run。把 1TB 数据分成若干个内存可以处理的数据块,例如每次读取 2GB。每个数据块加载到内存中完成排序,再写回磁盘。

得到:

Run1
Run2
Run3
...
RunN

每一个 Run 内部已经有序。

第二阶段进行多路归并。假设有 N 个有序 Run,就分别读取每个 Run 当前最小的元素,使用最小堆维护这些元素。每次取出堆顶最小值,再从对应 Run 中读取下一个元素加入堆,直到所有 Run 处理完成。

最终得到一个完整有序的数据文件。

整个过程的关键不是算法本身,而是磁盘 IO。实际实现需要尽可能使用顺序读写,并通过 Buffer 减少随机 IO。

算法复杂度通常可以看作:

时间复杂度:O(N log N)

其中 N 是数据规模。

这道题本质上考的是:

当数据规模超过内存时,如何利用磁盘作为外部存储,通过“分块排序 + 多路归并”完成排序。


11.设计一个令牌桶

令牌桶是一种限流算法。

它维护一个固定容量的桶,桶中存放 Token。系统按照固定速率生成 Token,但是桶最多只能保存 capacity 个 Token。

请求到达时消耗一个 Token,有 Token 则允许请求,没有 Token 则拒绝或者等待。

令牌桶有两个重要参数:

capacity:桶容量
rate:Token 生成速率

例如:

capacity = 100
rate = 10 token/s

表示系统长期平均允许约 10 个请求每秒,同时最多允许一定程度的突发流量,因为桶中可以提前积累 100 个 Token。

实际实现不需要定时器每隔固定时间生成 Token,可以通过时间戳进行惰性计算:

新增 Token
=
(now - lastTime) × rate

然后:

tokens = min(capacity, tokens + 新增Token)

如果 tokens >= 1,则扣除一个 Token 并放行,否则拒绝。

单机实现时,需要保证“补充 Token、判断 Token、扣除 Token、更新时间”这一整个过程的并发安全,否则多个 goroutine 同时执行可能导致超发。Go 中可以使用 sync.Mutex,也可以根据实现方式使用 CAS 等原子操作。

如果是分布式限流:

flowchart LR A[Server A] --> D[Redis] B[Server B] --> D C[Server C] --> D D --> E[Token Bucket State]

多个服务实例必须共享 Token Bucket 状态,否则每个实例独立限流后,总限流值会变成:

总限流 = 实例数 × 单实例限流

Redis + Lua 是常见方案。Lua Script 可以把“读取状态 → 根据时间补充 Token → 判断 → 扣减 → 更新时间”放在 Redis 中原子执行。


12.二维数组中找最大正方形的面积

题目详解可见此

定义:

dp[i][j]

表示以 (i,j) 为右下角的、只包含 1 的最大正方形边长。

如果当前位置为 0:

dp[i][j] = 0

如果当前位置为 1:

dp[i][j] =
min(
    dp[i-1][j],
    dp[i][j-1],
    dp[i-1][j-1]
) + 1

这里取三个方向的最小值,是因为以当前位置作为右下角形成一个更大的正方形时,上方、左方和左上方都必须能够形成足够大的正方形。

例如:

1 1
1 1

右下角可以形成边长为 2 的正方形,因此 dp 对应位置为 2。

遍历整个矩阵时维护最大边长 maxSide,最后:

最大面积 = maxSide × maxSide

时间复杂度:

O(nm)

如果使用二维 dp 数组,空间复杂度为:

O(nm)

实际上可以优化到 O(m),因为当前状态只依赖上一行、当前行左侧以及左上角的状态。此时需要额外保存更新前的左上角值。

这道题真正需要掌握的不是代码,而是状态定义:

dp[i][j] 表示“以当前位置为右下角的最大正方形边长”。

一旦状态定义确定,状态转移自然就是三个方向取最小值再加一。