博客记录-day114-Java并发面试题+项目面试题+计网传输层面试题

154 阅读29分钟

一、语雀-Java并发面试题

1、synchronized是怎么实现的?

✅synchronized是怎么实现的?

synchronized是Java中实现线程同步的核心机制,其底层基于Monitor模型锁优化技术,具体实现如下:

  1. Monitor与对象锁
    每个Java对象(或类)关联一个Monitor(监视器),synchronized通过获取对象的锁(lock())进入Monitor,释放锁(unlock())退出。锁信息存储在对象头的锁标记字段中,用于标记当前锁的状态(如无锁、偏向锁、轻量级锁或重量级锁)。

  2. 锁的升级机制
    偏向锁:首次访问时,锁偏向当前线程,减少无竞争时的开销。
    轻量级锁:若发生竞争,偏向锁升级为轻量级锁,通过CAS(Compare-And-Swap)操作尝试获取锁,避免线程阻塞。
    重量级锁:当CAS频繁失败时,锁升级为操作系统级别的
    互斥量(Mutex)
    ,阻塞竞争线程并调度至等待队列,由操作系统管理线程阻塞与唤醒。

  3. 原子性与可见性保障
    原子性:锁的获取和释放确保同步代码块或方法的互斥执行
    可见性:锁的释放会强制刷新线程本地缓存到主内存,新获取锁的线程能看到最新数据。

  4. 静态锁的特殊性
    static synchronized方法锁定的是类的Class对象,确保所有实例共享同一把类锁,而实例方法锁定对象实例,不同实例的锁相互独立。

总结synchronized通过JVM内置的锁机制与OS原生锁结合,动态选择锁策略以平衡性能与正确性,既避免了过度阻塞,又保证了多线程环境下共享资源的安全访问。


synchronized 是 Java 中最基础的线程同步机制,其实现涉及 ​JVM 字节码指令对象内存结构 和 ​操作系统内核 的多层协作。以下是其核心实现原理的分层解析:


1.1 语法到字节码的转换

当编译器遇到 synchronized 代码块时,会自动插入以下字节码指令:

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++; // monitorenter 和 monitorexit 自动插入
    }
}

对应的字节码片段:

// 方法入口
aload_0          // 加载当前对象(this)
monitorenter     // 获取锁
iload_0         // 加载 count
iconst_1        // 常量 1
iadd            // count + 1
istore_0        // 存储回 count
monitorexit     // 释放锁
return           // 返回
  • monitorenter:尝试获取对象的锁。
  • monitorexit:释放对象的锁。
  • 如果获取锁失败,线程会阻塞直到持有锁的线程释放它。

1.2 JVM 对象的内存布局

每个 Java 对象在内存中包含一个 ​对象头(Object Header)​,其中包含:

  • Mark Word​(32/64 位):存储锁信息(如锁状态、偏向线程 ID、CAS 竞争计数等)。
  • 类型指针(Type Pointer)​:指向类元数据。

Mark Word 的锁状态演变(简化版):

状态描述相关场景
UNLOCKED无锁状态初始状态
BIASING偏向锁模式单一线程反复获取锁
LIGHTLY_LOCKED轻量级锁模式(自旋锁)短暂竞争
HEAVY_LOCKED重量级锁模式(操作系统互斥锁)长时间竞争
LOCKEDJVM 内部锁(如 synchronized 方法锁)static 方法同步

1.3 锁的获取与释放流程

1. ​获取锁(monitorenter)​
  • 步骤

    1. 检查 Mark Word 的锁状态:

      • UNLOCKED:直接标记为当前线程持有(偏向锁)。
      • BIASED:若当前线程是偏向线程,直接获取;否则升级为轻量级锁。
      • LIGHTLY_LOCKED:自旋等待(轻量级锁)。
      • HEAVY_LOCKED:调用操作系统 pthread_mutex_lock,阻塞当前线程。
    2. 更新 Mark Word 并返回成功。

2. ​释放锁(monitorexit)​
  • 步骤

    1. 清除 Mark Word 中的线程 ID 和锁标记。
    2. 唤醒等待的线程(通过操作系统信号或 JVM 内部机制)。

1.4 操作系统层面的实现

当锁升级为 重量级锁 时,JVM 会通过以下方式与操作系统交互:

  1. 用户态到内核态切换:线程因等待锁而阻塞,触发上下文切换。
  2. 进程间通信(IPC)​:操作系统维护锁的等待队列,使用条件变量或信号量通知等待线程。
  3. 原子操作:如 compare-and-swap(CAS)用于无锁编程中的锁竞争检测。

1.5 对比 synchronized 和 ReentrantLock

特性synchronizedReentrantLock
实现方式JVM 内置,基于对象头和操作系统Java 层面实现,可扩展性更强
锁类型只支持非公平锁支持公平锁和非公平锁
中断响应不支持中断支持 lockInterruptibly()
性能在无竞争时更快在高竞争时可能更灵活
代码复杂度简单,易用需手动管理锁的获取/释放

1.6 总结

synchronized 的实现是 ​JVM + 操作系统 协作的经典案例:

  • JVM 层:通过对象头的 Mark Word 管理锁状态,优化锁的获取/释放路径。
  • 操作系统层:提供底层阻塞机制,确保锁的强一致性。
  • 开发者层:需合理设计锁的粒度和使用场景,避免性能瓶颈和死锁。

image.png

2、监视锁Monitor

✅synchronized是怎么实现的? image.png

image.png

3、synchronized锁的是什么?

✅synchronized锁的是什么?

image.png

image.png

4、synchronized的锁升级过程是怎样的?

✅synchronized的锁升级过程是怎样的?

image.png

image.png

特性轻量级锁偏向锁
优点- 减少CAS失败后的线程阻塞开销。 - 适用于中等竞争场景。- 无竞争时开销极低(几乎零成本)。 - 适合单线程高频访问场景。
缺点- CAS操作本身有竞争风险。 - 自旋会消耗CPU资源。- 有竞争时需撤销偏向锁,带来额外开销。 - 适合极端单线程场景,通用性较低。

4.1 JDK15中废除了偏向锁

image.png

image.png

image.png

5、synchronized的锁优化是怎样的?

✅synchronized的锁优化是怎样的?

5.1 自旋锁

image.png

image.png

5.2 锁消除

image.png

5.3 锁粗化

image.png

image.png

6、int a = 1 是原子性操作吗,User a = new User(); 是原子性操作吗?

✅int a = 1 是原子性操作吗

image.png

image.png

image.png

7、volatile是如何保证可见性和有序性的?

image.png

image.png

8、volatile在单例模式的作用

image.png

image.png

image.png

image.png

image.png

正确代码:

public class Singleton {
    private static volatile Singleton instance; // 必须用 volatile

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查(非同步)
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查(同步)
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

二、大营销面试题

问题1:加分段锁的目的是为了什么?

答:核心目标是为了防止低概率下集群、主从故障导致的超卖,做一个兜底逻辑。

在星选抽奖系统中,​分段锁的核心意义在于平衡高并发性能与数据一致性,其具体作用可从以下角度分析:


1. 解决高并发下的库存竞争问题
  • 背景:抽奖活动的库存扣减是典型的临界资源竞争场景,大量用户同时请求可能导致超卖。

  • 分段锁设计

    • 将总库存划分为多个逻辑段(如按用户ID哈希分片、按奖品ID分片等),每个段分配独立的锁。
    • 示例:若总库存为10,000,分为100个段,每个段维护100个库存。用户请求时,根据其ID或抽奖ID映射到特定段,仅对该段的锁进行操作。
  • 效果

    • 降低锁粒度:避免所有线程竞争同一把锁,减少锁冲突,提升并发吞吐量。
    • 局部竞争:即使某个段因热点数据竞争激烈,其他段仍可并行处理,避免全局阻塞。

2. 防止超卖与保证最终一致性
  • 原子性操作

    • 使用Redis的DECR命令实现库存扣减的原子性,避免并发导致的数值错误。
    • 问题:若DECR后库存为负数(如网络抖动导致后续数据库更新失败),需通过锁兜底。
  • 分段锁兜底

    • DECR操作后,通过SETNX(Set if Not eXists)尝试获取分布式锁(如Redisson的分布式锁)。
  • 效果

    • 双重保障DECR保证瞬时一致性,分布式锁确保最终一致性。
    • 避免雪崩:锁竞争失败时回滚Redis操作,防止无效库存占用。

3. 支持分库分表场景
  • 背景:当库存数据量极大时,需通过分库分表提升存储能力。

  • 分段锁适配分片

    • 按分片键(如用户ID哈希值)将库存分配到不同数据库分片。
    • 分段锁设计:每个分片独立维护锁,避免跨分片锁竞争。
  • 效果

    • 线性扩展:分片数量增加时,锁竞争压力分散,系统可横向扩展。
    • 隔离性:分片内操作互不影响,提升局部性能。

4. 热点数据优化
  • 问题:某些热门奖品(如iPhone 15 Pro)可能被频繁抢购,导致单点锁竞争。

  • 分段锁优化

    • 对热门奖品按用户ID或地域分片,分散锁竞争到多个子库存。
    • 示例:将“iPhone 15 Pro”库存分为10个子库存,每个子库存对应不同用户群体。
  • 效果

    • 热点削峰:避免单一锁成为性能瓶颈。
    • 公平性:不同用户群体公平竞争子库存,减少资源争抢。

5. 容错与补偿机制
  • 分段锁与补偿结合

    • 若某分段锁因异常未释放(如服务崩溃),通过定时任务检测并清理残留锁。
    • 实现:利用Redis的过期时间(TTL)或看门狗机制自动续期。
  • 效果

    • 高可用:避免锁意外持有导致的死锁或资源浪费。
    • 数据一致性:通过补偿机制(如重试、日志回放)修复异常状态。

问题2:如果不需要补库存,分段锁是不是可以不用加?

答:即使不考虑手动补库存的情况,如果集群、主从故障,不加分段锁还是会可能超卖的,所以这里的分段锁不单单是为了补库存的场景而设计。

问题3:那直接 incr 不可以吗?

答:在 redis 集群模式下,incr 请求操作可能发生网络抖动超时返回。这个时候 incr 有可能成功,也有可能失败。可能是请求超时,也可能是请求完的应答超时。那么 incr 的值可能就不准。【实际使用中10万次,可能会有10万零1和不足10万】,那么为了这样一个临界状态的可靠性,所以添加 setNx 加锁只有成功和失败。 还有一种情况是主从切换的时候,如果主节点的 incr 还没同步到从节点,主节点挂了,丢失了部分未同步的数据,incr 的值从 8 变成 6,如果没有加锁就可能超卖,属于极端情况下的一种兜底策略,有 setNX 锁拦截后,会更加可靠。

1. 防止超卖与最终一致性
  • DECR 的瞬时一致性

    • Redis 的 DECR 操作能立即返回扣减后的结果,若结果为负数,则直接拒绝请求(避免超卖)。
  • SETNX 锁的兜底作用

    • 在极端情况下(如 Redis 与数据库间网络抖动导致异步更新失败),SETNX 锁可防止部分请求绕过数据库更新。

    • 示例

      • 若 DECR 成功但数据库更新失败,SETNX 锁会阻塞后续请求,直到数据库操作完成或锁超时。
  • 最终一致性

    • 通过异步队列(如 RabbitMQ)补偿失败请求,确保库存最终与数据库一致。

2. 与分库分表的兼容性
  • 分片键的天然适配

    • 若库存数据按分片键(如用户 ID)存储到不同数据库分片,DECR 可直接作用于分片键对应的库存键(如 stock_{userId})。

    • 对比 INCR 的问题

      • 若使用 INCR 模拟扣减,需在分片逻辑中额外处理负数映射(如先查询分片库存,再执行 INCR),这会引入非原子性操作,导致超卖风险。

5. 性能与可扩展性
  • DECR 的性能优势

    • Redis 单线程模型下,DECR 的性能损耗极低(仅需 O(1) 时间复杂度)。

    • INCR 的额外开销

      • 若通过 INCRBY key -1 实现扣减,Redis 需额外解析负数运算,可能略微增加 CPU 开销。

6. 热点数据优化
  • 热点削峰

    • 对热门奖品(如 iPhone 15 Pro)按用户 ID 或地域分片,分散锁竞争到多个子库存。
    • 示例:将“iPhone 15 Pro”库存分为 100 个子库存,每个子库存对应不同用户群体。
  • 公平性

    • 不同用户群体公平竞争子库存,避免单一热点导致全局阻塞。

7. 总结:为何不直接使用 INCR
场景DECR + 分段锁INCR 模拟扣减
原子性天然原子性(直接扣减)需额外逻辑(如 INCRBY -1 + 判断)
可读性代码语义清晰(直接扣减)需通过负数间接模拟,易产生误解
性能Redis 单线程高效处理需额外运算和判断,性能略低
分库分表兼容性直接适配分片键(如 stock_{userId}需复杂映射逻辑,难以分片

问题4:那如果考虑集群故障,机器挂掉的情况,setNX 不也会报错吗?

答:setNX 如果失败了,就直接报错返回 "活动库存不足" 即可,也就是可能会导致少卖,但是不会导致超卖。并且 incr 和 setNX 的 key 不同,incr 的 key 和滑块锁的 key 大概率不在同一节点上,从而双重保证,如果 senNx 的 key 和库存的 key 节点都 down 机了,那这里确实有超卖的可能,不过这个概率可以低到忽略不计。

1. 原生 SETNX 的局限性
  1. 缺乏自动续期机制

    • 如果锁的持有者因长时间任务未完成导致锁过期,其他客户端可能抢占锁,引发并发问题。
    • 若锁过期时间设置过短,可能在任务未完成时锁被释放,同样导致竞争。
  2. 集群脑裂问题

    • Redis 集群发生分裂时,可能导致多个客户端同时认为获取了锁(脑裂场景)。
  3. 单点故障敏感

    • 如果 Redis 主节点宕机且未启用持久化,锁数据可能丢失,导致锁状态不一致。

2. 解决方案:增强锁的容错性
(1) 设置锁的过期时间

通过 EXPIRE 命令为锁设置超时时间,避免锁永久持有。

  • 优点:确保锁在超时后自动释放。
  • 缺点:若任务在超时前未完成,可能被其他客户端抢占。
(2) 使用 RedLock 算法

RedLock 是 Redis 官方推荐的分布式锁算法,通过以下方式增强容错性:

  1. 多节点写入:在 N 个 Redis 主节点上尝试获取锁(通常为奇数,如 5 个节点)。
  2. 多数节点成功:只有当超过半数节点成功写入且总耗时小于锁有效期时,认为锁获取成功。
  3. 自动续期:在锁有效期内定期刷新过期时间,防止任务未完成时锁过期。

优点:容忍部分节点故障,避免单点问题。

(3) 异常重试与超时机制
  • 客户端重试:若 SETNX 失败(如连接超时),客户端应进行有限次数的重试。
  • 超时控制:为 SETNX 操作设置超时时间,避免无限阻塞。
(4) 使用 Redis 集群模式
  • 高可用架构:启用 Redis Cluster 或 Sentinel 模式,自动切换故障节点。
  • 持久化配置:开启 AOF 或 RDB 持久化,确保锁数据在节点重启后恢复。
3. SETNX 失败直接返回“库存不足”
(1) 是否会导致少卖?
  • 正常场景
    如果 SETNX 失败,说明当前锁已被其他线程持有,说明已有其他请求正在处理库存扣减。此时直接返回“库存不足”是合理的,因为库存确实已被锁定,不会被多个线程同时扣减。
    结论:不会导致少卖,但可能因锁竞争导致部分请求被拒绝(属于正常限流行为)。
  • 异常场景
    如果 SETNX 失败是由于锁的 ​过期释放​(如持有锁的服务崩溃),此时可能有其他线程同时进入临界区,导致库存被多次扣减(超卖)。
    结论:极端情况下可能引发超卖,但概率较低(取决于锁的过期时间和业务执行时间)。
(2) 是否会导致少卖?
  • 关键点
    SETNX 的作用是确保同一时间只有一个线程执行库存扣减。如果 SETNX 失败,说明其他线程已持有锁,当前请求应被阻塞或重试,而非直接返回“库存不足”。
    直接返回“库存不足”会跳过业务逻辑,导致真实库存未被扣减,但请求被错误拒绝(少卖)。

问题5:decr 和 incr 两种扣减方式有什么不同?

答:二种方式都可以,decr 适合固定库存场景,和 0 对比,incr 适合可以补库存的场景,和库存总量对比。

在 Redis 中,DECR 和 INCR 都是原子性操作,但它们的核心设计目标和适用场景有显著差异。以下是两者的详细对比:


1. 核心功能差异
命令目标操作数学逻辑典型场景
DECR递减​(减法)key = key - 1库存扣减、计数器减少
INCR递增​(加法)key = key + 1计数器增长、自增 ID 生成

2. DECR原子性保障
  • DECR
    直接返回扣减后的结果,若结果为负数(如库存不足),可直接拒绝请求。

    if (remainingStock < 0) {
        throw new SellOutException(); // 天然防止超卖
    }
    
  • INCR
    需手动判断是否超卖,存在非原子性操作风险

    Long newStock = redisTemplate.opsForValue().increment("product:stock", -1);
    if (newStock < 0) {
        // 需额外恢复库存(非原子性操作,可能引发竞争)
        redisTemplate.opsForValue().increment("product:stock");
        throw new SellOutException();
    }
    
3. 性能优化
  • DECR
    Redis 内部对减法操作进行了优化,直接返回结果,无额外计算开销。
  • INCR
    需对负数进行转换(如 INCRBY key -1),可能略微增加 CPU 负载。
4. 分布式锁的配合使用
  • DECR
    适合与分段锁(如按用户 ID 分片)结合,直接操作分片后的库存键。

    String shardKey = "stock_" + hash(userId);
    Long remainingStock = redisTemplate.opsForValue().decrement(shardKey);
    
  • INCR
    需额外处理分片逻辑(如先查询分片库存,再执行 INCR),引入非原子性步骤。


5. 最佳实践总结
场景推荐命令原因
库存扣减DECR原子性、语义清晰、性能最优
计数器增长(如点赞数)INCR天然适合递增场景
分布式锁配合分片DECR分片逻辑简单,避免热点竞争

问题6:那为什么要分段,直接对一个 key senNX 不可以吗?

答:分段锁的话,setNX 因为是非独占锁,所以 key 不存在释放。setNX 的 key 的过期时间可以优化为活动的有效期时间为结束。而独占锁,其实你永远也不好把握释放时间,因为秒杀都是瞬态的,释放的晚了活动用户都走了,释放的早了,流程可能还没处理完

在库存扣减场景中,​分段锁(Sharded Lock)的核心目的是通过降低锁的粒度,解决高并发下的性能瓶颈和单点竞争问题。直接使用单个 SETNX 锁虽然能保证互斥性,但在大规模并发场景下存在显著缺陷。以下是具体分析:


1. 单个 SETNX 锁的局限性
(1) 锁竞争激烈,性能瓶颈
  • 所有请求竞争同一把锁
    若所有库存操作共享同一把锁(如 SETNX stock_lock),当并发量极高时,线程会因争夺锁而阻塞,形成“锁竞争风暴”,导致 QPS 急剧下降。

    • 问题:1000 个并发请求需串行执行,性能与单线程无异。
(2) 热点数据放大问题
  • 热点商品雪崩效应
    若某商品库存被频繁抢购(如秒杀场景),所有请求都会集中在该商品的锁上,导致锁竞争白热化,可能引发超卖系统雪崩

2. 分段锁的解决方案
(1) 按业务维度分片,降低锁粒度
  • 核心思想
    将库存按业务维度分片(如按用户 ID、商品 ID、地域等),每个分片分配独立的锁。

  • 效果
    用户 A 和用户 B 的请求分别竞争不同的锁,实现并行处理。

(2) 避免热点数据单点竞争
  • 均匀分布请求
    通过哈希函数(如 hash(userId))将请求均匀分配到不同分片,避免热点数据集中在单一锁上。

  • 示例

    • 用户 ID 为 user_1 和 user_2 的请求分别分配到分片 0 和分片 1。
    • 两个分片锁独立运作,互不干扰。
(3) 提升系统扩展性
  • 线性扩容
    若分片数量不足,可动态增加分片(如从 10 个分片扩展到 100 个),提升系统吞吐量。

3. 分段锁 vs 单锁的关键对比
场景单个 SETNX 锁分段锁
锁竞争粒度全局竞争(所有请求竞争同一把锁)局部竞争(按分片竞争,互不干扰)
性能上限受限于单锁的吞吐量(QPS 低)线性扩展,QPS 随分片数量增加
热点场景表现热点数据导致锁竞争爆炸(如秒杀)热点数据分散到多个分片,避免单点瓶颈
实现复杂度简单需设计分片策略和路由逻辑
适用场景低并发、非热点场景高并发、热点场景

4. 分段锁的实现注意事项
(1) 分片策略设计
  • 均匀性
    分片键需通过哈希函数(如 MurmurHash)均匀分布,避免数据倾斜。
  • 动态调整
    支持分片数量动态调整(如通过一致性哈希),适应业务增长。
(2) 锁的粒度与超时
  • 锁过期时间
    为分片锁设置合理的过期时间(如 30 秒),避免死锁。
  • 自动续期
    使用 Redisson 等工具实现锁的自动续期(Watchdog 机制)。
(3) 与 DECR 的配合
  • 原子性扣减
    分段锁需与 DECR 原子操作结合,确保扣减逻辑的完整性。

  • 示例

    // 分段锁 + DECR 原子操作
    String shardKey = "stock_shard_" + hash(userId);
    try (RLock lock = redisson.getLock(shardKey)) {
        lock.lock();
        Long stock = redisTemplate.opsForValue().decrement("stock_key_" + hash(userId));
        if (stock < 0) {
            throw new SellOutException();
        }
    }
    

5. 总结:分段锁的价值
目标分段锁的实现手段技术收益
降低锁竞争按业务维度分片提升并发吞吐量
防止热点瓶颈哈希分片,均匀分布请求避免单点过载
支持扩展性动态分片数量调整线性扩展能力
容错性锁自动过期 + 补偿机制提升系统鲁棒性

问题7:incr 扣减模式下,如果同一个用户并发进来,那么缓存中的库存就会+并发数,但实际这个用户只会领取到一条数据,所以就要恢复并发数-1的库存数量。这样种情况并不是 redis 不稳定导致的,而是同一用户并发导致的,应该及时去恢复数据啊,不然的话缓存中的库存直接一下就给一个用户并发干没了,然后再去恢复,效率太低了吧?

答:不需要恢复,还是回到上面,核心是保证不超卖,关于库存恢复,一般这类抽奖都是瞬态的,且 redis 集群非常稳定。所以很少有需要恢复库存,如果需要恢复库存,那么是把失败的秒杀 incr 对应的值的 key,加入到待消费队列中。等整体库存消耗后,开始消耗队列库存,等补偿恢复,活动已经基本过去了。所以超卖,快速结束是最好的。这个一般是基于运营策略配置何种方式恢复库存,可以失败的专门扫描到恢复库存列表用于消耗,也可以不恢复(因为失败概率很低,也允许不超买即可)。

1. 库存恢复的必要性
(1) 为什么短暂允许超卖?
  • 瞬态活动特性:抽奖活动通常时间短、流量集中,库存消耗速度极快。即使出现少量超卖(如网络抖动导致重复扣减),实际物理库存(如实物奖品)仍可覆盖,用户感知影响较小。
  • 最终一致性:通过异步补偿机制(如定时任务、消息队列),可在活动结束后逐步修复库存差异,无需实时强一致。
(2) 什么情况下需要恢复库存?
  • 失败请求的兜底:如用户未收到中奖通知、支付超时等异常场景,需将未实际消耗的库存回补。
  • 防止单点故障:若Redis集群短暂不可用,导致部分扣减操作未持久化,需通过补偿机制修复。
2. 库存恢复策略
  • 基于队列的异步补偿

    1. 记录操作日志:每次DECR操作时,将{userId, productId, amount}写入消息队列(如Kafka)。

    2. 活动后扫描队列

      • 未支付订单:通过INCR恢复库存。
      • 已支付订单:更新数据库库存,避免超卖。
    3. 技术选型

      • 消息队列:优先选支持延迟消费的中间件(如RabbitMQ的TTL+DLX)。
      • 数据库:使用支持乐观锁的存储(如MySQL的version字段)。
  • 基于时间窗口的快速过期

    • 主动清理:活动结束时触发DEL stock_key,避免残留数据。
  • 运营策略驱动的恢复规则

    策略适用场景实现方式
    严格模式高价值奖品(如iPhone 15 Pro)支付成功后扣减库存,失败则补偿
    宽松模式普通奖品仅补偿超时未支付的请求
    无补偿模式库存充足、允许小幅超卖直接丢弃失败请求,依赖最终一致
3. 技术实现关键点
  • 原子性保障
    使用Lua脚本封装扣减库存与用户抽奖次数更新,避免竞态条件:

    -- Lua脚本:原子性扣减库存 + 记录用户抽奖
    local key = KEYS[1]
    local user = ARGV[1]
    local amount = tonumber(ARGV[2])
    
    redis.call('DECRBY', key, amount)
    redis.call('HINCRBY', 'user_draws:' .. user, key, amount)
    
  • 分片策略
    按用户ID哈希分片,避免热点竞争:

    int shardCount = 100;
    String shardKey = "stock_" + (Math.abs(userId.hashCode()) % shardCount);
    
  • 监控与告警

    • 监控指标:队列积压量、补偿成功率、Redis内存使用率。
    • 告警规则:队列积压超过10分钟、补偿失败率>1%时触发告警。

4. 总结:库存恢复的设计哲学
场景策略技术要点
活动中短暂超卖允许误差,快速结束活动基于Redis的原子操作,无需实时补偿
活动后遗留库存异步队列补偿消息队列 + 批量处理
高价值奖品严格补偿,确保零误差分布式事务 + 状态机
库存充足、允许误差无补偿,依赖最终一致性设置Key过期时间,简化逻辑

三、小林-计网传输层面试题

1、假设客户端重传了 SYN 报文,服务端这边又收到重复的 SYN 报文怎么办?

会继续发送第二次握手报文。

2、第一次握手,客户端发送SYN报后,服务端回复ACK报,那这个过程中服务端内部做了哪些工作?

服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。

image-20240725231318748

不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,内核会直接丢弃,或返回 RST 包。

3、大量SYN包发送给服务端服务端会发生什么事情?

有可能会导致TCP 半连接队列打满,这样当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃,导致客户端无法和服务端建立连接。

避免 SYN 攻击方式,可以有以下四种方法:

  • 调大 netdev_max_backlog;
  • 增大 TCP 半连接队列;
  • 开启 tcp_syncookies;
  • 减少 SYN+ACK 重传次数

方式一:调大 netdev_max_backlog

当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数,默认值是 1000,我们要适当调大该参数的值,比如设置为 10000:

net.core.netdev_max_backlog = 10000

方式二:增大 TCP 半连接队列

增大 TCP 半连接队列,要同时增大下面这三个参数:

  • 增大 net.ipv4.tcp_max_syn_backlog
  • 增大 listen() 函数中的 backlog
  • 增大 net.core.somaxconn

方式三:开启 net.ipv4.tcp_syncookies

开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接,相当于绕过了 SYN 半连接来建立连接。

image-20240725231252689

具体过程:

  • 当 「 SYN 队列」满之后,后续服务端收到 SYN 包,不会丢弃,而是根据算法,计算出一个 cookie 值;
  • 将 cookie 值放到第二次握手报文的「序列号」里,然后服务端回第二次握手给客户端;
  • 服务端接收到客户端的应答报文时,服务端会检查这个 ACK 包的合法性。如果合法,将该连接对象放入到「 Accept 队列」。
  • 最后应用程序通过调用 accpet() 接口,从「 Accept 队列」取出的连接。

可以看到,当开启了 tcp_syncookies 了,即使受到 SYN 攻击而导致 SYN 队列满时,也能保证正常的连接成功建立。

net.ipv4.tcp_syncookies 参数主要有以下三个值:

  • 0 值,表示关闭该功能;
  • 1 值,表示仅当 SYN 半连接队列放不下时,再启用它;
  • 2 值,表示无条件开启功能;

那么在应对 SYN 攻击时,只需要设置为 1 即可。

$ echo 1 > /proc/sys/net/ipv4/tcp_syncookies

方式四:减少 SYN+ACK 重传次数

当服务端受到 SYN 攻击时,就会有大量处于 SYN_REVC 状态的 TCP 连接,处于这个状态的 TCP 会重传 SYN+ACK ,当重传超过次数达到上限后,就会断开连接。

那么针对 SYN 攻击的场景,我们可以减少 SYN-ACK 的重传次数,以加快处于 SYN_REVC 状态的 TCP 连接断开。

SYN-ACK 报文的最大重传次数由 tcp_synack_retries内核参数决定(默认值是 5 次),比如将 tcp_synack_retries 减少到 2 次:

$ echo 2 > /proc/sys/net/ipv4/tcp_synack_retries

4、TCP 四次挥手过程说一下?

img

具体过程:

  • 客户端主动调用关闭连接的函数,于是就会发送 FIN 报文,这个 FIN 报文代表客户端不会再发送数据了,进入 FIN_WAIT_1 状态;
  • 服务端收到了 FIN 报文,然后马上回复一个 ACK 确认报文,此时服务端进入 CLOSE_WAIT 状态。在收到 FIN 报文的时候,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,服务端应用程序可以通过 read 调用来感知这个 FIN 包,这个 EOF 会被放在已排队等候的其他已接收的数据之后,所以必须要得继续 read 接收缓冲区已接收的数据;
  • 接着,当服务端在 read 数据的时候,最后自然就会读到 EOF,接着 read() 就会返回 0,这时服务端应用程序如果有数据要发送的话,就发完数据后才调用关闭连接的函数,如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,这时服务端就会发一个 FIN 包,这个 FIN 报文代表服务端不会再发送数据了,之后处于 LAST_ACK 状态;
  • 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;
  • 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态;
  • 客户端经过 2MSL 时间之后,也进入 CLOSE 状态;

5、为什么4次握手中间两次不能变成一次?

服务器收到客户端的 FIN 报文时,内核会马上回一个 ACK 应答报文,但是服务端应用程序可能还有数据要发送,所以并不能马上发送 FIN 报文,而是将发送 FIN 报文的控制权交给服务端应用程序

  • 如果服务端应用程序有数据要发送的话,就发完数据后,才调用关闭连接的函数;
  • 如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,

从上面过程可知,是否要发送第三次挥手的控制权不在内核,而是在被动关闭方(上图的服务端)的应用程序,因为应用程序可能还有数据要发送,由应用程序决定什么时候调用关闭连接的函数,当调用了关闭连接的函数,内核就会发送 FIN 报文了,所以服务端的 ACK 和 FIN 一般都会分开发送。

6、第二次和第三次挥手能合并嘛

当被动关闭方在 TCP 挥手过程中,「没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。

img

7、第三次挥手一直没发,会发生什么?

当主动方收到 ACK 报文后,会处于 FIN_WAIT2 状态,就表示主动方的发送通道已经关闭,接下来将等待对方发送 FIN 报文,关闭对方的发送通道。

这时,如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态,因为它可能还可以发送或接收数据。但对于 close 函数关闭的孤儿连接,由于无法再发送和接收数据,所以这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒:

img

它意味着对于孤儿连接(调用 close 关闭的连接),如果在 60 秒后还没有收到 FIN 报文,连接就会直接关闭。

8、第二次和第三次挥手之间,主动断开的那端能干什么

如果主动断开的一方,是调用了 shutdown 函数来关闭连接,并且只选择了关闭发送能力且没有关闭接收能力的话,那么主动断开的一方在第二次和第三次挥手之间还可以接收数据

img