Java大厂面试官VS水货程序员「牢大」:电商秒杀场景下微服务架构与Redis缓存实战面试实录
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 缓存穿透
问题:查询不存在的数据,缓存和数据库都没有,大量请求直接打到数据库。
解决方案:
- 布隆过滤器
// 使用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; // 一定不存在,直接返回
}
- 缓存空值
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宕机。
解决方案:
- 过期时间加随机值:
expire = baseExpire + random(0, 300)秒 - Redis集群(哨兵/Cluster)做高可用
- 多级缓存:Caffeine(本地) + Redis(远程)
- 限流降级: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 | 用户维度查询方便 | 热点用户数据倾斜 | | 时间 | 范围查询方便 | 新数据集中,旧数据冷 |
解决跨维度查询的方案:
- ES异构索引:同时写入ES,按用户ID查询走ES拿到订单ID,再回表
- 基因法:订单ID中嵌入用户ID的hash值
- 映射表:维护 user_id → order_id 的映射关系
10.3 分库分表中间件
- ShardingSphere-JDBC:客户端分片,无网络开销
- ShardingSphere-Proxy:代理层分片,支持多语言
- MyCat:老牌中间件
- Vitess:YouTube开源,云原生
🎯 总结:大厂Java面试考察维度
通过这次面试可以看出,大厂面试主要考察以下几个维度:
- 基础扎实度(30%):Java核心、集合、JVM、并发编程
- 框架理解深度(25%):Spring Boot/Cloud原理,不只是会用
- 分布式实战能力(25%):缓存、消息队列、分布式事务、锁
- 系统设计能力(15%):架构演进、性能优化、问题排查
- 学习能力与潜力(5%):对新技术的好奇心和理解力
给牢大的建议:八股文只能帮你过第一轮,真正的大厂面试官会层层深入追问。与其死记硬背,不如动手实践,把每个技术点都亲自实现一遍。
本文纯属虚构,如有雷同,说明你也遇到过这样的面试官。
更多推荐



所有评论(0)