面试实录:电商大厂三面直击HashMap、JVM、秒杀架构与分布式事务,水货程序员牢大的翻车现场

场景:某头部电商大厂(以下简称"某东"),Java高级开发工程师岗位,3-5年经验要求,薪资范围25k-35k。

面试官:老李,某东资深架构师,10年Java老兵,性格严肃认真,最讨厌面试背八股文。

求职者:牢大,号称4年Java开发经验,实际水平≈1年CRUD,简历上写满了"精通Spring Cloud"、"深入理解JVM"、"亿级并发经验",实则全靠培训班包装。


第一轮:基础热身上线

面试官老李:(翻看简历,眉头微皱)"看你简历上写着'精通Java集合框架',那咱们从基础的开始聊。先说说HashMap的底层实现原理吧,JDK8之后有什么变化?"

牢大:(心里暗喜,这个我背过!)"这个简单!HashMap底层是数组+链表+红黑树。JDK8之前就是数组加链表,JDK8之后当链表长度大于8并且数组长度大于64的时候,链表会转成红黑树。扩容机制是当元素数量超过容量乘以负载因子的时候触发,默认负载因子是0.75。put的时候先算hash,然后定位到数组下标,如果没冲突直接放进去,有冲突就用拉链法。"

面试官老李:(微微点头)"嗯,基础还行。那我问深一点——为什么链表转红黑树的阈值是8?为什么不是7或者9?还有,红黑树什么时候会退化回链表?"

牢大:(开始冒汗)"呃……这个……因为8是一个比较合理的值……红黑树退化的话,应该是元素减少到一定程度……6?对,小于6的时候退化成链表。"

面试官老李:"差不多,是小于等于6。那你知道为什么转树是8,退链是6,中间留了一个7的差值吗?"

牢大:"额……是为了防止频繁转换?避免在阈值附近反复横跳?"

面试官老李:(露出一丝微笑)"对,这是为了防止哈希表在阈值附近频繁地进行树化和链化,造成性能损耗。行,这块过了。你简历还写了'深入理解JVM',聊聊JVM内存结构吧。"

牢大:(又来劲了)"JVM内存结构分五大块:堆、方法区、虚拟机栈、本地方法栈、程序计数器。堆是存放对象实例的,方法区存放类信息、常量、静态变量,虚拟机栈是线程私有的,每个方法执行的时候会创建一个栈帧。JDK8把方法区改成了元空间,用本地内存来实现。"

面试官老李:"不错,那堆内存的分代模型呢?年轻代和老年代,以及GC的策略,结合电商场景说说。"

牢大:"年轻代分Eden区和两个Survivor区,比例默认是8:1:1。对象先在Eden分配,经过一次Minor GC后存活的对象进入Survivor,经过多次GC后进入老年代。电商场景的话……比如大促的时候会有大量订单对象,这些对象生命周期短,大部分在年轻代就被回收了。"

面试官老李:(点头)"可以。我看你简历写了Spring Boot,说说Spring Boot的自动装配原理。"

牢大:"自动装配就是……通过@SpringBootApplication注解,它包含了@EnableAutoConfiguration,然后……会去读取META-INF/spring.factories文件……里面配置了自动配置类……然后根据条件注解来判断是否生效……"(越说越没底气)

面试官老李:"你提到了条件注解,说说@ConditionalOnMissingBean和@ConditionalOnBean的区别,以及它们在自动装配里的作用。"

牢大:"呃……一个是Bean不存在的时候生效,一个是Bean存在的时候生效……比如默认的DataSource自动配置,就是当用户没有自己定义DataSource Bean的时候,自动配置才会生效……吧?"

面试官老李:"方向是对的。那如果我想自定义一个starter,需要哪些步骤?"

牢大:"这个……需要写一个自动配置类,然后在spring.factories里面注册……还需要一个配置属性类……大概就是这样。"(其实完全没自己写过)

面试官老李:(表情恢复严肃)"行,这轮先到这里。基础还可以,有些细节需要加强。"


第二轮:中间件深水区

面试官老李:"咱们进入第二轮。你在简历上写了'精通Spring Cloud微服务体系',说说你们项目里微服务是怎么做服务发现的?"

牢大:(紧张了,这题超纲)"我们用的是Nacos……服务注册的时候,服务提供者启动后会把自己的信息注册到Nacos,然后消费者从Nacos拉取服务列表……还支持健康检查。"

面试官老李:"Nacos的AP模式和CP模式有什么区别?电商场景下你们用哪种?"

牢大:"AP是……高可用模式,CP是一致性模式。电商的话……应该用AP吧,因为要保证可用性。但其实我们项目里用的就是默认配置,没太关注这个……"(心虚)

面试官老李:(皱眉)"好。那聊聊缓存吧,Redis在你的项目里怎么用的?"

牢大:"Redis主要用来做热点数据缓存,比如商品详情、用户信息这些。我们还用Redis实现过分布式锁。"

面试官老李:"说到缓存,缓存穿透、缓存击穿、缓存雪崩,这三个概念你能区分清楚吗?分别怎么解决?"

牢大:"缓存穿透是查询不存在的数据,请求直接打到数据库。解决方案是用布隆过滤器,或者把空值也缓存起来。缓存击穿是热点key过期,大量请求打到数据库……解决方案是加互斥锁,或者设置热点key永不过期。缓存雪崩是大面积缓存同时过期……解决方案是给过期时间加随机值,或者用集群保证高可用。"

面试官老李:(稍微满意)"这个回答还不错。那你刚才提到分布式锁,Redis实现分布式锁有什么坑?"

牢大:"坑的话……锁需要设置过期时间防止死锁,还有释放锁的时候要判断是不是自己加的锁,用Lua脚本保证原子性。Redisson的看门狗机制可以自动续期。"

面试官老李:"那Redis主从架构下,分布式锁有什么问题?"

牢大:(懵了)"主从……主从的话……如果主节点挂了,锁信息还没同步到从节点……那从节点提升为主之后,锁就丢了……RedLock算法可以解决这个问题。但说实话,我们项目并发量不大,没遇到这个问题。"

面试官老李:(叹了口气)"行,说说消息队列吧。你们用Kafka还是RabbitMQ?"

牢大:"Kafka。用来做订单异步处理,比如用户下单后,订单服务发消息到Kafka,然后库存服务消费消息扣减库存。"

面试官老李:"Kafka怎么保证消息不丢失?"

牢大:"这个……生产者端用ack机制,设置acks=all保证所有副本都收到消息;Broker端保证副本数大于1;消费者端手动提交offset,处理完业务后再提交。"

面试官老李:"那消息重复消费怎么办?"

牢大:"要做幂等处理。比如用数据库的唯一约束,或者Redis记录已经处理过的消息ID,消费前先判断。"

面试官老李:"如果我要保证消息的严格顺序,Kafka怎么实现?"

牢大:"Kafka同一个分区内的消息是有序的,所以可以把需要有序的消息发到同一个分区……比如同一个订单的所有消息用订单ID作为key。但是跨分区就没办法保证顺序了……还有消费者端多线程消费也会打乱顺序。"

面试官老李:"这轮暴露了一些问题。你对中间件的理解停留在'能用'的层面,原理和边界case还不够清楚。"


第三轮:场景设计生死局

面试官老李:"最后一轮,我们聊点实际的。假设某东要做一个秒杀活动,茅台秒杀,库存1000瓶,预计有100万人同时抢。你从零开始设计这个秒杀系统,说说你的思路。"

牢大:(冷汗直流)"这个……首先……前端要做限流,比如按钮防重复点击……然后网关层做流量限制……再往后用Redis做库存预扣减……下单请求放到消息队列异步处理……"

面试官老李:"前端限流?100万人抢1000瓶,你前端限流有什么用?说后端架构。"

牢大:"后端的话……Nginx做第一层限流,限流算法用令牌桶或者漏桶……然后网关层用Sentinel做熔断降级……Redis做库存预热,把库存数据提前加载到Redis,扣减的时候用Lua脚本保证原子性……下单请求发送到Kafka,订单服务慢慢消费……"

面试官老李:"Redis扣减库存用Lua脚本,那如果Redis也扛不住100万的并发呢?"

牢大:"那可以用Redis集群分片……把库存分散到多个Redis节点……但说实话100万并发对Redis来说应该还好……吧……"(其实完全不确定)

面试官老李:"那你这个方案里,什么时候扣减数据库的库存?如果Redis扣减成功了,但数据库扣减失败了怎么办?"

牢大:"这个……用最终一致性,Kafka消费的时候再扣数据库……如果失败了就重试……或者用分布式事务……Seata的AT模式……"(越说越乱)

面试官老李:(表情严肃)"Seata的AT模式和TCC模式有什么区别?秒杀场景适合用哪个?"

牢大:"AT模式是自动回滚……TCC需要自己写Try/Confirm/Cancel逻辑……秒杀的话……可能TCC更合适?因为需要更精细的控制……但说实话我没在生产环境用过分布式事务,我们项目里都是单机事务。"

面试官老李:"好吧。再问一个,如果秒杀活动上线后,你发现RT突然飙升,怎么排查?你们监控体系怎么搭的?"

牢大:"监控的话……我们用的是Prometheus + Grafana,采集JVM指标、接口RT、QPS这些……然后日志用ELK,链路追踪用Jaeger……排查问题的话,先看监控大屏,找到哪个服务RT高,然后看链路追踪找到具体是哪个接口慢,再结合日志分析……"

面试官老李:"听起来还行。那如果Jaeger显示是一个SQL执行了3秒,你怎么优化?"

牢大:"用explain看执行计划……看看有没有走索引……如果没有就加索引……如果是大表JOIN的话考虑拆分成多次查询……或者用ES做搜索……"

面试官老李:"如果索引也加了,SQL还是很慢呢?"

牢大:"那可能是数据量太大了……考虑分库分表……用ShardingSphere……按订单ID做分片键……"

面试官老李:"分库分表后,跨分片的查询怎么处理?比如按用户ID查询订单,但分片键是订单ID。"

牢大:(彻底崩溃)"这个……用ES做异构索引……或者……建立一个用户ID到订单ID的映射表……其实我们项目没做过大规模分库分表,最多就是读写分离……"

面试官老李:(合上简历,沉默片刻)"今天的面试就到这里吧。你的基础部分还算扎实,HashMap、JVM这些常规八股文背得不错,Redis的基础场景也能回答。但是一深入到分布式系统的细节,比如分布式事务、大规模并发设计、分库分表的实际问题,你的经验明显不够。坦白说,你的水平和简历上写的'精通'、'深入理解'还有不小的差距。"

牢大:(尴尬地笑)"谢谢面试官,我回去会好好补补这些知识的……"

面试官老李:"嗯,面试结果我们内部讨论后会通知HR,你回去等通知吧。"

牢大:(心里OS:经典"等通知",散了散了,回去接着刷面试题……)


面试复盘:技术点深度解析

以下是面试中涉及的所有技术点的详细讲解,适合Java开发者系统学习。


一、HashMap底层原理

1. 数据结构

JDK8中HashMap的底层结构是数组 + 链表 + 红黑树

  • 数组:哈希桶数组,通过hash值定位元素位置
  • 链表:解决哈希冲突,采用拉链法(头插法→尾插法)
  • 红黑树:链表过长时优化查询效率
2. 核心参数

| 参数 | 默认值 | 说明 | |------|--------|------| | 初始容量 | 16 | 哈希桶数组初始大小 | | 负载因子 | 0.75 | 扩容阈值比例 | | 树化阈值 | 8 | 链表长度≥8时转为红黑树 | | 退链阈值 | 6 | 红黑树元素≤6时退回链表 | | 最小树化容量 | 64 | 数组长度≥64时才能树化 |

3. 为什么树化阈值是8?

根据泊松分布概率统计,当负载因子为0.75时,链表长度达到8的概率极低(约0.00000006),因此选择8作为树化阈值,在概率和性能之间取得平衡。

泊松分布下链表长度概率:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006  ← 概率极低
4. 为什么退链阈值是6而不是8?

这是为了避免频繁转换。如果树化阈值和退链阈值都是8,那么当元素在7-9之间波动时,会频繁触发树化和链化,造成性能损耗。设置7的缓冲区可以有效避免这个问题。

5. JDK8 vs JDK7的改进

| 对比维度 | JDK7 | JDK8 | |----------|------|------| | 数据结构 | 数组+链表 | 数组+链表+红黑树 | | 插入方式 | 头插法(多线程死循环) | 尾插法 | | Hash计算 | 4次位运算+5次异或 | 1次位运算+1次异或 | | 扩容时机 | 先扩容再插入 | 先插入再扩容 |


二、JVM内存结构与GC

1. JVM内存结构全景
JVM内存
├── 线程共享
│   ├── 堆(Heap)
│   │   ├── 年轻代(Young Generation)
│   │   │   ├── Eden区
│   │   │   ├── Survivor 0(S0)
│   │   │   └── Survivor 1(S1)
│   │   └── 老年代(Old Generation)
│   └── 方法区(Method Area)/ 元空间(Metaspace,JDK8+)
│       ├── 类信息
│       ├── 常量池
│       └── 静态变量
└── 线程私有
    ├── 虚拟机栈(VM Stack)
    │   └── 栈帧(Stack Frame)
    │       ├── 局部变量表
    │       ├── 操作数栈
    │       ├── 动态链接
    │       └── 返回地址
    ├── 本地方法栈(Native Method Stack)
    └── 程序计数器(Program Counter Register)
2. 堆内存分配流程
1. 对象优先在Eden区分配
2. Eden区满 → 触发Minor GC
3. 存活对象 → Survivor区(年龄+1)
4. 年龄达到阈值(默认15)→ 晋升老年代
5. 大对象(可通过-XX:PretenureSizeThreshold设置)→ 直接进入老年代
3. GC算法

| 算法 | 适用区域 | 特点 | |------|----------|------| | 标记-清除 | 老年代 | 产生内存碎片 | | 标记-整理 | 老年代 | 无碎片,但耗时 | | 复制算法 | 年轻代 | 效率高,但浪费空间 |

4. 电商场景GC调优

电商大促期间的特点:大量短生命周期对象(订单、请求上下文等)

  • 适当增大年轻代,让短命对象在年轻代就被回收
  • 控制晋升老年代的对象数量,减少Full GC
  • 选择合适的GC收集器:高吞吐用Parallel GC,低延迟用G1 GC

三、Spring Boot自动装配原理

1. 核心注解链路
@SpringBootApplication
    ├── @SpringBootConfiguration(标识配置类)
    ├── @EnableAutoConfiguration(自动装配的核心)
    │   ├── @AutoConfigurationPackage
    │   └── @Import(AutoConfigurationImportSelector.class)
    └── @ComponentScan(组件扫描)
2. 自动装配执行流程
1. Spring Boot启动时,通过AutoConfigurationImportSelector
2. 读取META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件(Spring Boot 2.7+)
   (旧版本读取META-INF/spring.factories)
3. 加载所有自动配置类(约150+个)
4. 通过条件注解(@Conditional系列)判断是否生效
5. 符合条件的自动配置类创建对应的Bean
3. 条件注解详解

| 注解 | 作用 | 示例 | |------|------|------| | @ConditionalOnBean | 指定Bean存在时生效 | 仅在存在DataSource时配置JdbcTemplate | | @ConditionalOnMissingBean | 指定Bean不存在时生效 | 用户未定义RedisTemplate时自动创建 | | @ConditionalOnClass | 类存在时生效 | classpath有Redis依赖时配置 | | @ConditionalOnProperty | 配置属性满足时生效 | spring.cache.type=redis时启用 |

4. 自定义Starter步骤
1. 创建autoconfigure模块
2. 定义配置属性类(@ConfigurationProperties)
3. 编写自动配置类(@Configuration + @Bean)
4. 在META-INF/spring/xxx.imports中注册配置类
5. 创建starter模块,依赖autoconfigure模块

四、Redis缓存三大问题

1. 缓存穿透

定义:查询一个不存在的数据,缓存和数据库都没有,导致每次请求都打到数据库。

攻击场景:恶意用户用不存在的ID(如负数ID)持续请求。

解决方案

  • 布隆过滤器:在缓存层之前用布隆过滤器判断key是否存在
  • 缓存空值:将null结果也缓存,设置较短过期时间(如5分钟)
  • 参数校验:对明显非法的参数直接拦截
// 布隆过滤器示例
public Product getProduct(Long id) {
    // 1. 布隆过滤器判断
    if (!bloomFilter.mightContain(id)) {
        return null; // 一定不存在
    }
    // 2. 查缓存
    Product product = redis.get("product:" + id);
    if (product != null) return product;
    // 3. 查数据库
    product = db.query(id);
    if (product != null) {
        redis.set("product:" + id, product);
    } else {
        redis.set("product:" + id, "NULL", 300); // 空值缓存5分钟
    }
    return product;
}
2. 缓存击穿

定义:某个热点key在过期瞬间,大量并发请求直接打到数据库。

典型场景:秒杀商品缓存刚好过期。

解决方案

  • 互斥锁:只有一个线程能查数据库并重建缓存
  • 逻辑过期:缓存不设TTL,value中存储过期时间,后台异步更新
  • 永不过期:热点数据不设过期时间,通过异步线程更新
// 互斥锁方案
public Product getProduct(Long id) {
    Product product = redis.get("product:" + id);
    if (product != null) return product;
    
    // 加锁重建缓存
    String lockKey = "lock:product:" + id;
    if (redis.setnx(lockKey, "1", 10)) { // 获取锁
        try {
            // 双重检查
            product = redis.get("product:" + id);
            if (product != null) return product;
            
            product = db.query(id);
            redis.set("product:" + id, product, 3600);
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没拿到锁,等一会重试
        Thread.sleep(50);
        return getProduct(id);
    }
    return product;
}
3. 缓存雪崩

定义:大量缓存同一时间段过期,或者Redis集群宕机,导致所有请求打到数据库。

解决方案

  • 过期时间加随机值:避免同时过期
  • 集群高可用:Redis Cluster / 哨兵模式
  • 多级缓存:本地缓存(Caffeine)+ 分布式缓存(Redis)
  • 限流降级:Sentinel/Hystrix保护数据库
// 过期时间加随机值
int baseExpire = 3600; // 基础1小时
int randomExpire = new Random().nextInt(600); // 随机0-10分钟
redis.set(key, value, baseExpire + randomExpire);

五、Redis分布式锁

1. 基本实现
// 加锁
String lockValue = UUID.randomUUID().toString();
boolean locked = redis.setnx("lock:order:" + orderId, lockValue, 30);

// 解锁 - Lua脚本保证原子性
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redis.eval(lua, key, lockValue);
2. 关键问题

| 问题 | 说明 | 解决方案 | |------|------|----------| | 死锁 | 获取锁的线程挂了,锁永远不释放 | 设置过期时间 | | 误删锁 | 线程A的锁过期,线程B获取锁,A释放了B的锁 | value用唯一标识+Lua原子释放 | | 锁过期 | 业务执行时间超过锁过期时间 | Redisson看门狗自动续期 | | 主从切换丢锁 | 主节点宕机,锁信息未同步到从节点 | RedLock算法 |

3. Redisson看门狗机制

Redisson在加锁时不指定leaseTime时,会启动Watchdog线程,默认每10秒续期一次,将锁持有时间重置为30秒:

// Redisson分布式锁
RLock lock = redissonClient.getLock("lock:order:" + orderId);
lock.lock(); // 看门狗自动续期

try {
    // 业务逻辑
} finally {
    lock.unlock();
}

六、Kafka消息可靠性

1. 消息不丢失三要素
┌──────────────────────────────────────────────────────┐
│                    消息不丢失保障                       │
├──────────────┬───────────────┬────────────────────────┤
│   生产者端    │    Broker端   │       消费者端          │
├──────────────┼───────────────┼────────────────────────┤
│ acks=all     │ replication   │ 手动提交offset          │
│ retries>0    │ factor ≥ 3     │ 先处理业务再提交         │
│ 幂等性       │ min.insync    │ 保证幂等性              │
│              │ replicas ≥ 2  │                        │
└──────────────┴───────────────┴────────────────────────┘
2. 生产者配置
# acks=all:所有ISR副本确认后才返回成功
acks=all
# 重试次数
retries=3
# 开启幂等性(防止重复)
enable.idempotence=true
# 每个连接最大未确认请求数
max.in.flight.requests.per.connection=5
3. 消费者配置
# 关闭自动提交
enable.auto.commit=false
# 消费策略:从最早开始
# auto.offset.reset=earliest
// 手动提交offset
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> record : records) {
        try {
            processMessage(record); // 先处理业务
        } catch (Exception e) {
            // 处理失败,不提交offset,下次重新消费
            continue;
        }
    }
    consumer.commitSync(); // 处理完毕后再提交
}
4. 消息幂等性方案
// 方案1:数据库唯一约束
// 订单号作为唯一键,重复插入会失败
INSERT INTO orders (order_id, ...) VALUES (?, ...) ON DUPLICATE KEY UPDATE ...

// 方案2:Redis记录已处理消息
String msgKey = "processed:msg:" + msgId;
boolean isFirst = redis.setnx(msgKey, "1", 3600);
if (!isFirst) {
    return; // 已处理过
}

// 方案3:消息体带业务唯一ID
// 消费前查询数据库判断是否已处理
if (orderDao.exists(orderId)) {
    return; // 已处理
}

七、秒杀系统架构设计

1. 核心挑战

| 挑战 | 说明 | 数据量级 | |------|------|----------| | 高并发 | 100万人同时抢1000瓶茅台 | QPS峰值可达数十万 | | 超卖 | 库存只有1000,不能卖出1001 | 必须精确控制 | | 恶意刷单 | 黄牛脚本、羊毛党 | 需要风控 | | 数据库压力 | 大量下单请求不能直接落库 | 需要异步削峰 |

2. 架构分层
┌──────────────────────────────────────────────────────┐
│                     前端/CDN                           │
│   - 静态资源CDN加速                                    │
│   - 按钮防重复(灰置+倒计时)                            │
└──────────────────────┬───────────────────────────────┘
                       ↓
┌──────────────────────────────────────────────────────┐
│                   Nginx 网关层                         │
│   - IP限流(limit_req_zone)                          │
│   - 校验登录态                                         │
│   - 直接拦截99%无效请求                                │
└──────────────────────┬───────────────────────────────┘
                       ↓
┌──────────────────────────────────────────────────────┐
│               API Gateway(Spring Cloud Gateway)      │
│   - Sentinel限流降级                                   │
│   - 用户风控校验(黑名单)                              │
│   - 请求合法性校验                                      │
└──────────────────────┬───────────────────────────────┘
                       ↓
┌──────────────────────────────────────────────────────┐
│                  秒杀服务层                             │
│   ┌─────────────────────────────────────┐            │
│   │  Redis 库存预扣减(Lua原子操作)      │            │
│   │  - 库存1000预热到Redis               │            │
│   │  - Lua脚本:查询库存→判断→扣减       │            │
│   └──────────────┬──────────────────────┘            │
│                  ↓                                    │
│   ┌─────────────────────────────────────┐            │
│   │  秒杀成功→生成预订单→发送Kafka消息    │            │
│   │  秒杀失败→直接返回"已售罄"           │            │
│   └─────────────────────────────────────┘            │
└──────────────────────┬───────────────────────────────┘
                       ↓
┌──────────────────────────────────────────────────────┐
│                   Kafka 削峰                          │
│   - 分区数按业务量设定                                 │
│   - 消费者组逐步消费                                   │
└──────────────────────┬───────────────────────────────┘
                       ↓
┌──────────────────────────────────────────────────────┐
│                  订单服务(消费端)                      │
│   - 校验预订单                                         │
│   - 扣减数据库库存                                     │
│   - 生成正式订单                                       │
│   - 幂等性校验                                         │
└──────────────────────────────────────────────────────┘
3. Redis库存扣减Lua脚本
-- 秒杀扣减库存 Lua脚本(原子操作)
local productKey = KEYS[1]        -- 商品库存key
local userId = ARGV[1]            -- 用户ID
local requestId = ARGV[2]         -- 请求ID(幂等)

-- 检查是否重复请求
local dedupKey = "seckill:dedup:" .. requestId
if redis.call('exists', dedupKey) == 1 then
    return {-1, "重复请求"}
end

-- 检查库存
local stock = tonumber(redis.call('get', productKey))
if stock == nil or stock <= 0 then
    return {0, "库存不足"}
end

-- 检查用户是否已秒杀过(一人一单)
local userKey = "seckill:user:" .. productKey .. ":" .. userId
if redis.call('exists', userKey) == 1 then
    return {-2, "已参与秒杀"}
end

-- 扣减库存
redis.call('decr', productKey)
-- 记录用户已秒杀
redis.call('setex', userKey, 86400, "1")
-- 记录请求去重
redis.call('setex', dedupKey, 300, "1")

return {1, "秒杀成功"}

八、分布式事务

1. Seata模式对比

| 维度 | AT模式 | TCC模式 | Saga模式 | |------|--------|---------|----------| | 侵入性 | 低(自动回滚) | 高(需实现3个接口) | 中 | | 性能 | 中(有全局锁) | 高(无锁) | 高 | | 适用场景 | 通用场景 | 核心交易链路 | 长事务 | | 秒杀适用性 | ❌(全局锁影响并发) | ✅(高性能) | ❌(不适合短事务) |

2. TCC模式示例
// Try阶段:预留资源
public boolean tryFreezeStock(String orderId, int count) {
    // 冻结库存(不可见),而非真正扣减
    return stockDao.freezeStock(orderId, count) > 0;
}

// Confirm阶段:确认提交
public boolean confirmDeductStock(String orderId) {
    // 将冻结库存转为已扣减
    return stockDao.commitFrozenStock(orderId) > 0;
}

// Cancel阶段:回滚
public boolean cancelFreezeStock(String orderId) {
    // 释放冻结库存
    return stockDao.releaseFrozenStock(orderId) > 0;
}
3. 秒杀场景的分布式事务简化方案

实际上,秒杀场景更推荐最终一致性 + 异步补偿而非强一致性分布式事务:

1. Redis扣减库存成功 → 发消息到Kafka
2. 订单服务消费消息 → 扣数据库库存 → 生成订单
3. 如果数据库操作失败 → 重试机制 + 回补Redis库存
4. 定时任务对账:Redis库存 + 数据库库存 + 订单数

九、全链路监控体系

1. 监控三件套
┌───────────────┬──────────────────┬─────────────────────┐
│    Metrics    │    Logging       │     Tracing         │
│   指标监控     │    日志聚合       │     链路追踪         │
├───────────────┼──────────────────┼─────────────────────┤
│ Prometheus    │ ELK (ES+Logstash │ Jaeger / Zipkin    │
│ + Grafana     │  + Kibana)       │                     │
├───────────────┼──────────────────┼─────────────────────┤
│ QPS/RT/错误率  │ 业务日志查询      │ 请求调用链路         │
│ JVM指标        │ 异常日志告警      │ 耗时分析            │
│ 系统资源       │ 关键词检索        │ 服务依赖拓扑         │
└───────────────┴──────────────────┴─────────────────────┘
2. 秒杀RT飙升排查链路
Step 1: Grafana查看整体QPS/RT → 哪个服务RT高?
         ↓
Step 2: Jaeger链路追踪 → 哪个Span耗时高?
         ↓
Step 3: 定位到具体接口/方法 → 查看慢SQL/慢调用
         ↓
Step 4: explain分析SQL → 索引问题?表数据量?
         ↓
Step 5: 解决:加索引 / 分库分表 / 缓存预热 / 限流
3. Micrometer + Prometheus示例
// Spring Boot Actuator + Micrometer 自动暴露指标
@RestController
public class SeckillController {
    
    private final MeterRegistry meterRegistry;
    
    @PostMapping("/seckill")
    public Result seckill(@RequestBody SeckillRequest req) {
        // 计数器:秒杀请求总数
        meterRegistry.counter("seckill.request.total").increment();
        
        long start = System.currentTimeMillis();
        try {
            Result result = seckillService.execute(req);
            // 计数器:成功数
            meterRegistry.counter("seckill.success.total").increment();
            return result;
        } finally {
            // 计时器:RT
            long duration = System.currentTimeMillis() - start;
            meterRegistry.timer("seckill.request.duration")
                .record(duration, TimeUnit.MILLISECONDS);
        }
    }
}

十、分库分表与跨分片查询

1. 分片策略

| 策略 | 示例 | 优点 | 缺点 | |------|------|------|------| | 按订单ID分片 | order_id % 16 | 订单查询精准路由 | 按用户查需全表扫描 | | 按用户ID分片 | user_id % 16 | 用户维度查询高效 | 商家维度查询困难 | | 按时间分片 | 按月分表 | 历史数据归档方便 | 跨月查询复杂 |

2. 跨分片查询解决方案
方案1:异构索引(ES/HBase)
  - MySQL按order_id分片
  - 同步数据到ES,按user_id建立索引
  - 用户查询时先走ES获取order_id列表,再回MySQL查询

方案2:映射表
  - 建立 user_id → order_id 的映射表
  - 映射表可以按user_id分片
  - 先查映射表获取order_id,再查订单表

方案3:基因法
  - order_id中嵌入user_id的"基因"(如user_id的后4位)
  - 这样同一个用户的订单会落在同一个分片
  - 适用于用户维度查询为主的场景
3. ShardingSphere分片配置
spring:
  shardingsphere:
    datasource:
      names: ds0,ds1
    rules:
      sharding:
        tables:
          t_order:
            actual-data-nodes: ds$->{0..1}.t_order_$->{0..15}
            database-strategy:
              standard:
                sharding-column: order_id
                sharding-algorithm-name: database-inline
            table-strategy:
              standard:
                sharding-column: order_id
                sharding-algorithm-name: table-inline
        sharding-algorithms:
          database-inline:
            type: INLINE
            props:
              algorithm-expression: ds$->{order_id % 2}
          table-inline:
            type: INLINE
            props:
              algorithm-expression: t_order_$->{order_id % 16}

总结

这场面试覆盖了从Java基础到分布式架构的完整技术栈:

  • 基础扎实:HashMap、JVM、Spring Boot自动装配是面试敲门砖
  • 中间件深入:Redis、Kafka不仅要会用,更要理解原理和边界case
  • 架构设计:秒杀场景是检验分布式能力的试金石
  • 监控运维:全链路监控是保障系统稳定性的关键

牢大的翻车告诉我们:简历上写"精通"要慎重,面试官一问就露馅。 真正的高手不在于背了多少八股文,而在于对技术的深入理解和实际解决复杂问题的能力。

如果你也在准备Java面试,建议从以上技术点逐一攻克,理论结合实践,才能在面试中游刃有余!

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐