Skip to content

IEG天美工作室后台开发实习一二面凉经

来源:https://www.nowcoder.com/feed/main/detail/00ba76defe2f4709b2a34b24d11eb66f

1、go slice实现原理,追问扩容数组拷贝很耗时,有了解怎么做的优化吗?

Go 中的 slice 是一种轻量级的数据结构,它包含三个部分:指向底层数组的指针、当前长度(len)以及容量(cap)。

Go Slice 的实现原理

Go 中 slice 的内部表示:

c++
type slice struct {
    ptr unsafe.Pointer // 指向底层数组的指针
    len int            // 当前 slice 中的元素数量
    cap int            // 底层数组的总容量
}
  • 指针(ptr): 指向实际存储数据的连续内存区域。
  • 长度(len): 当前 slice 可用的元素个数。
  • 容量(cap): 底层数组能容纳的最大元素个数。

append 操作

  • 当使用 append 添加元素时,如果当前 slice 的长度小于容量,则直接在底层数组的剩余空间写入数据。
  • 如果新添加的元素超过了容量,Go 运行时就会进行扩容操作:
    1. 计算新容量: 小 slice 通常采用翻倍策略,而大 slice 可能按 1.25 倍甚至其他策略增长,这样可以平衡内存使用和扩容频率。
    2. 分配新数组: 根据新的容量分配一块连续内存。
    3. 数据拷贝: 使用 highly-optimized 的 memmove(汇编实现)将旧数组中的数据复制到新分配的数组中。
    4. 更新 slice: 新的底层数组替代旧数组,原 slice 的指针、长度和容量相应更新。

扩容操作涉及内存分配和数据拷贝。当 slice 较大时,这个复制过程会消耗较多 CPU 时间,成为性能瓶颈。特别是在高并发场景中,如果频繁调用 append 导致扩容,多次拷贝可能会累积出较大的开销。

扩容优化的方法

1. 提前预分配容量

如果能预知需要存储的数据量,可以在创建 slice 时直接指定足够的容量,避免多次扩容。

c++
// 假设需要存储 10000 个元素
data := make([]int, 0, 10000)c

这样就能避免在 append 时频繁触发扩容操作。

2. 批量添加数据

与逐个 append 不同,批量添加数据可以减少扩容次数。例如,将待添加的数据先收集到一个临时数组中,然后一次性使用 append 合并:

c++
temp := []int{...} // 批量数据
data = append(data, temp...)
3. 使用 sync.Pool 重用 slice

对于短生命周期的 slice,可以通过 sync.Pool 进行内存重用,减少频繁分配和释放内存的开销:

c++
var slicePool = sync.Pool{
    New: func() interface{} {
        return make([]int, 0, 1024) // 根据业务场景设定合适的初始容量
    },
}

func getSlice() []int {
    return slicePool.Get().([]int)
}

func putSlice(s []int) {
    // 重置 slice,避免数据污染
    slicePool.Put(s[:0])
}

2、redis缓存雪崩,缓存击穿,缓存穿透?

1. 缓存雪崩

缓存雪崩指的是大量缓存同时过期或失效,导致大量请求在短时间内直接打到后端数据库,造成数据库短时间内压力暴增,甚至引发系统故障。

原因:

  • 缓存中存储的 key 设置了相同的过期时间,导致大量 key 同时失效。
  • 外部因素(如 Redis 服务故障、网络问题等)使得缓存失效。

解决方案:

  • 过期时间加随机值:在设置缓存时,为不同的 key 添加随机的过期时间,避免大量 key 同时过期。
  • 预加载缓存:在缓存即将过期之前,通过后台线程预先刷新缓存。
  • 设置互备策略:在缓存失效时,采用降级方案,或利用分布式锁限制并发对数据库的访问。
  • 限流与熔断:对请求进行限流,避免短时间内的流量冲击数据库。

2. 缓存击穿

缓存击穿指的是某个非常热点的数据缓存突然失效,而此时有大量请求同时到达这个 key,导致所有请求直接打到后端数据库,造成数据库压力急剧增加。

特点:

  • 针对单个热点 key,当它失效后会有大量请求“击穿”缓存。
  • 与缓存雪崩不同,缓存击穿通常是针对某个特定的热点数据。

解决方案:

  • 热点数据永不过期:对于极其热门的数据,可以考虑设置不过期,或者在后台异步更新。
  • 互斥锁(Mutex):在缓存失效时,让第一个请求通过加锁去查询数据库,其它请求等待,等缓存更新后再返回。
  • 预加载机制:提前判断热点数据的访问量,通过预先加载确保热点数据始终在缓存中存在。

3. 缓存穿透

缓存穿透是指查询一个根本不存在的数据,由于缓存和数据库都没有这个数据,所有请求都会直接穿过缓存层打到数据库,可能导致数据库压力急剧增加。

常见原因:

  • 客户端恶意请求不存在的 key,或者攻击者利用大量不存在的 key 进行攻击。
  • 程序逻辑中对非法数据缺少过滤,导致频繁请求不存在的数据。

解决方案:

  • 布隆过滤器(Bloom Filter):在缓存层前增加布隆过滤器,快速判断 key 是否可能存在,从而拦截绝大部分不存在的请求。
  • 缓存空结果:对于查询不存在的数据,可以在缓存中保存一个空值(如 null),并设置较短的过期时间,避免频繁查询数据库。
  • 参数校验:在应用层增加对请求参数的校验,防止恶意请求直接进入数据库查询流程。

3、缓存击穿提到了分布式锁方案,问分布式锁如何实现。追问分布式锁抢锁失败应该做什么?

分布式锁的实现原理

使用 Redis 实现分布式锁

基本思路:利用 Redis 的原子性命令,通过设置一个带有超时时间的 key 来作为锁,常用的命令是 SET key value NX PX timeout

  • NX 参数保证只有在 key 不存在时才进行设置,确保只有一个客户端能获得锁。
  • PX timeout 为锁设置一个超时时间,防止由于持有锁的客户端异常退出导致死锁。

实现步骤:

  1. 获取锁:客户端生成一个唯一的标识(例如 UUID),然后尝试执行:

    c++
    SET lock_key <unique_value> NX PX 3000

    如果返回 OK,则表示成功获取锁;否则,说明有其他客户端持有锁。

  2. 释放锁:释放锁时要保证只有持有锁的客户端才能释放(防止误删其他客户端的锁)。

    通常采用 Lua 脚本保证原子性:

    c++
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end

    客户端在释放锁时传入同样的 unique_value,只有验证成功后才删除锁。

  3. RedLock 算法:对于对分布式锁强一致性要求较高的场景,还可以采用 RedLock 算法,通过在多个 Redis 实例上获取锁来提高容错性和安全性。

抢锁失败时的处理策略

在缓存击穿等场景下,使用分布式锁主要目的是让第一个获取锁的请求去查询数据库并更新缓存,而其他请求等待缓存更新。对于抢锁失败的情况,常见的处理策略有:

2.1 等待并重试(双重检查)
  1. 第一次查询缓存:当请求到来时,先查询缓存。如果命中直接返回,避免不必要的加锁。
  2. 尝试获取分布式锁:如果缓存未命中,尝试获取分布式锁:
    • 成功获取锁:执行数据库查询,更新缓存,最后释放锁,然后返回查询结果。
    • 抢锁失败:表明已有其他客户端正在更新缓存。
  3. 等待短暂延时后重试:抢锁失败的客户端可以等待一小段时间(例如几十到几百毫秒,最好采用随机延时以避免同时重试),然后重新查询缓存:
    • 如果缓存此时已被更新,则直接返回结果;
    • 如果依然未命中,可以尝试再次获取锁,或者根据业务场景选择返回错误或降级处理。

这种“双重检查锁”模式可以有效防止缓存击穿时数据库瞬间压力激增。

2.2 限流与熔断
  • 限流:如果大量请求抢锁失败,意味着后端可能正处于高负载状态,应用可以进行限流处理,拒绝部分请求,保证系统稳定。
  • 熔断:当检测到后端服务压力过大时,可以采用熔断策略,直接返回默认数据或错误提示,保护后端数据库。

4、缓存穿透提到了布隆过滤器,问布隆过滤器的原理,追问写操作时写布隆过滤器和写redis数据缓存的一致性如何保障?

布隆过滤器原理

布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,主要用于判断一个元素是否在一个集合中。

特点是:

  • 可能出现误判(假阳性): 当布隆过滤器判断某个元素存在时,可能会有少量误判,但如果判断某个元素不存在,则一定不存在(不会出现假阴性)。
  • 内部结构:布隆过滤器由一个固定长度的位数组和一组独立的哈希函数组成。

工作流程

  1. 添加元素:当需要将一个元素加入集合时,依次使用 k 个不同的哈希函数对该元素进行哈希,得到 k 个位置,将位数组中对应的 k 个位置置为 1。
  2. 判断元素是否存在:当判断一个元素是否在集合中时,同样使用 k 个哈希函数计算出 k 个位置:
    • 如果这 k 个位置中有任意一个位为 0,则该元素一定不在集合中;
    • 如果所有位置都是 1,则认为该元素可能在集合中(此处可能存在哈希碰撞,导致误判)。

这种设计使得布隆过滤器在占用较少内存的情况下,可以非常快速地判断元素是否存在,从而有效过滤掉大量不存在的 key,防止缓存穿透。

写操作时布隆过滤器与Redis数据缓存的一致性保障

实际应用中,为了防止缓存穿透,通常会在写数据到Redis缓存的同时,将对应的 key 写入布隆过滤器。

在分布式环境下,如何保证两者的一致性是个关键问题,主要面临以下挑战:

  • 写入顺序问题: 如果先写入 Redis,再更新布隆过滤器,可能会出现短暂的时间窗口内,Redis 已存在数据而布隆过滤器未更新,导致后续请求因布隆过滤器判定不存在而直接请求数据库;反之,如果先更新布隆过滤器再写 Redis,万一后续 Redis 写入失败,则布隆过滤器中存在数据但缓存中没有,可能导致无效查询穿透缓存层。
  • 分布式事务问题: 直接对两者进行原子性写入较难实现,因为 Redis 和布隆过滤器(通常在内存中维护或分布在不同的节点上)并不支持跨实例的分布式事务。

常见的优化与处理方案

  1. 写操作的顺序设计:
    • 先写 Redis,再更新布隆过滤器:写入 Redis 成功后,再异步或同步更新布隆过滤器。这样即使布隆过滤器更新延迟,也不会出现缓存缺失问题,因为数据已经存在 Redis 中。缺点是短时间内后续请求可能先到数据库查询,但数据不会错误丢失。
    • 幂等更新:由于布隆过滤器的写入操作是幂等的(多次将同一 key 添加到布隆过滤器影响相同),可以允许后续补偿更新。即如果第一次写入布隆过滤器失败,系统可以通过重试机制补上。
  2. 使用异步更新与重试机制:
    • 将布隆过滤器的更新放入消息队列或异步任务中,确保 Redis 写入成功后尽快更新布隆过滤器;
    • 如果布隆过滤器更新失败,异步任务会定时重试或通过监控报警,保证最终一致性。
  3. 定期同步机制:
    • 定时从 Redis 或后端数据库中重新生成或刷新布隆过滤器,保证在极端情况下(例如瞬时网络故障导致部分更新失败)布隆过滤器能恢复到一致状态;
    • 这种方式适用于数据变化不频繁的场景。
  4. 分布式事务(较少采用):
    • 尝试采用分布式事务方案,例如使用两阶段提交(2PC)或 Saga 模式来保证多数据源一致性,但由于性能和复杂性开销较大,通常不作为首选方案;
    • 一般情况下,布隆过滤器容忍短暂的不一致,因为其主要作用是过滤非法请求,误判只会导致少量额外数据库查询,而不会引入数据错误。

5、kafka如何保证消息的可靠性,追问消费者幂等性如何实现。幂等性提到了唯一id的方案,追问唯一id在哪里生成?

Kafka 消息可靠性保障

  • 数据持久化与日志结构:Kafka 将所有消息以追加写入的方式记录到磁盘上的日志文件中,写入顺序保证了数据持久化,同时可以通过顺序写入提高 IO 性能。
  • 副本机制:每个分区的消息会在多个 Broker 上备份(副本),当 Leader 宕机时,Follower 可以接替服务,从而保证数据不丢失。客户端可以设置 ACK 级别(例如 acks=all)来确保所有副本都确认写入后再返回成功。
  • 数据同步与恢复:当某个节点出现故障时,Kafka 会自动从其他节点恢复数据,确保集群整体数据的高可用性。
  • 事务机制与幂等性(Producer 端):Kafka 还支持幂等性生产者和事务特性,通过为每个生产者分配唯一的 producerId 和维护序列号,实现生产端的 exactly-once 语义,防止消息重复写入。

消费者幂等性实现

在消费者侧,幂等性主要用于防止由于重复消费或重试导致业务数据被重复处理。实现消费者幂等性常见做法是 去重,而去重的关键是为每条消息赋予一个全局唯一标识(唯一 ID)。

常见策略:
  • 利用消息中的唯一 ID 进行去重:每条消息携带一个业务唯一 ID,消费者在处理消息时先检查该 ID 是否已经处理过(例如记录在数据库、缓存或专门的幂等性存储中),如果已处理则跳过,从而实现幂等消费。
  • 使用 Kafka 内部位移(offset)配合外部存储:通过记录消费者的处理位移和业务数据标识,确保重复消费时能够判断该消息是否已经处理。
  • 结合事务:如果消费者同时更新业务数据和提交位移,可以使用分布式事务或 Kafka 自身的事务机制(从 Kafka 0.11 开始支持事务)来保证数据和位移的一致提交,从而实现 exactly-once 消费。

唯一 ID 的生成

  1. 生产者侧生成:最常见的做法是在消息产生阶段,由业务生产者在构造消息时生成全局唯一的 ID(如 UUID、Snowflake 算法生成的 ID等)。方式有以下优点:

    • 业务相关性: 唯一 ID 通常与业务上下文相关,可以更灵活地设计和使用。

    • 及时性: 生产者在发送消息前就能确定消息标识,消费者无需额外计算或依赖外部数据。

    • 跨系统一致性: 当多个系统写入同一主题时,统一由生产者生成的唯一 ID 能够更好地支持跨系统去重逻辑。

  2. Kafka Broker 生成:虽然 Kafka Broker 会为每条消息分配(topic, partition, offset)的组合,但这组合在严格意义上仅用于消息存储定位,而不是业务去重。它们的生成在消息被追加到日志时才确定,并且在重试或幂等生产者场景中可能无法完全代表业务的全局唯一性。

  3. 消费者侧生成(不常用):消费者侧通常不会生成唯一 ID,因为这会导致同一条消息在不同消费者之间无法达成一致去重判断,增加实现复杂度。

6、内存只有512M,进程分配1G内存可以实现吗。追问如何关闭换页机制,追问oom kill时杀哪些进程?

内存只有512M,进程分配1G内存可以实现吗?

原理说明:

  • 虚拟内存机制: 操作系统(如 Linux)支持虚拟内存技术,这意味着进程看到的内存空间是虚拟地址空间,并不直接等同于物理内存。如果系统启用了交换空间(swap),即使物理内存只有 512MB,也可以让进程分配 1GB 或更多的虚拟内存。实际上,内存分配时并不一定立刻物理映射全部内存页,而是在实际使用(写入)时才分配物理页,这与内存过量分配(overcommit)有关。
  • 风险和限制:
    • 如果系统启用了 swap,进程分配1GB内存是可以实现的,但当程序实际写入数据并使用这1GB时,操作系统可能会大量使用交换空间,导致性能急剧下降。
    • 如果系统禁用了 swap,且物理内存只有 512MB,进程请求1GB内存时可能会导致内存分配失败,或者在运行时触发 OOM(Out-Of-Memory)机制,系统可能杀掉部分进程。

因此,在物理内存只有512MB的机器上,允许进程分配1GB虚拟内存是可能的(依赖于虚拟内存、swap和内存过量分配策略),但实际能否稳定运行则取决于内存使用情况和是否启用了交换空间。

如何关闭换页机制?

关闭或限制换页(swap)的方法主要有两种:

  • 禁用 swap:在 Linux 系统中,可以使用以下命令关闭所有交换分区或交换文件:

    c++
    swapoff -a

    这样会禁用 swap,也就“关闭”了换页机制。但需要注意,这会让系统只能依赖有限的物理内存,一旦内存耗尽,可能会更快触发 OOM 机制。

  • 调整 swappiness 参数:Linux 内核有一个参数 vm.swappiness,用于控制内核使用 swap 的积极程度。值的范围是 0 到 100,数值越低,内核越不倾向于将内存页换出到 swap。当设置为 0 时,并非完全禁止 swap,但能最大限度地减少换页:

    c++
    sysctl -w vm.swappiness=0

    或者修改 /etc/sysctl.conf 文件使之永久生效:

    vm.swappiness = 0

OOM Kill 时杀哪些进程?

OOM(Out-Of-Memory) Killer 机制:当系统内存和交换空间都耗尽,且内核无法再为进程分配内存时,Linux 内核的 OOM Killer 会启动,选择并终止一个或多个进程来释放内存。

选择策略:

  • oom_score 计算:内核为每个进程计算一个“OOM 分数”(oom_score),该分数综合考虑了进程占用的内存量、运行时间、优先级(nice 值)以及其它因素。
    • 占用内存越多的进程,oom_score 越高。
    • 不重要的用户进程(例如那些没有被设置保护的应用)可能会获得较高的 oom_score。
  • oom_score_adj 参数:进程可以通过设置 oom_score_adj(范围从 -1000 到 +1000)来影响被 OOM Killer 选择的可能性。值为 -1000 表示非常重要的进程(如系统关键进程),几乎不会被杀;值为正的进程更容易被杀。
  • 实际行为:内核会选择 oom_score 最高(即最“容易被牺牲”的)进程来杀掉。
    • 如果某个进程占用了大量内存且其 oom_score_adj 没有降低(或显著提高安全系数),这个进程更容易成为 OOM Killer 的目标。
    • 系统核心服务或通过设置较低 oom_score_adj 的进程一般会被保护,不容易被杀掉。

7、问tcp可靠性如何保证?

三次握手四次挥手

这里不展开讲

序列号与确认应答(ACK)

序列号:每个 TCP 报文段都会分配一个序列号,表示数据流中该段数据的起始字节位置。接收方可以根据序列号判断数据是否丢失或乱序。

确认应答:接收方在收到数据后,会发送 ACK(确认报文),告知发送方下一个期望接收的字节序号。如果发送方在一定时间内没有收到 ACK,则会认为数据丢失,从而触发重传。

重传机制与超时控制

超时重传:TCP 为每个报文段设置超时计时器,如果在超时内未收到对应的 ACK,发送方会重传该报文段。

快速重传:当发送方连续收到多个相同的 ACK(表明后续数据丢失)时,会立即重传缺失的数据,而不必等待超时发生,从而提高传输效率。

校验和机制

每个 TCP 报文段都包含一个校验和字段,用于检测数据在传输过程中是否发生了损坏。接收方计算接收到数据的校验和,并与报文中的校验和进行比较,确保数据完整性。如果校验失败,则会丢弃该段数据,并期望发送方通过重传机制恢复数据。

流量控制(滑动窗口机制)

TCP 采用滑动窗口机制来实现流量控制,确保发送方不会发送超过接收方缓冲区处理能力的数据。接收方在 ACK 中告知当前可接受的窗口大小,发送方根据该值调整发送速率,防止缓冲区溢出。

拥塞控制

慢启动(Slow Start):初始时发送窗口较小,通过指数增长快速探测网络容量。

拥塞避免(Congestion Avoidance):当窗口增长到一定阈值后,采用线性增长,避免过快加大发送速率而引起拥塞。

快速重传与快速恢复(Fast Retransmit & Fast Recovery):当检测到包丢失时(通过重复 ACK),快速重传丢失数据,并在一定程度上恢复窗口大小,而不是直接重置为初始值。

顺序传输与乱序处理

顺序传输:TCP 保证数据按照发送顺序到达应用层。如果数据乱序到达,接收端会在内部缓存后重新排序,确保应用程序收到的数据是有序的。

缓冲与重排序:利用接收窗口,TCP 会将乱序到达的数据暂存起来,直到所有前面的数据都到达后再交付给应用程序。

8、tcp场景,发送端发seq=11,12,13,接收端只收到11,13,接收端回复什么信息。追问超时重传,快速重传。追问tcp滑动窗口的概念,滑动窗口的初始大小通过什么参数调整?

TCP 数据丢失场景下的 ACK 回复

假设发送端依次发送三个数据包,其序列号分别为 11、12、13,而接收端只收到 seq=11 和 seq=13,这意味着 seq=12 丢失了。TCP 是累积确认(cumulative acknowledgment)的协议,接收端只确认连续正确接收到的数据。

接收端的行为:当接收端收到 seq=11 后,下一步它期望收到 seq=12;如果收到 seq=13(但 seq=12 丢失),接收端发现数据出现空洞,仍然会回复 ACK=12(表示“我还在等待从 12 开始的数据”)。 此外,收到乱序数据时,接收端会发送重复 ACK(Duplicate ACK),也就是多次回复 ACK=12,以通知发送端缺失的部分。

超时重传与快速重传

超时重传(Timeout Retransmission):

  • 发送端为每个发送出去的数据段启动一个定时器,如果在预定时间内没有收到相应的 ACK,发送端认为该段丢失,便重新发送该数据段。
  • 超时重传的时间间隔是动态计算的,通常依赖于往返时间(RTT)的估算以及 RTT 的偏差,以适应网络延迟的变化。

快速重传(Fast Retransmission):

  • 当发送端连续收到三个或更多相同的 ACK(即重复 ACK),说明接收端已经收到后续的数据包,但缺失的数据包(此处为 seq=12)一直没有到达。
  • 发送端就会认为丢失的数据段很可能已经丢失,于是不等待超时,立即进行重传,这样可以更快地恢复数据连续性,提高传输效率。
  • 快速重传的触发条件通常是“三个重复 ACK”,这是 TCP 协议的一条经验规则。

TCP 滑动窗口概念与初始大小调整

TCP 滑动窗口的概念:

  • 流量控制: 滑动窗口机制用于控制发送方一次可以发送多少字节的数据而无需等待确认。它帮助平衡发送速率和接收方处理能力,避免接收端缓冲区溢出。
  • 拥塞控制: 同时,滑动窗口大小受到拥塞控制(congestion control)的影响,表现为拥塞窗口(cwnd)。发送方实际可以发送的数据量受两部分限制:
    • 接收窗口(rwnd): 由接收端根据自身缓冲区大小通告给发送方。
    • 拥塞窗口(cwnd): 由发送端根据网络拥塞状况调整。 发送方可发送的数据量为 min(rwnd, cwnd)

初始滑动窗口的调整参数:

  • 初始拥塞窗口(Initial Congestion Window,初始 cwnd):
    • 通常由操作系统 TCP/IP 栈决定,历史上有过 2~4 个 MSS(最大报文段长度)的设置,而现代系统多采用 10 个 MSS(参考 RFC 6928)作为默认值。
    • 在 Linux 系统中,这个初始值可以通过内核参数进行调整,例如有些发行版支持修改 tcp_initcwnd 参数(具体名称和可调范围可能因内核版本而异)。
  • 接收窗口(rwnd):
    • 由接收端根据自己的接收缓冲区大小确定,应用程序可以通过 socket 接口设置缓冲区大小,相关参数如 SO_RCVBUF(接收缓冲区)和 SO_SNDBUF(发送缓冲区)。

因此,初始滑动窗口大小主要受以下两个因素影响:

  • 发送方初始拥塞窗口(cwnd): 可通过内核或系统参数进行调整(例如 Linux 中默认 10 MSS)。
  • 接收方通告的接收窗口(rwnd): 受接收缓冲区大小影响,通过 socket 设置调整。

9、发http请求的过程,追问tls握手过程,追问一个会话如何记录用户的状态信息(token),追问为什么用了https还需要session,token这些东西?

HTTP 请求的发起过程

  1. DNS 解析:客户端(浏览器或其他 HTTP 客户端)首先通过 DNS 解析将 URL 中的域名转换成服务器的 IP 地址。
  2. 建立 TCP 连接:
    • 客户端与服务器之间建立 TCP 连接。这通常涉及三次握手过程:
      1. 客户端发送 SYN 包;
      2. 服务器响应 SYN+ACK;
      3. 客户端再发送 ACK 确认,连接建立。
  3. 发送 HTTP 请求:
    • TCP 连接建立后,客户端构造 HTTP 请求报文,包含请求方法(GET、POST 等)、请求 URI、HTTP 版本、请求头(Header)和可选的请求体(Body)。
    • 请求报文被发送到服务器。
  4. 服务器处理请求:
    • 服务器接收到 HTTP 请求后,根据 URI、请求头、请求体等信息进行路由和业务处理。
    • 生成响应报文,包含状态行(如 HTTP/1.1 200 OK)、响应头和响应体。
  5. 返回 HTTP 响应:
    • 服务器通过 TCP 连接将响应数据发送回客户端。
    • 客户端接收到响应后,解析响应头和响应体,进行后续处理(如呈现页面、解析 JSON 数据等)。
  6. 关闭连接或保持连接:
    • 根据 HTTP 协议版本(如 HTTP/1.1 默认采用持久连接 Keep-Alive)或应用场景,TCP 连接可能会被复用或关闭。

TLS 握手过程

当使用 HTTPS 时,在建立 TCP 连接之后,还需要进行 TLS 握手,确保通信安全。TLS 握手大致包括以下步骤(以 TLS 1.2/1.3 为例,细节略有不同):

  1. ClientHello(客户端问候):客户端发送 ClientHello 消息,其中包含支持的 TLS 协议版本、加密套件列表、压缩方法、随机数(client_random)等信息。
  2. ServerHello(服务器问候):服务器回复 ServerHello 消息,确定使用的 TLS 版本、加密套件、压缩方法,并发送服务器的随机数(server_random)。
  3. 服务器证书和密钥交换:
    • 服务器发送其数字证书,证明其身份(证书由受信任的 CA 签名)。
    • 根据加密套件,服务器可能还发送 ServerKeyExchange 消息,提供必要的密钥交换参数(如 DH 参数)。
    • 若需要服务器请求客户端认证,则会发送 CertificateRequest。
  4. 客户端证书(可选)和密钥交换:
    • 客户端验证服务器证书有效后,发送 ClientKeyExchange 消息,其内容用于双方计算共享密钥。
    • 如果服务器请求了客户端证书,客户端还会发送自己的证书及证明其私钥的消息。
  5. 双方生成对称密钥:基于 client_random、server_random 和密钥交换信息,双方计算出对称加密所需的会话密钥。
  6. Finished 消息:
    • 双方分别发送 Finished 消息,用于验证握手过程是否完整、未被篡改。
    • 握手成功后,后续通信都采用协商好的对称密钥加密。

会话如何记录用户的状态信息(Token)

HTTP 协议本身是无状态的,即每个请求都是独立的,不保留前后关联。为了在用户会话中记录状态信息,常见的做法包括:

  1. Session(会话):
    • 服务器在用户首次登录或访问时生成一个唯一的 Session ID,并将其保存在服务器端的会话存储中(如内存、数据库或分布式缓存)。
    • Session ID 通常通过 Cookie(如 Set-Cookie: JSESSIONID=...)传回客户端,后续请求中客户端自动带上该 Cookie,服务器根据 Session ID 关联用户状态信息。
  2. Token 机制(如 JWT):
    • 另一种方式是在用户认证后,由服务器生成一个令牌(Token),常见的如 JSON Web Token(JWT),其中包含用户身份信息、权限、过期时间等,经过数字签名确保完整性。
    • 客户端将该 Token 保存(例如 localStorage 或 Cookie 中),每次请求时在 HTTP Header 中带上,如 Authorization: Bearer <token>。服务器验证 Token 后即可获得用户状态。
  3. 其他方式:使用 OAuth 等协议,通过访问令牌来维持用户会话状态。

为什么用了 HTTPS 还需要 Session/Token?

虽然 HTTPS 提供了传输层的加密和数据完整性保障,但它只负责在客户端和服务器之间建立安全的通信通道。

使用 Session 或 Token 记录用户状态有以下原因:

  1. HTTP 本身无状态:HTTPS 只保证数据传输过程中的安全,无法解决 HTTP 协议无状态的问题。无论是 HTTP 还是 HTTPS,每个请求在协议层面都是独立的,服务器需要额外机制来识别同一个用户的连续请求。
  2. 用户认证与授权:会话机制(Session/Token)用于记录用户的登录状态、权限信息以及业务相关的状态数据。这样,服务器可以根据请求中的 Session ID 或 Token 确定用户身份,执行相应的业务逻辑。
  3. 防止重放和 CSRF:Token 机制(如 JWT)可以内嵌过期时间、签名等信息,帮助防止重放攻击,同时结合其它防护机制可以减少 CSRF 等安全问题。HTTPS 保护的是传输过程,而 Token 则用于业务层面的认证和授权。
  4. 分布式环境的状态管理:在微服务架构中,不同服务可能需要共享用户状态信息,Token(特别是自包含的 JWT)能够方便地在各服务间传递并验证用户身份,HTTPS 无法提供这类应用层数据管理功能。