Java后端面试实战:用企业级项目讲透分布式锁与缓存一致性
想靠背八股文拿20Koffer这条路现在越来越走不通了。我在面试候选人的时候经常碰到这样的情况问“什么是CAS”背得滚瓜烂熟问“你项目里哪里用了CAS不用会怎样”立刻卡壳。面试官真正想看的从来不是你会背什么而是你有没有在一个真实的企业级项目里把那些考点用出来、踩过坑、做出取舍。这篇文章就是来聊这件事的一套能覆盖大部分面试官常问考点的Java企业级项目实战应该怎么做、怎么讲以及为什么它比刷一百道八股文都管用。1. 为什么你刷了三个月八股文还是倒在20K的门口先说个我亲眼见过的案例。之前团队招Java高级开发有个候选人简历很漂亮技术栈写了Spring Cloud、Redis、MQ、分库分表一看就是精心准备过的。前两轮笔试做得也不错到了我这一轮我问了他一个很基础的问题“你们项目里Redis都用来做什么有没有遇到缓存和数据库不一致的情况”他愣了几秒然后开始背缓存穿透、击穿、雪崩的定义背得一字不差但问到“你们用的是Cache Aside Pattern还是Read Through一致性问题你们最终怎么抗的”就彻底沉默了。最后他承认那个项目是付费培训班的demoRedis只是用来存了个验证码。这就是问题所在。企业要的是能解决实际问题的人不是复读机。你背的那些考点只有放进真实的业务场景里才有意义。1.1 面试官考察的核心逻辑场景、取舍、复盘20K的Java岗位面试官考察的底层逻辑只有三个词场景、取舍、复盘。场景这个技术在你的项目里解决什么问题别跟我说Redis快我问的是你用它扛住了什么流量存了什么数据为什么不用本地缓存为什么不用数据库直接查。取舍你用了A方案为什么不用B方案代价是什么比如你用了分布式锁为什么不用synchronized用了Seata AT模式为什么不用TCC这些问题没有标准答案但能看出你是否有技术判断力。复盘项目上线后出了什么问题你怎么排查的别跟我说一次上线就成功那是你运气好不是你有能力。所以一套真正能帮到你面试的企业级项目必须满足三个条件业务足够典型能让面试官自然地往考点上引、技术栈足够主流Spring Boot Redis MQ MySQL 是最低配置、坑足够真实每个考点都对应一个你确实踩过的坑讲出来才有说服力。这也是我为什么推荐用“电商秒杀订单支付”这条业务线来串项目。这套场景几乎能覆盖面试中80%的高频考点高并发、缓存、消息队列、分布式锁、分布式事务、幂等性、JVM调优、索引优化……每个考点都有天然的业务落点面试官问到哪个点你都能从项目里拿出对应的实践来讲。2. 项目架构先想清楚别再拿微服务当简历摆设很多人在写企业级项目时有个误区一上来就Spring Cloud全家桶Nacos、Gateway、OpenFeign、Sentinel全上。结果面试官问“你们为什么拆分微服务拆分后带来了什么收益”答不上来。这里我要说实话面试官不怕你用单体怕的是你用微服务却说不清为什么。很多人用微服务纯粹是因为招聘要求里写了“熟悉微服务”但企业级项目的核心从来不是架构有多炫而是业务有多稳、扩展有多顺。2.1 业务的出发点是单体架构微服务是被逼出来的我建议你做项目的路径是先用单体架构把业务完整跑通再根据业务痛点逐步抽出微服务。这样面试时你就能说出一个完整的演进逻辑而不是一上来就“我们用了微服务”。举个例子秒杀系统的核心链路用户下单 - 扣库存 - 创建订单 - 支付回调 - 更新订单状态。这个链路在一开始用单体架构完全能跑所有模块在一个应用里本地事务保证一致性开发调试都简单。但当秒杀开始后流量暴涨你会发现几个痛点商品详情页的读请求大量打到数据库需要引入Redis做缓存下单请求集中在瞬间需要引入消息队列做削峰填谷库存扣减并发冲突严重需要引入分布式锁。这些痛点本身就是架构演进的理由也是面试官最爱听的“取舍”。等到业务规模再大比如订单服务和用户服务都需要独立扩容各团队要独立迭代这时候才值得拆微服务。按照这个思路走下来架构演进是有逻辑的每一步都有明确原因面试官听到的是你“做过决策”而不是“用过框架”。2.2 核心模块设计每一个都踩在考点上这套项目我建议至少包含以下模块每个模块对应的高频考点我列个表给你参考模块核心功能覆盖的高频考点用户模块注册、登录、Token鉴权Spring Security/OAuth2/JWT、ThreadLocal、拦截器商品模块商品列表、详情、库存管理Redis缓存、缓存穿透/击穿/雪崩、索引优化秒杀模块秒杀活动、限购、抢购分布式锁、乐观锁、MQ削峰、幂等性设计订单模块下单、订单状态流转、超时关闭分布式事务、状态机、延迟队列、死信队列支付模块支付回调、退款、对账幂等性、MQ可靠消息、分布式事务搜索模块商品搜索、筛选、排序Elasticsearch、倒排索引、分词这六个模块做下来你基本上把Java后端的主流考点覆盖齐全了。而且每个模块之间都有业务关联不是孤立的demo用户下单先查商品缓存秒杀校验后发MQ订单服务消费MQ创建订单支付回调后更新状态搜索服务同步商品数据。整条链路串起来整个项目的业务深度和完整度就出来了。3. 核心难点一库存扣减从JVM锁到分布式锁的演化库存扣减是秒杀系统的核心难点也是面试官几乎必问的考点。我先说一个很多项目里常见的错误写法直接用synchronized锁住扣库存的方法。单体部署时好像没问题一旦横向扩展成多个实例每个实例有各自的锁超卖立刻就出现了。这个问题的本质是JVM级别的锁只能管住单个进程管不住多个进程之间的并发。所以必须引入跨进程的锁也就是分布式锁。3.1 Redis分布式锁的正确姿势从setnx到RedissonRedis分布式锁最基础的实现是SET key value NX PX 30000这行命令可以保证原子性地“只有key不存在时才能设置成功”并自动设置过期时间。但你自己手写这个逻辑容易踩坑比如锁超时后业务还没执行完另一个线程就能拿到锁导致并发问题比如释放锁时没判断是不是自己的锁把别人的锁释放了。所以我建议直接用Redisson的RLock它内部用的是Lua脚本保证加锁和释放锁的原子性还实现了可重入和看门狗自动续期机制。// Redisson 分布式锁的标准用法 RLock lock redissonClient.getLock(stock_lock_ skuId); try { // 尝试加锁最多等待3秒锁有效期默认30秒看门狗会自动续期 if (lock.tryLock(3, TimeUnit.SECONDS)) { // 1. 校验库存是否充足 int stock getStockFromRedis(skuId); if (stock 1) { throw new BizException(库存不足); } // 2. 扣减库存先减Redis缓存再异步同步DB decrStockFromRedis(skuId); // 3. 发送MQ消息异步创建订单 mqSender.sendOrderMessage(buildOrderMessage(skuId, userId)); } else { throw new BizException(系统繁忙请稍后再试); } } finally { // 注意只有当前线程持有锁时才释放Redisson内部已经做了判断 if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这里有个面试时一定要主动讲出来的点为什么扣减库存先操作Redis再异步同步DB因为秒杀场景下数据库抗不住瞬时流量必须先让Redis挡住绝大部分读和写然后通过MQ慢慢把数据同步到数据库做最终一致。面试官听到你用“最终一致性”这个词就会知道你理解了这个场景的本质。3.2 乐观锁兜底DB层防止超卖的最后一堵墙分布式锁能挡住绝大多并发但极端情况下锁超时或者Redis宕机DB层面的校验就是最后防线。这里用的是乐观锁更新库存时加一个版本号或库存条件判断。-- 乐观锁扣减库存库存大于0才更新 UPDATE stock SET stock stock - 1, version version 1 WHERE sku_id #{skuId} AND stock 0; -- 返回受影响行数为1表示扣减成功为0表示库存不足或并发冲突这条SQL看起来简单但里面藏着一个高频追问为什么UPDATE语句自带原子性不需要额外加锁因为InnoDB在执行UPDATE时会对命中的行加行级排他锁多个并发UPDATE同一行时会被引擎层串行化所以stock 0这个条件就保证了不会扣成负数。理解了这一点你就能回答“乐观锁是不是完全不用锁”这类陷阱题。4. 核心难点二缓存与数据库的一致性如何选择取舍缓存和数据库的一致性问题是另一座大山。先说我看到过的错误做法先删缓存再更新数据库。结果删除缓存后、更新DB前来了一个读请求把旧数据缓存进去了等DB更新完缓存里还是旧值一致性永远恢复不了。4.1 Cache Aside Pattern 延迟双删能解决大部分场景业界最常用的Cache Aside Pattern做法是读的时候先读缓存读不到再读DB并回填缓存写的时候先更新DB再删除缓存。为什么是删除缓存而不是更新缓存因为更新缓存有并发问题两个线程同时更新DB后更新的反而先写缓存缓存里就是旧值。删除缓存则简单粗暴下次读的时候自然会把最新值load进来。但这样还有个空窗期更新DB成功后删除缓存失败怎么办所以有个叫“延迟双删”的优化方案// 延迟双删的核心思路 public void updateSkuStock(Long skuId, Integer stock) { // 1. 先删除缓存 redisTemplate.delete(sku_stock_ skuId); // 2. 更新数据库 skuStockMapper.updateStock(skuId, stock); // 3. 延迟500ms再次删除缓存把更新DB期间被读请求回填的旧缓存清掉 executorService.schedule(() - redisTemplate.delete(sku_stock_ skuId), 500, TimeUnit.MILLISECONDS); }面试时你要主动讲清楚延迟双删的代价更新DB和第二次删缓存不是原子的极端情况下还是可能短暂不一致所以它只适合允许秒级短暂不一致的业务。如果是强一致场景比如账户余额就别用缓存直接查DB。4.2 缓存雪崩、穿透、击穿从“知道概念”到“会解决”这三个概念是八股文重灾区几乎人人会背但能不能结合项目讲出解决手段差别很大。我把它们放在一张表里对比这样你复习和面试时都能很快理清思路问题现象解决方案项目里的实际做法穿透查一个不存在的key每次都打到DB布隆过滤器、缓存空值用布隆过滤器拦截黑名单和无效商品ID击穿某个热点key过期瞬间大量请求打到DB互斥锁、逻辑过期秒杀商品详情加互斥锁只放一个线程去查DB重建缓存雪崩大量key同时过期DB压力骤增过期时间加随机值、多级缓存key过期时间设为 base random(0, 300) 秒我在项目里解决缓存穿透的实现是先查布隆过滤器判断商品ID是否存在不存在直接返回把“查DB拿不到数据”的结果也缓存60秒防止恶意攻击用不存在的ID反复打DB。// 布隆过滤器判断商品ID是否存在 public SkuDetail getSkuDetailFromCache(Long skuId) { // 1. 布隆过滤器快速判断不存在的直接返回 if (!bloomFilter.mightContain(skuId)) { return null; } // 2. 缓存优先 String json redisTemplate.opsForValue().get(sku_detail_ skuId); if (StringUtils.isNotBlank(json)) { return JSON.parseObject(json, SkuDetail.class); } // 3. 加互斥锁防止击穿 String lockKey sku_detail_lock_ skuId; boolean locked tryLock(lockKey, 3, TimeUnit.SECONDS); if (!locked) { // 没拿到锁就稍后重试这里用短暂sleep避免空转 Thread.sleep(100); return getSkuDetailFromCache(skuId); } try { // 双检拿到锁后再查一次缓存 json redisTemplate.opsForValue().get(sku_detail_ skuId); if (StringUtils.isNotBlank(json)) { return JSON.parseObject(json, SkuDetail.class); } // 4. 查数据库并回填缓存设置随机过期时间避免雪崩 SkuDetail detail skuDetailMapper.selectById(skuId); if (detail ! null) { int expireTime 1800 new Random().nextInt(300); redisTemplate.opsForValue().set(sku_detail_ skuId, JSON.toJSONString(detail), expireTime, TimeUnit.SECONDS); } return detail; } finally { releaseLock(lockKey); } }这一段代码值得你反复看几遍因为它在20行里同时处理了穿透、击穿、雪崩三个问题。面试时你能讲出这段逻辑效果远好于背诵三条定义。5. 核心难点三分布式场景下的订单与支付难点全在一致性订单系统和支付系统是最能体现“企业级”这个词难度的模块。单体架构下用本地事务就够一旦拆成服务或引入MQ保证数据一致性就成了技术深水区。5.1 订单超时关闭延迟队列与死信队列的实战对比用户下单后如果15分钟内不支付订单要自动关闭、库存要回滚。这个“延迟任务”几乎是必考的经典场景实现方式也五花八门定时轮询、JDK延迟队列、RocketMQ延迟消息、RabbitMQ死信队列。我直接说结论高并发场景下别用定时轮询延迟大而且对DB压力大优先用RocketMQ的延迟消息配置简单、精度够用。RocketMQ支持预设的延迟级别比如18代表18个级别中的第18级也就是延迟2分钟。如果要15分钟可以在Broker端调整messageDelayLevel配置或者直接用4.x/5.x版本中可自定义延迟时间的接口。// RocketMQ 延迟消息发送订单超时消息 Message message new Message(ORDER_TIMEOUT_TOPIC, orderId.getBytes(StandardCharsets.UTF_8)); // 设置延迟级别18表示延迟2分钟对应messageDelayLevel配置 message.setDelayTimeLevel(18); SendResult sendResult producer.send(message);消费端收到消息后先去查订单状态如果还是“待支付”就更新为“已关闭”并通过MQ通知库存服务回滚库存。这里有个容易踩的坑消费端一定要做幂等因为MQ消费有“至少一次”的投递语义消息可能重复到达处理前先查订单状态就能保证只处理一次。5.2 支付回调的幂等与分布式事务方案选型支付回调是另一个必考点。支付宝或微信支付服务器回调你的接口因为网络原因可能重试多次如果你不做幂等用户付一次款却给用户账户加两次余额后果就很严重。幂等最简单的做法是用“业务唯一键状态判断”// 支付回调处理的幂等实现 Transactional public void handlePayCallback(PayCallbackRequest request) { // 1. 以订单号作为唯一业务键查是否已处理过 PayRecord record payRecordMapper.selectByOrderId(request.getOrderId()); if (record ! null SUCCESS.equals(record.getStatus())) { // 已经处理过直接返回 return; } // 2. 更新支付记录状态 payRecordMapper.updateStatus(request.getOrderId(), PAID); // 3. 更新订单状态 orderMapper.updateStatus(request.getOrderId(), PAID); // 4. 发消息通知其他服务 mqSender.sendOrderPaidMessage(request.getOrderId()); }再往深一层就是分布式事务。面试官大概率会问订单服务和支付服务跨越两个库怎么保证要么都成功、要么都回滚业界常用方案有几个Seata AT模式、TCC模式、本地消息表、MQ事务消息。我对这几个方案的使用建议是本地消息表 MQ事务消息最通用、侵入最小适合大多数异步场景我建议项目里主用这个。Seata AT模式适合团队愿意引入额外组件、对强一致要求高的内部系统但要注意它在高并发下性能损失。TCC模式适合资金类核心链路但代码侵入大需要业务表提供Try/Confirm/Cancel三个操作。用MQ事务消息实现订单创建和库存扣减的最终一致性核心逻辑是这样的先发一条半消息RocketMQ术语叫half message然后执行本地事务创建订单本地事务执行成功后去提交消息如果本地事务失败就回滚消息。下游库存服务收到“订单创建成功”消息后扣减库存。如果库存扣减失败虽然概率很低可以通过定时对账任务发现不一致并修复。6. 不只背代码这些软技能决定了你面试的上限项目技术搞定了最后还要说说怎么“讲”。我在前面说了20K的岗位考的是场景、取舍、复盘这意味着你的表达方式比你的代码能力更容易被面试官感知。6.1 讲项目的正确顺序背景 - 方案 - 难点 - 数据我建议你在准备项目介绍时按“背景-方案-难点-数据”四步来组织。背景就是你所在的业务是什么方案是你用的技术架构难点是你解决了什么非教科书问题比如“秒杀扣库存时如何防超卖”“支付回调解耦出来之后如何保证一致性”数据一定要量化不能只说“不错”“有提升”。比如你可以说“引入Redis缓存后商品详情接口的TP99从500ms降到20msDB的QPS从3000降到300。”有数据面试官才能评估你的能力边界。6.2 遇到不会的问题别慌也别装没有谁的知识边界能覆盖面试官所有问题。遇到不会的最好的策略是坦诚说“这个点我之前没深入了解过”然后补一句“按照我对这块的理解大概是……”。这个反应模式体现的是一种技术工作者非常重要的能力——不确定性下的推断能力。面试官要的不是你什么都能答对而是你面对未知时能不能冷静地做合理分析。我做技术面试官这几年最满意的候选人不是那些全都答对的人而是一个数据库问题没答上来、但主动说“这块我没深挖不过以我对InnoDB的理解可能跟MVCC的可见性有关我回去会查一下”的人。这种姿态说明他会学习、有自驱力而这种能力比记住答案重要得多。7. 最后说点实在的项目完成度比项目数量更重要很多人的简历上一写就三四个项目但每个都只有两三个功能模块没有一条完整的业务链路。这样的项目在面试官眼里不值钱。你就老老实实把“秒杀 - 下单 - 支付 - 订单闭环”这个链路做成一个真正的项目让它能跑起来有完整的日志有合理的监控有你在联调时真实踩过的坑记录这比十个半成品demo都管用。我见过一个让我印象很深的候选人他的项目和其他人比没什么特别但他准备了一个文档把自己在项目里遇到的14个问题和排查过程全部记录了下来包括一次Redis内存飙高的排查、一次MQ消息积压的处理。面试时我追着这些问题问了一个多小时每一个他都能讲清楚来龙去脉。最后他拿到了超过他期望的offer。这就是复盘的威力。希望这篇内容能帮你把“技术点”变成“技术体系”把“背过的答案”变成“做过的决策”。你先把项目按这个思路搭起来跑通一条核心链路再针对性地准备每个考点在项目里的对应的落点。等你真正讲明白了这套项目你会发现20K不是目标只是自然结果。