Java大厂面试官VS水货程序员「牢大」:电商秒杀场景下微服务架构与Redis缓存实战面试实录

一场严肃与搞笑并存的Java面试,带你走进互联网大厂电商秒杀场景的真实技术面试。


故事背景

某互联网大厂会议室,下午两点。

面试官老张,工龄12年,架构师,表情严肃,眼镜片反光让人看不清眼神。他面前坐着一个穿着格子衫、头发略微凌乱的年轻人——江湖人称「牢大」,简历上写着5年Java开发经验,精通Spring Cloud全家桶,熟悉高并发场景。

老张翻了下简历,推了推眼镜,开始了这场面试。


第一轮:基础夯实——Java核心与框架基础

场景引入:电商商品详情页

面试官老张(严肃):「牢大,你先做个简单的自我介绍吧。然后我们从一个电商场景切入——假设你要做一个商品详情页的数据加载功能,商品信息存在MySQL里,需要高频访问。我们就从这个场景聊起。」

牢大(挺直腰板):「面试官好!我是牢大,5年Java开发,Spring全家桶熟手,微服务搞过,高并发也接触过。您说的商品详情页这个场景,我熟得很,之前在项目里做过类似的!」

老张:「好,那我不废话。第一个问题——」


问题1:HashMap底层原理与线程安全

老张:「商品详情页的数据,比如商品基本信息、SKU信息,我们通常会用一个Map结构来缓存。你跟我说说HashMap的底层数据结构是什么?另外,它在JDK 8之后做了什么优化?如果要在多线程环境下用Map,你会怎么做?」

牢大(眼睛一亮):「这个简单!HashMap底层是数组+链表+红黑树。JDK 8之前纯粹是数组加链表,JDK 8之后当链表长度超过8并且数组长度大于等于64的时候,链表就会转成红黑树,查询复杂度从O(n)降到O(log n)。

多线程环境下HashMap不安全的,会导致死循环和数据覆盖。如果要线程安全,可以用ConcurrentHashMap,它用了分段锁的机制,JDK 8之后改成了CAS+synchronized,性能比Hashtable好太多了!」

老张(微微点头):「不错,基础还算扎实。那ConcurrentHashMap的size()方法是怎么实现的?是直接加锁统计吗?」

牢大:「这个……size()方法在JDK 8里是通过baseCount加上CounterCell数组来统计的,用了CAS操作,不会直接锁整个Map。具体的,每次put的时候会通过CAS更新baseCount,如果CAS失败就转而更新CounterCell,最后size()的时候把baseCount和所有CounterCell的值加起来。这样避免了全局锁,性能很高。」

老张(嘴角微扬):「可以,这个细节能说出来,说明你看过源码。继续。」


问题2:Spring Boot自动配置原理

老张:「商品详情页的服务,你们用的是Spring Boot对吧?那你说说Spring Boot的自动配置是怎么实现的?如果你要自定义一个Starter,你会怎么做?」

牢大(自信满满):「这个我在行!Spring Boot自动配置的核心是**@EnableAutoConfiguration注解**,它通过@Import导入了AutoConfigurationImportSelector。这个Selector会去读classpath下的META-INF/spring.factories文件(Spring Boot 3.x之后改成了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports),加载里面配置的所有自动配置类。

每个自动配置类上都有一堆**@Conditional注解**,比如@ConditionalOnClass、@ConditionalOnMissingBean,这些条件注解会在运行时判断是否满足条件,满足的话才创建对应的Bean。

自定义Starter的话,一般建两个模块:一个是starter模块,只放pom依赖和spring.factories配置;另一个是autoconfigure模块,放自动配置类和配置属性类,用@ConfigurationProperties绑定application.yml里的配置。比如自定义一个redis-starter,就可以把Jedis或者Lettuce的连接创建逻辑封装进去。」

老张(点头赞许):「不错,Starter的模块拆分也提到了,说明你有实战经验。这个回答我给好评。」

牢大(窃喜):「嘿嘿,我以前写过公司内部的starter,给业务方用的。」


问题3:MySQL索引优化

老张:「好,商品信息存在MySQL里。假设商品表有字段:id, title, category_id, price, status, create_time。现在有一个查询很频繁:SELECT * FROM product WHERE category_id = 5 AND status = 1 ORDER BY create_time DESC LIMIT 20。你怎么建索引?」

牢大(不假思索):「这个简单!建一个联合索引(category_id, status, create_time)。这样where条件里的category_id和status都能走索引,create_time也能利用索引排序,避免了Using filesort。

不过这里有个小细节,如果status字段区分度很低(比如就0和1两个值),那其实建(category_id, create_time)也差不多,具体得看数据分布。但按规范来说,把等值查询的字段放前面,范围查询或排序的字段放后面,所以(category_id, status, create_time)是标准答案。」

老张(露出微笑):「不错,Using filesort的坑你也知道。那如果我有一个查询是WHERE category_id IN (1,2,3,4,5) AND status = 1,这个联合索引还能用上吗?」

牢大:「能!MySQL优化器会把IN条件展开,相当于多个等值查询,索引会用Index Range Scan。不过如果IN的范围太大,优化器可能觉得全表扫描更快,就不会走索引了。另外MySQL 5.6之后引入了Index Condition Pushdown(ICP),把过滤条件下推到引擎层,也能提升性能。」

老张:「很好,ICP都提到了,这一轮你表现不错。」


第一轮小结

老张在笔记本上记了几笔,心里默念:「基础确实扎实,HashMap源码、Spring Boot自动配置、MySQL索引优化都能说清楚,不像简历造假的。」

牢大心里:「这不都是八股文嘛,背就完事了!」


第二轮:进阶实战——缓存与消息队列

场景升级:秒杀活动上线

老张(话锋一转):「好了,基础差不多了。现在场景升级——你们公司要搞一个双11秒杀活动,茅台飞天53度,原价1499,秒杀价999,库存只有100瓶。预计有几万人同时抢。你来说说这个场景怎么设计?」

牢大(开始冒汗):「呃……秒杀嘛,就……用Redis缓存库存,然后……加个消息队列削峰……反正就是那一套。」

老张(眉头一皱):「别急着说"那一套",我们拆开细聊。」


问题4:Redis缓存穿透、击穿、雪崩

老张:「你说用Redis缓存库存。那我问你,在秒杀场景下,Redis可能会遇到缓存穿透、缓存击穿、缓存雪崩这三个问题。你分别解释一下是什么,以及怎么解决?」

牢大(擦了擦汗,这个他背过):「这个我知道!

缓存穿透是说查询一个根本不存在的数据,缓存里没有,数据库里也没有。比如有人拿一个不存在的商品ID来疯狂请求。解决方案有:布隆过滤器,把存在的商品ID都存进去,先过滤一遍;或者缓存空值,把null也缓存起来,设置短一点的过期时间。

缓存击穿是说某个热点数据过期了,大量请求同时打到数据库。比如秒杀商品恰好缓存过期。解决方案:互斥锁,让拿到锁的请求去查数据库更新缓存,其他的等着;或者逻辑过期,缓存不过期,用后台线程异步更新。

缓存雪崩是说大量缓存同时过期,或者Redis挂了。解决方案:过期时间加随机值打散;Redis集群做高可用;限流降级;还有多级缓存,本地Caffeine + Redis。」

老张(面色稍缓):「这三个概念区分得清楚。那你觉得秒杀场景下,哪个问题最需要关注?」

牢大:「应该是缓存击穿吧?因为秒杀商品是热点,一旦缓存过期就完蛋了。」

老张:「对。那你说互斥锁,具体怎么实现?」

牢大(开始含糊):「就……用Redis的setnx命令嘛,设置一个锁,拿到锁的进去查数据库,其他的自旋等待……」

老张(追问):「那锁的超时时间设多少?如果查数据库花了比超时时间更长的时间怎么办?锁被误删了怎么处理?」

牢大(额头冒汗):「呃……超时时间一般设个几秒吧……误删的话……可以用Redisson的看门狗机制,它会自动续期……还有释放锁的时候用Lua脚本判断一下是不是自己的锁……」

老张:「嗯,Redisson确实封装了这些。不过你自己实现过吗?」

牢大:「呃……项目里直接用Redisson,没自己写过……」

老张(不置可否):「行,下一个问题。」


问题5:Kafka在秒杀场景的应用

老张:「你说用消息队列削峰。假设我们用Kafka,秒杀成功的订单写到Kafka,后面慢慢消费落库。你跟我说说,怎么保证消息不丢?怎么保证消息不重复消费?」

牢大:「这个……消息不丢的话,Kafka有三层保证:

生产者端:acks=all,确保所有ISR副本都收到消息才返回成功;

Broker端:多副本机制,leader挂了follower能顶上;

消费者端:手动提交offset,处理完再提交,不要自动提交。

不重复消费的话……消费者要做幂等处理,比如用唯一键去重,或者Redis记录消费过的消息ID。」

老张(追问):「那如果消费者处理完消息,还没来得及提交offset就挂了,重启后会重复消费。你的幂等方案具体怎么实现?数据库唯一键的话,订单表用什么做唯一键?」

牢大(有点慌):「呃……用……用订单号做唯一键?秒杀的时候先生成订单号,然后写Kafka,消费者insert的时候如果唯一键冲突就跳过……」

老张:「那你订单号怎么生成的?雪花算法?如果分布式环境下机器时钟回拨了怎么办?」

牢大(彻底懵了):「雪花算法……时钟回拨……这个……可以……呃……用美团Leaf那种方案?或者……百度那个……呃……」

老张(叹了口气):「算了,这个问题先过。」


问题6:分布式锁与Redis集群

老张:「回到秒杀扣库存的问题。你说用Redis扣库存,如果Redis是哨兵模式或者Cluster模式,你用setnx做分布式锁会有什么问题?」

牢大:「呃……主从切换的时候,锁数据可能还没同步到从节点,导致锁丢失……所以要用RedLock算法?就是……在多个独立的Redis实例上同时加锁……」

老张:「你详细说下RedLock的流程。」

牢大(支支吾吾):「就是……向N个Redis实例……呃……获取锁,如果超过半数成功,就算获取成功……然后……呃……有个超时时间……」

老张(打断):「行了,我问你,RedLock在社区争议很大,你知道为什么吗?Redis的作者和分布式系统专家Martin Kleppmann还吵过架。」

牢大(完全放弃):「这个……我不太清楚……我就知道Redisson有实现……」

老张:「好吧。那换个问题,Redis Cluster模式下,分布式锁用Lua脚本扣库存,有什么坑?」

牢大:「呃……Lua脚本是原子性的……应该没问题吧?」

老张:「你确定?Redis Cluster的数据是分片存储的,如果你的Lua脚本操作了多个key,这些key不在同一个slot上,会怎样?」

牢大(恍然大悟又茫然):「哦对!会报错!Cross Slot……需要用hash tag把key映射到同一个slot……」

老张:「嗯,总算说对一个点。那你说hash tag怎么用?」

牢大:「就是……key里面用花括号,比如{product:123}:stock{product:123}:sold,这样Redis只对花括号里的部分计算hash,保证落在同一个slot。」

老张:「行,这个点算你过了。」


第二轮小结

老张内心:「Redis基础概念能说清楚,但深入一点就开始含糊。Kafka的幂等和分布式锁的实战细节明显不太熟练。所谓的"5年经验"水分不小。」

牢大内心:「完了完了,再问下去要露馅了……」


第三轮:架构设计——分布式与高可用

场景升级:全链路压测与降级

老张(合上简历,直视牢大):「最后一轮,我们聊点宏观的。假设秒杀系统已经上线了,但是压测发现QPS到了10万之后,系统响应变慢。你会从哪些维度去排查和优化?」

牢大(深呼吸):「呃……先看监控……Prometheus+Grafana看JVM的GC情况、CPU、内存……然后看数据库的慢查询……Redis的命中率……还有接口的耗时分布……」


问题7:分布式事务

老张:「秒杀成功后,你需要做三件事:扣库存、创建订单、扣减用户余额。这三个操作分布在三个微服务上。你怎么保证数据一致性?」

牢大:「用……用分布式事务?」

老张:「废话,我问你怎么实现。」

牢大:「呃……Seata?用AT模式……或者TCC模式……」

老张:「你说说Seata AT模式和TCC模式的区别,以及在秒杀场景下你选哪个?」

牢大(硬着头皮):「AT模式是……自动挡,对业务代码无侵入,通过undo_log来回滚。TCC是手动挡,需要自己实现Try、Confirm、Cancel三个接口……

秒杀场景……应该用TCC吧?因为AT模式有全局锁,并发性能不好……」

老张:「那你具体说说TCC的Try阶段在库存服务里怎么写?」

牢大:「Try阶段……先冻结库存,比如把可用库存减掉,加到冻结库存里……Confirm的时候把冻结库存扣掉……Cancel的时候把冻结库存加回去……」

老张:「那你考虑过空回滚和悬挂问题吗?」

牢大(一脸茫然):「空……空回滚?悬挂?」

老张(摇头):「TCC的两个经典问题。空回滚是Cancel先于Try到达,你要允许空回滚。悬挂是Try在Cancel之后到达,你要拒绝这个Try。这些Seata都帮你处理了,但你得知道这个概念。」

牢大:「哦哦……学到了……」


问题8:限流与熔断

老张:「秒杀场景下,如果流量远超预期,你怎么保护下游服务?」

牢大:「用Sentinel或者Resilience4j做限流熔断。比如用Sentinel的QPS限流,超过阈值就快速失败或者排队等待。熔断的话,如果下游服务的错误率超过阈值,就熔断一段时间,直接返回降级结果。」

老张:「Sentinel的滑动窗口算法和常见的固定窗口有什么区别?」

牢大:「固定窗口有临界突刺的问题,比如第59秒和第61秒各来一波流量,固定窗口可能都放过去了。滑动窗口把时间分成多个小格子,更平滑。」

老张:「不错。那如果你要自己实现一个限流器,你会用什么算法?」

牢大:「令牌桶或者漏桶。Guava的RateLimiter就是令牌桶,支持突发流量。漏桶的话……强行平滑,不支持突发。」

老张:「好,这个问题回答得还行。」


问题9:全链路压测与JVM调优

老张:「回到性能优化。压测发现Full GC频繁,你猜可能是什么原因?怎么排查?」

牢大:「可能是……堆内存设太小了?或者有内存泄漏?排查的话……先用jstat -gcutil看GC频率,然后用jmap dump导出堆文件,用MAT或者JProfiler分析……」

老张:「如果堆文件分析出来,发现有一个HashMap占了2G内存,里面全是用户Session对象,你怎么办?」

牢大:「那可能是……Session没设置过期时间?或者用户量太大?解决方案……用Redis存Session,不要放JVM内存里……或者用更高效的数据结构?」

老张:「还有呢?你项目里JVM参数一般怎么配的?」

牢大:「-Xms4g -Xmx4g,年轻代和老年代比例用-XX:NewRatio,GC算法用G1的话就-XX:+UseG1GC……还有-XX:MaxGCPauseMillis设个200ms……」

老张:「你了解G1的Mixed GC和Full GC的区别吗?」

牢大:「Mixed GC会回收年轻代和部分老年代……Full GC是整堆回收……G1的目标是尽量避免Full GC……」

老张(不再追问):「嗯……」


问题10:系统瓶颈与架构演进

老张:「最后一个问题。你们这个秒杀系统,从单体架构演进到微服务,再到云原生,你觉得每个阶段的瓶颈分别是什么?怎么突破的?」

牢大(彻底放飞自我):「呃……单体阶段瓶颈就是……单机性能不够了,就……加机器,搞集群……然后数据库扛不住了就分库分表……微服务阶段就是……服务拆分后引入了分布式事务和网络调用的问题,就……上各种中间件……云原生阶段就是……上Kubernetes,弹性伸缩……」

老张:「说得太笼统了。我问你,分库分表之后,跨库Join怎么处理?」

牢大:「呃……尽量避免跨库Join,用冗余字段或者……用Elasticsearch做宽表查询……」

老张:「那分库分表的Sharding Key怎么选?」

牢大:「一般选……用户ID或者订单ID……按hash取模……」

老张:「如果用订单ID做Sharding Key,那按用户ID查订单的时候怎么办?不是要走所有分表?」

牢大(满头大汗):「这个……可以建一个……用户ID到订单ID的映射表?或者……用ES做用户维度的订单索引……」

老张(合上笔记本):「差不多了。」


面试结束

老张站起身,表情恢复了职业化的微笑:「牢大,今天面试就到这里。你的Java基础还不错,Spring Boot和MySQL索引这块比较扎实。但分布式场景下的实战经验还有提升空间,尤其是分布式事务、分布式锁的细节,以及全链路压测的经验。」

牢大(强颜欢笑):「谢谢面试官,今天学到很多……」

老张:「嗯,你先回去等通知吧。我们评估完之后,HR会在3到5个工作日内联系你。」

牢大(心里咯噔一下):「好的好的,谢谢您!」

牢大走出会议室,掏出手机百度:「面试说3到5个工作日是不是凉了?」

百度第一条:「面试官说等通知,99%是没戏了。」

牢大:「……」



📚 面试问题详细解析(小白学习版)

以下是本次面试中所有技术问题的详细解答,适合正在准备Java面试的同学系统学习。


一、HashMap底层原理与线程安全

1.1 HashMap的数据结构

JDK 8中HashMap采用数组 + 链表 + 红黑树的数据结构:

  • 数组:也称为哈希桶(bucket),每个bucket对应一个链表或红黑树的头节点
  • 链表:当发生hash冲突时,采用链地址法,将冲突的节点串成链表
  • 红黑树:当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转化为红黑树,提升查询效率
HashMap结构示意:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ ... │ n │  ← 数组(bucket)
└─┬─┴───┴─┬─┴───┴───┴───┴───┴───┘
  │       │
  ▼       ▼
┌───┐   ┌───┐   ← 链表节点
│ K │   │ K │
├───┤   ├───┤
│ V │   │ V │
├───┤   ├───┤
│next──→ │next──→ ...  (长度>=8时转红黑树)
└───┘   └───┘

1.2 为什么链表转红黑树的阈值是8?

根据泊松分布,当负载因子为0.75时,链表长度达到8的概率极低(约0.00000006),这说明hash函数设计得足够好时,几乎不会出现链表过长的情况。如果真的到了8,那很可能是hash碰撞严重,转为红黑树是合理的止损手段。

1.3 ConcurrentHashMap原理

JDK 8中ConcurrentHashMap放弃了分段锁(Segment),改用CAS + synchronized

  • put操作:先通过CAS尝试插入,如果该bucket为空,CAS插入成功;如果不为空,对bucket头节点加synchronized锁,然后插入链表或红黑树
  • size():使用baseCount + CounterCell[]统计,减少竞争。put时先CAS更新baseCount,失败则随机选一个CounterCell累加
  • get操作:全程无锁,因为Node的val和next都是volatile修饰的
// ConcurrentHashMap size统计的简化原理
private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;

// put时累加计数
final void addCount(long x, int check) {
    // 先尝试CAS更新baseCount
    if (!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        // 失败则更新CounterCell
        CounterCell[] as = counterCells;
        // ... 随机选一个CounterCell累加
    }
}

// size()汇总
public int size() {
    long n = baseCount;
    for (CounterCell cell : counterCells) {
        if (cell != null) n += cell.value;
    }
    return (int)n;
}

二、Spring Boot自动配置原理

2.1 自动配置核心流程

@SpringBootApplication
    │
    └── @EnableAutoConfiguration
            │
            └── @Import(AutoConfigurationImportSelector.class)
                    │
                    └── 读取 META-INF/spring/...imports 文件
                            │
                            └── 加载所有自动配置类
                                    │
                                    └── @Conditional条件判断
                                            │
                                            ├── 满足 → 创建Bean
                                            └── 不满足 → 跳过

2.2 核心注解说明

| 注解 | 作用 | |------|------| | @ConditionalOnClass | 类路径存在指定类时生效 | | @ConditionalOnMissingBean | 容器中不存在指定Bean时生效 | | @ConditionalOnProperty | 配置文件中存在指定属性时生效 | | @ConditionalOnBean | 容器中存在指定Bean时生效 | | @ConditionalOnMissingClass | 类路径不存在指定类时生效 |

2.3 自定义Starter示例

my-starter/
├── my-spring-boot-starter/          # starter模块(空项目,只放POM)
│   └── pom.xml
└── my-spring-boot-autoconfigure/    # 自动配置模块
    ├── pom.xml
    └── src/main/
        ├── java/
        │   └── com/example/
        │       ├── MyAutoConfiguration.java
        │       └── MyProperties.java
        └── resources/
            └── META-INF/
                └── spring/
                    └── org.springframework.boot.autoconfigure.AutoConfiguration.imports
// MyProperties.java - 配置属性类
@ConfigurationProperties(prefix = "my.config")
public class MyProperties {
    private String name = "default";
    private int timeout = 3000;
    // getter/setter...
}

// MyAutoConfiguration.java - 自动配置类
@Configuration
@EnableConfigurationProperties(MyProperties.class)
@ConditionalOnClass(MyService.class)
public class MyAutoConfiguration {
    
    @Bean
    @ConditionalOnMissingBean
    public MyService myService(MyProperties properties) {
        return new MyService(properties.getName(), properties.getTimeout());
    }
}
// AutoConfiguration.imports文件内容
com.example.MyAutoConfiguration

三、MySQL联合索引与优化

3.1 联合索引的最左前缀原则

对于联合索引(A, B, C)

| 查询条件 | 是否走索引 | 说明 | |----------|-----------|------| | WHERE A = 1 | ✅ | 使用A列 | | WHERE A = 1 AND B = 2 | ✅ | 使用A+B列 | | WHERE A = 1 AND B = 2 AND C = 3 | ✅ | 完整使用 | | WHERE B = 2 | ❌ | 跳过A,不走索引 | | WHERE A = 1 AND C = 3 | ⚠️ | 只用A列,C列不走 | | WHERE A > 1 AND B = 2 | ⚠️ | 只用A列,范围查询中断 |

3.2 Using filesort vs Using index

  • Using filesort:MySQL需要额外排序,性能差。发生在ORDER BY的字段没有走索引排序时
  • Using index:覆盖索引,查询的列都在索引中,不需要回表
  • Using index condition:使用了ICP(Index Condition Pushdown),索引条件下推

3.3 实战建索引示例

-- 商品表
CREATE TABLE product (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(200),
    category_id INT,
    price DECIMAL(10,2),
    status TINYINT,  -- 0下架 1上架
    create_time DATETIME,
    INDEX idx_cat_status_time (category_id, status, create_time)
);

-- 这个查询完美走索引
EXPLAIN SELECT * FROM product 
WHERE category_id = 5 AND status = 1 
ORDER BY create_time DESC 
LIMIT 20;

-- Extra: Using index condition  ← 说明走了ICP

四、Redis缓存三大问题详解

4.1 缓存穿透

问题:查询不存在的数据,缓存和数据库都没有,大量请求直接打到数据库。

解决方案

  1. 布隆过滤器
// 使用Redisson的布隆过滤器
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("productFilter");
bloomFilter.tryInit(1000000L, 0.03); // 预计100万数据,误判率3%
bloomFilter.add("product:123"); // 初始化时把所有商品ID加入

// 查询前先判断
if (!bloomFilter.contains("product:999")) {
    return null; // 一定不存在,直接返回
}
  1. 缓存空值
public Product getProduct(Long id) {
    String key = "product:" + id;
    Product product = redisTemplate.opsForValue().get(key);
    
    if (product != null) {
        if (product.getId() == -1) return null; // 空值标记
        return product;
    }
    
    product = db.findById(id);
    if (product == null) {
        // 缓存空值,过期时间短一些
        redisTemplate.opsForValue().set(key, new Product(-1), 60, TimeUnit.SECONDS);
    } else {
        redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
    }
    return product;
}

4.2 缓存击穿

问题:热点key过期,大量请求同时打到数据库。

解决方案——互斥锁

public Product getProductWithLock(Long id) {
    String key = "product:" + id;
    Product product = redisTemplate.opsForValue().get(key);
    if (product != null) return product;
    
    String lockKey = "lock:product:" + id;
    try {
        // 尝试获取锁,设置超时时间防止死锁
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
        
        if (Boolean.TRUE.equals(locked)) {
            // 双重检查
            product = redisTemplate.opsForValue().get(key);
            if (product != null) return product;
            
            // 查数据库
            product = db.findById(id);
            redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
        } else {
            // 没拿到锁,自旋等待
            Thread.sleep(50);
            return getProductWithLock(id); // 递归重试
        }
    } finally {
        redisTemplate.delete(lockKey);
    }
    return product;
}

4.3 缓存雪崩

问题:大量缓存同时过期,或Redis宕机。

解决方案

  1. 过期时间加随机值:expire = baseExpire + random(0, 300)
  2. Redis集群(哨兵/Cluster)做高可用
  3. 多级缓存:Caffeine(本地) + Redis(远程)
  4. 限流降级:Sentinel/Resilience4j

五、Kafka消息可靠性保证

5.1 消息不丢失的三层保证

生产者端                    Broker端                   消费者端
───────                   ────────                  ────────
acks=all    ────────►    多副本 + ISR   ────────►   手动提交offset
retries>0                min.insync.replicas=2      处理完再commit
max.in.flight.requests.per.connection=1(有序性)

5.2 生产者配置

# 生产者关键配置
acks=all                              # 所有ISR副本确认
retries=3                             # 重试次数
max.in.flight.requests.per.connection=1  # 保证顺序(Kafka 0.11+可用幂等替代)
enable.idempotence=true               # 幂等生产者

5.3 消费者幂等处理

@KafkaListener(topics = "seckill-order")
public void consume(ConsumerRecord<String, String> record) {
    String orderId = record.key();
    
    // 方案1:数据库唯一键
    try {
        orderService.insert(order); // 唯一键冲突则跳过
    } catch (DuplicateKeyException e) {
        log.warn("重复订单: {}", orderId);
        // 手动提交offset,继续消费
        return;
    }
    
    // 方案2:Redis记录消费过的消息
    Boolean success = redisTemplate.opsForValue()
        .setIfAbsent("consumed:" + orderId, "1", 24, TimeUnit.HOURS);
    if (Boolean.FALSE.equals(success)) {
        return; // 已消费过
    }
    // 处理业务...
}

5.4 雪花算法与时钟回拨

雪花算法(Snowflake)结构:

┌─────┬──────────┬──────────┬────────────┐
│ 1bit│  41bit   │  10bit   │   12bit    │
│ 保留 │ 时间戳ms │ 机器ID   │ 序列号     │
└─────┴──────────┴──────────┴────────────┘

时钟回拨的解决方案:

  • 百度UID Generator:使用ringbuffer预生成
  • 美团Leaf:Segment模式(号段)+ Snowflake模式,依赖ZooKeeper
  • 简单方案:检测到回拨时等待或抛异常,记录上次时间戳

六、分布式锁详解

6.1 Redis分布式锁的演进

V1:简单版(有bug)

// ❌ 问题:setnx和expire不是原子操作,可能死锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1");
redisTemplate.expire("lock", 10, TimeUnit.SECONDS);

V2:原子版

// ✅ setIfAbsent同时设置过期时间
Boolean lock = redisTemplate.opsForValue()
    .setIfAbsent("lock", "unique_value", 10, TimeUnit.SECONDS);

V3:防误删版(Lua脚本)

-- 释放锁时判断是否是自己的锁
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

V4:Redisson(企业级)

RLock lock = redissonClient.getLock("seckill:product:123");
try {
    // 尝试加锁,最多等待10秒,锁30秒后自动释放
    if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
        // 业务逻辑(看门狗会自动续期)
    }
} finally {
    lock.unlock();
}

6.2 RedLock算法

RedLock流程:
1. 客户端获取当前时间 T1
2. 依次向 N 个独立的Redis实例获取锁(SET NX PX)
3. 获取当前时间 T2
4. 如果获取锁的实例数 >= N/2+1,且 T2-T1 < 锁超时时间,则获取成功
5. 否则,向所有实例释放锁

争议点:Martin Kleppmann认为RedLock依赖时钟同步,在GC停顿或时钟跳跃时可能不安全。Redis作者antirez则反驳认为在真实系统中这些问题被夸大了。

6.3 Redis Cluster的Hash Tag

// ❌ 错误:不同key可能在不同slot
String stockKey = "product:123:stock";
String soldKey = "product:123:sold";

// Lua脚本操作两个key → 可能报错 CROSSSLOT Keys in request don't hash to the same slot

// ✅ 正确:使用hash tag
String stockKey = "{product:123}:stock";
String soldKey = "{product:123}:sold";
// Redis只对 {product:123} 部分计算hash,保证在同一个slot

七、分布式事务——Seata

7.1 AT模式 vs TCC模式

| 特性 | AT模式 | TCC模式 | |------|--------|---------| | 侵入性 | 低(自动) | 高(手动实现三个接口) | | 性能 | 一般(有全局锁) | 高(无锁) | | 适用场景 | 一般业务 | 高并发/核心业务 | | 回滚方式 | 自动undo_log | 手动Cancel |

7.2 TCC模式实现示例

// 库存服务 - TCC实现
@Service
public class StockTccService {
    
    // Try阶段:冻结库存
    public boolean tryDeduct(Long productId, int count) {
        // UPDATE stock SET available = available - ?, frozen = frozen + ?
        // WHERE product_id = ? AND available >= ?
        int rows = stockMapper.freeze(productId, count);
        return rows > 0;
    }
    
    // Confirm阶段:确认扣减
    public boolean confirmDeduct(Long productId, int count) {
        // UPDATE stock SET frozen = frozen - ? WHERE product_id = ?
        stockMapper.confirm(productId, count);
        return true;
    }
    
    // Cancel阶段:解冻
    public boolean cancelDeduct(Long productId, int count) {
        // UPDATE stock SET available = available + ?, frozen = frozen - ?
        stockMapper.cancel(productId, count);
        return true;
    }
}

7.3 空回滚和悬挂

  • 空回滚:Cancel先于Try到达(可能因为Try超时触发了Cancel)。此时Cancel应该允许"空回滚",不做任何操作或记录一个标记
  • 悬挂:Try在Cancel之后到达。此时Try应该被拒绝,因为事务已经取消

Seata框架已经处理了这两个问题,但开发者需要理解这些概念。


八、限流算法对比

8.1 四种限流算法

| 算法 | 原理 | 优点 | 缺点 | |------|------|------|------| | 固定窗口 | 每个时间窗口内计数 | 简单 | 临界突刺 | | 滑动窗口 | 窗口细分为多个小格 | 平滑 | 内存占用稍高 | | 漏桶 | 固定速率流出 | 严格平滑 | 无法应对突发 | | 令牌桶 | 固定速率放入令牌 | 允许突发 | 实现稍复杂 |

8.2 Guava RateLimiter示例

// 令牌桶 - 每秒100个令牌,允许突发
RateLimiter limiter = RateLimiter.create(100);

// 阻塞获取
limiter.acquire();

// 非阻塞获取
if (limiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
    // 执行业务
} else {
    // 降级处理
}

8.3 Sentinel vs Hystrix vs Resilience4j

| 特性 | Sentinel | Hystrix | Resilience4j | |------|----------|---------|--------------| | 状态 | 活跃维护 | 停更 | 活跃维护 | | 限流 | ✅ 丰富 | 基本 | ✅ | | 熔断 | ✅ | ✅ | ✅ | | 控制台 | ✅ 完善 | Dashboard | 需集成 | | 规则动态更新 | ✅ | ❌ | 需自行实现 |


九、JVM调优实战

9.1 常用JVM参数

# 堆内存设置
-Xms4g -Xmx4g                      # 初始堆=最大堆,避免动态扩容
-XX:NewRatio=2                     # 老年代:年轻代 = 2:1
-XX:SurvivorRatio=8                # Eden:S0:S1 = 8:1:1

# G1 GC
-XX:+UseG1GC                       # 使用G1垃圾收集器
-XX:MaxGCPauseMillis=200           # 期望最大GC停顿时间
-XX:G1HeapRegionSize=4m            # G1 Region大小
-XX:InitiatingHeapOccupancyPercent=45  # 触发Mixed GC的堆占用阈值

# 内存溢出dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof

# GC日志
-Xlog:gc*:file=/tmp/gc.log:time

9.2 G1 GC的几种GC类型

| GC类型 | 回收区域 | 触发条件 | |--------|---------|---------| | Young GC | Eden + Survivor | Eden满 | | Mixed GC | Young + 部分Old | IHOP阈值触发 | | Full GC | 整个堆 | Mixed GC跟不上分配速度 |

9.3 排查内存问题常用命令

# 查看GC统计
jstat -gcutil <pid> 1000 10

# 导出堆快照
jmap -dump:format=b,file=heap.hprof <pid>

# 查看线程堆栈
jstack <pid> > thread.txt

# 查看JVM参数
jinfo -flags <pid>

十、分库分表架构

10.1 Sharding策略

水平分库分表示意:

订单表 order 按 order_id 分片(16库,每库16表)

┌─────────────────────────────────────────────┐
│                   应用层                      │
│              Sharding-JDBC                    │
└──────┬──────┬──────┬──────┬─────────────────┘
       │      │      │      │
    ds0     ds1    ds2   ...   ds15    ← 16个数据库
       │      │      │      │
    order_0  order_0  ...   order_0
    order_1  order_1  ...   order_1
    ...      ...      ...   ...
    order_15 order_15 ...   order_15  ← 每个库16张表

10.2 Sharding Key选择

| Sharding Key | 优点 | 缺点 | |--------------|------|------| | 订单ID | 均匀分布 | 按用户查需全表扫描 | | 用户ID | 用户维度查询方便 | 热点用户数据倾斜 | | 时间 | 范围查询方便 | 新数据集中,旧数据冷 |

解决跨维度查询的方案

  1. ES异构索引:同时写入ES,按用户ID查询走ES拿到订单ID,再回表
  2. 基因法:订单ID中嵌入用户ID的hash值
  3. 映射表:维护 user_id → order_id 的映射关系

10.3 分库分表中间件

  • ShardingSphere-JDBC:客户端分片,无网络开销
  • ShardingSphere-Proxy:代理层分片,支持多语言
  • MyCat:老牌中间件
  • Vitess:YouTube开源,云原生

🎯 总结:大厂Java面试考察维度

通过这次面试可以看出,大厂面试主要考察以下几个维度:

  1. 基础扎实度(30%):Java核心、集合、JVM、并发编程
  2. 框架理解深度(25%):Spring Boot/Cloud原理,不只是会用
  3. 分布式实战能力(25%):缓存、消息队列、分布式事务、锁
  4. 系统设计能力(15%):架构演进、性能优化、问题排查
  5. 学习能力与潜力(5%):对新技术的好奇心和理解力

给牢大的建议:八股文只能帮你过第一轮,真正的大厂面试官会层层深入追问。与其死记硬背,不如动手实践,把每个技术点都亲自实现一遍。


本文纯属虚构,如有雷同,说明你也遇到过这样的面试官。

Logo

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

更多推荐