一、语雀-Java并发面试题
1、synchronized是怎么实现的?
synchronized是Java中实现线程同步的核心机制,其底层基于Monitor模型和锁优化技术,具体实现如下:
-
Monitor与对象锁
每个Java对象(或类)关联一个Monitor(监视器),synchronized通过获取对象的锁(lock())进入Monitor,释放锁(unlock())退出。锁信息存储在对象头的锁标记字段中,用于标记当前锁的状态(如无锁、偏向锁、轻量级锁或重量级锁)。 -
锁的升级机制
• 偏向锁:首次访问时,锁偏向当前线程,减少无竞争时的开销。
• 轻量级锁:若发生竞争,偏向锁升级为轻量级锁,通过CAS(Compare-And-Swap)操作尝试获取锁,避免线程阻塞。
• 重量级锁:当CAS频繁失败时,锁升级为操作系统级别的互斥量(Mutex),阻塞竞争线程并调度至等待队列,由操作系统管理线程阻塞与唤醒。 -
原子性与可见性保障
• 原子性:锁的获取和释放确保同步代码块或方法的互斥执行。
• 可见性:锁的释放会强制刷新线程本地缓存到主内存,新获取锁的线程能看到最新数据。 -
静态锁的特殊性
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 | 重量级锁模式(操作系统互斥锁) | 长时间竞争 |
| LOCKED | JVM 内部锁(如 synchronized 方法锁) | static 方法同步 |
1.3 锁的获取与释放流程
1. 获取锁(monitorenter)
-
步骤:
-
检查 Mark Word 的锁状态:
- UNLOCKED:直接标记为当前线程持有(偏向锁)。
- BIASED:若当前线程是偏向线程,直接获取;否则升级为轻量级锁。
- LIGHTLY_LOCKED:自旋等待(轻量级锁)。
- HEAVY_LOCKED:调用操作系统
pthread_mutex_lock,阻塞当前线程。
-
更新 Mark Word 并返回成功。
-
2. 释放锁(monitorexit)
-
步骤:
- 清除 Mark Word 中的线程 ID 和锁标记。
- 唤醒等待的线程(通过操作系统信号或 JVM 内部机制)。
1.4 操作系统层面的实现
当锁升级为 重量级锁 时,JVM 会通过以下方式与操作系统交互:
- 用户态到内核态切换:线程因等待锁而阻塞,触发上下文切换。
- 进程间通信(IPC):操作系统维护锁的等待队列,使用条件变量或信号量通知等待线程。
- 原子操作:如
compare-and-swap(CAS)用于无锁编程中的锁竞争检测。
1.5 对比 synchronized 和 ReentrantLock
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现方式 | JVM 内置,基于对象头和操作系统 | Java 层面实现,可扩展性更强 |
| 锁类型 | 只支持非公平锁 | 支持公平锁和非公平锁 |
| 中断响应 | 不支持中断 | 支持 lockInterruptibly() |
| 性能 | 在无竞争时更快 | 在高竞争时可能更灵活 |
| 代码复杂度 | 简单,易用 | 需手动管理锁的获取/释放 |
1.6 总结
synchronized 的实现是 JVM + 操作系统 协作的经典案例:
- JVM 层:通过对象头的 Mark Word 管理锁状态,优化锁的获取/释放路径。
- 操作系统层:提供底层阻塞机制,确保锁的强一致性。
- 开发者层:需合理设计锁的粒度和使用场景,避免性能瓶颈和死锁。
2、监视锁Monitor
3、synchronized锁的是什么?
4、synchronized的锁升级过程是怎样的?
| 特性 | 轻量级锁 | 偏向锁 |
|---|---|---|
| 优点 | - 减少CAS失败后的线程阻塞开销。 - 适用于中等竞争场景。 | - 无竞争时开销极低(几乎零成本)。 - 适合单线程高频访问场景。 |
| 缺点 | - CAS操作本身有竞争风险。 - 自旋会消耗CPU资源。 | - 有竞争时需撤销偏向锁,带来额外开销。 - 适合极端单线程场景,通用性较低。 |
4.1 JDK15中废除了偏向锁
5、synchronized的锁优化是怎样的?
5.1 自旋锁
5.2 锁消除
5.3 锁粗化
6、int a = 1 是原子性操作吗,User a = new User(); 是原子性操作吗?
7、volatile是如何保证可见性和有序性的?
8、volatile在单例模式的作用
正确代码:
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后库存为负数(如网络抖动导致后续数据库更新失败),需通过锁兜底。
- 使用Redis的
-
分段锁兜底:
- 在
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操作能立即返回扣减后的结果,若结果为负数,则直接拒绝请求(避免超卖)。
- Redis 的
-
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 的局限性:
-
缺乏自动续期机制:
- 如果锁的持有者因长时间任务未完成导致锁过期,其他客户端可能抢占锁,引发并发问题。
- 若锁过期时间设置过短,可能在任务未完成时锁被释放,同样导致竞争。
-
集群脑裂问题:
- Redis 集群发生分裂时,可能导致多个客户端同时认为获取了锁(脑裂场景)。
-
单点故障敏感:
- 如果 Redis 主节点宕机且未启用持久化,锁数据可能丢失,导致锁状态不一致。
2. 解决方案:增强锁的容错性
(1) 设置锁的过期时间
通过 EXPIRE 命令为锁设置超时时间,避免锁永久持有。
- 优点:确保锁在超时后自动释放。
- 缺点:若任务在超时前未完成,可能被其他客户端抢占。
(2) 使用 RedLock 算法
RedLock 是 Redis 官方推荐的分布式锁算法,通过以下方式增强容错性:
- 多节点写入:在 N 个 Redis 主节点上尝试获取锁(通常为奇数,如 5 个节点)。
- 多数节点成功:只有当超过半数节点成功写入且总耗时小于锁有效期时,认为锁获取成功。
- 自动续期:在锁有效期内定期刷新过期时间,防止任务未完成时锁过期。
优点:容忍部分节点故障,避免单点问题。
(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。 - 两个分片锁独立运作,互不干扰。
- 用户 ID 为
(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. 库存恢复策略
-
基于队列的异步补偿
-
记录操作日志:每次
DECR操作时,将{userId, productId, amount}写入消息队列(如Kafka)。 -
活动后扫描队列:
- 未支付订单:通过
INCR恢复库存。 - 已支付订单:更新数据库库存,避免超卖。
- 未支付订单:通过
-
技术选型:
- 消息队列:优先选支持延迟消费的中间件(如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 函数时把连接取出来。
不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,内核会直接丢弃,或返回 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 半连接来建立连接。
具体过程:
- 当 「 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 四次挥手过程说一下?
具体过程:
- 客户端主动调用关闭连接的函数,于是就会发送 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 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。
7、第三次挥手一直没发,会发生什么?
当主动方收到 ACK 报文后,会处于 FIN_WAIT2 状态,就表示主动方的发送通道已经关闭,接下来将等待对方发送 FIN 报文,关闭对方的发送通道。
这时,如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态,因为它可能还可以发送或接收数据。但对于 close 函数关闭的孤儿连接,由于无法再发送和接收数据,所以这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒:
它意味着对于孤儿连接(调用 close 关闭的连接),如果在 60 秒后还没有收到 FIN 报文,连接就会直接关闭。
8、第二次和第三次挥手之间,主动断开的那端能干什么
如果主动断开的一方,是调用了 shutdown 函数来关闭连接,并且只选择了关闭发送能力且没有关闭接收能力的话,那么主动断开的一方在第二次和第三次挥手之间还可以接收数据。