聊Java大厂面试,Spring Cloud、Redis、Kafka这三个词绑在一起出现的频率,比很多人想象中要高得多。最近帮几个准备跳槽的朋友做面试复盘,发现“互联网医疗系统”这个业务场景正在成为面试官的新宠,原因很简单:它既有极端高并发的瞬间流量(挂号秒杀、问诊高峰),又有极高的数据一致性和安全要求,还带着复杂的业务链路(问诊、处方、订单、支付、电子病历),一个系统能把研发、架构、稳定性、数据这块的能力全考一遍。这篇文章就把这个高频组合题完整拆开:从面试官为什么会问,到架构怎么设计,再到Redis和Kafka如何协同扛住流量峰值,最后把我在生产环境踩过的坑一并整理出来。无论你是准备面试的Java工程师,还是正在做系统设计选型,这份实战解析都能直接拿来用。
1. 先拆题:面试官为什么偏偏选中这三个组件
1.1 互联网医疗系统的业务特性决定了技术选型
互联网医疗系统跟普通电商系统最大的区别,在于它的业务场景自带两个极端:一个是“瞬时流量极高”,一个是“数据准确性要求极其变态”。拿在线挂号来说,三甲医院的专家号源可能只有二三十个,放号瞬间同时涌进来的请求可能轻松破几千甚至上万,这跟电商“库存多卖几个无所谓”的逻辑完全不同——号源超卖一个,患者到了医院才知道没号,那就是重大事故。另一个典型场景是图文问诊和电子处方,医生在App端开完处方,药房需要实时收到消息去准备药品,整个链路跨了用户端、医生端、药房端、支付中心、病历中心好几个子系统,任何一个环节掉了,患者体验都会崩。
这时候你就能看出技术选型的逻辑了:系统必须拆成微服务,否则几个业务团队没法并行迭代;拆完之后服务之间的调用、治理、容错必须有完整方案,这是Spring Cloud出场的理由;流量峰值必须扛住,热点数据必须缓存,这是Redis的活;系统内部大量异步通知、削峰、解耦、事件流转,这是Kafka的核心价值。面试官选这三个组件,本质上是想通过一个真实场景,看你有没有完整的分布式系统设计能力。
电商的通用方案套不到医疗场景,这是很多人面试时答偏的地方。比如有人张口就说“MySQL加索引硬扛”,或者“Redis做缓存就行”,这种回答在普通CRUD项目里可能过得去,但放在医疗系统里,面试官会继续追问:号源扣减的原子性怎么做?医生排班变更了缓存怎么通知?问诊订单状态流转丢了消息怎么办?连环追问之下,如果没真正做过,很容易露馅。
1.2 三个组件在系统中的分工边界要清楚
面试时最忌讳把三个组件混在一起说。我给一个清晰的边界划分,你可以直接记:Spring Cloud负责“把系统拆开并治理好”,Redis负责“把热点流量扛住并守住并发底线”,Kafka负责“把异步链路和流量削峰串起来”。三者是协作关系,不是替代关系。
具体来说,Spring Cloud在整个系统里承担服务注册发现(Nacos)、配置管理、网关路由(Spring Cloud Gateway)、熔断降级(Sentinel)、服务间调用(OpenFeign)这几件事。Redis承担的是业务缓存(医生信息、科室排班)、分布式锁(号源扣减)、接口幂等(防重复提交)、分布式限流(滑动窗口)、验证码等短生命周期数据。Kafka承担的是消息解耦(挂号成功通知、问诊会话创建、处方审核结果推送)、流量削峰(高并发写请求先入队,消费端按能力处理)、事件回溯(操作审计日志、业务事件流)。
| 组件 | 核心职责 | 在医疗系统中的典型场景 | 最常见面试考点 |
|---|---|---|---|
| Spring Cloud | 服务治理与系统拆分 | 挂号服务、问诊服务、药房服务、支付服务的注册发现与容错 | 服务拆分粒度、Gateway路由、Sentinel降级规则 |
| Redis | 热点缓存与并发控制 | 号源余量缓存、排班缓存、分布式锁、接口限流 | 缓存穿透/击穿/雪崩、分布式锁、持久化策略 |
| Kafka | 异步解耦与削峰填谷 | 挂号成功通知、处方流转、问诊状态事件、日志采集 | 消息可靠性、顺序性、重复消费、堆积处理 |
这个表格如果能在面试开头用两三句话说清楚,基本就建立了“这个人做过真实架构设计”的第一印象。切记不要一上来就背组件特性,而是先说“在这个场景下这个组件解决什么问题”,这才是加分项。
2. 整体架构设计与微服务拆分思路
2.1 一套可以直接复用的分层架构
我见过不少候选人描述架构时“只有一张图没有细节”,面试官追问就卡壳。这里给一套你可以在纸上画出来的层次结构,每一层的职责和关键组件都明确:
接入层是Spring Cloud Gateway,负责统一入口、鉴权、灰度发布和Sentinel限流;应用层是一组按业务域拆分的微服务,包括用户服务、挂号服务、问诊服务、处方服务、药房服务、支付服务、消息服务;支撑层是Nacos注册中心加配置中心,Kafka集群和Redis集群;数据层是MySQL主从加ES检索,以及对象存储(电子病历附件、检查报告图片)。
几个容易忽略的细节:所有服务必须接入同一个Nacos,但配置要按namespace隔离,比如dev、test、prod各一套,防止配置串环境;Gateway上要挂全局过滤器做Token解析,不要在业务服务里重复做;Redis和Kafka都是独立集群,不要部署在应用服务器同一台机器上,否则大促时互相抢CPU。
这套架构本身不复杂,但面试官真正关心的是你能不能解释“为什么这样拆”,以及流量如何穿透这几层。建议顺着“用户请求从App进来,经过Gateway鉴权,落到挂号服务,先读Redis缓存号源,再通过Kafka异步创建订单,最后推送通知给用户”这条主线讲一遍,比背十页架构图都管用。
2.2 服务拆分怎么分才不显得外行
微服务拆分是最容易暴露水平的地方。很多人一说拆分就是“按模块拆”,结果拆出来的服务互相调用成蜘蛛网。我的经验是,医疗系统优先按业务域拆分,同时考虑“变更频率”和“数据边界”。
用户服务管注册登录和基本信息;挂号服务管号源、排班、预约订单;问诊服务管会话、图文消息、音视频通话;处方服务管处方开立、审核、作废;药房服务管库存和发药;支付服务管费用结算和退款。数据上是完全隔离的,每个服务只能访问自己的库,跨服务数据通过接口或消息传递。
判断拆分粒度是否合理,可以问自己三个问题:这个服务能不能独立部署?(不能则说明耦合严重)这个服务的数据库表有没有被其他服务直接访问?(有则说明边界没划清)如果这个服务挂了,影响的用户范围是否可控?(影响面过大说明拆得不够细)在面试中快速说出这套判断标准,比堆一堆概念有用得多。
另外要主动提到“拆分不是越多越好”。我经历过把一个挂号服务拆成预约单服务和号源服务,结果一个事务要跨两个服务调三次接口,性能和一致性反而更差。正确的做法是在核心链路上允许适度粗粒度,比如挂号服务保留“排班+号源+预约单”的聚合能力,因为这三个数据强相关、变更频率同步。
2.3 网关与注册中心的高可用配置
网关和注册中心是流量的命门,面试官通常会在这里挖细节。注册中心我推荐Nacos,它的CP模式(临时实例采用临时节点+心跳续约)比Eureka更适合医疗这种要求实时发现变更的场景,服务下线最多几秒内就能被摘除,而Eureka的自我保护机制在极端情况下会导致调用打到已下线节点。
Nacos集群部署至少三节点,用内网域名互相通信,建议部署模式选“AP模式下的临时实例”(这是Nacos的默认临时实例行为,支持动态注册,故障实例会自动摘除),控制台和管理端走独立端口。服务端配置心跳时间建议保持默认,但把“保存实例状态的时间间隔”调小一些,可以加快故障感知。
Gateway侧重点关注超时和重试参数。路由级超时建议设为2000ms,重试机制开启但限制重试次数最多2次,并且只对GET请求自动重试,写请求重试要非常谨慎,否则可能造成重复挂号。还有一点很关键,Gateway的默认线程池大小要根据压测数据调整,不要让网关成为第一个被压垮的节点。我见过一次线上事故,网关线程池默认200,高峰时期全部线程阻塞在调用挂号服务上,后续请求全部超时,最后是通过增加服务实例和调大线程池上限才缓解。
3. 核心场景实战:挂号秒杀场景下的Redis与Kafka协同方案
3.1 挂号场景为什么难做
挂号秒杀是医疗系统里最能体现架构功力的场景,几乎每个面试官都会围绕它出题。难点有三个:第一,热key集中,一个专家的号源在放号瞬间被大量请求同时访问,Redis单key的访问会瞬间飙到每秒上万次;第二,一致性要求高,号源不能被超卖,也不能“扣了号但订单没生成”;第三,下游依赖脆弱,创建订单成功后要通知多条业务线,如果所有操作都在一次请求里同步完成,任何一个下游环节缓慢都会导致整体超时。
这三个难点对应三个核心技术点,也是面试官期待听到的答案:Redis缓存加分布式锁解决热key和超卖问题;Lua脚本保证扣减原子性;Kafka异步化解决下游通知问题。三者是串在一起的,缺任何一个环节都不完整。
我记得有一次模拟压测,纯同步实现下每个请求的耗时要800ms左右,因为订单创建、库存扣减、通知医生、记录日志全部串行。改成Redis预扣号源加Kafka异步落单后,核心路径耗时降到50ms以内,吞吐量提升了近十倍。面试时如果能掏出这种实测数据,说服力完全不一样。
3.2 基于Redis缓存与分布式锁的第一道防线
号源数据的特点决定了它必须提前预热到Redis里,不能每次请求都查MySQL。放号前通过定时任务把当日排班和号源余量写入Redis,key可以设计成clinic:schedule:slot:{doctorId}:{date},value用Hash存剩余号数和总号数。
扣减号源不能用“先读后写”的朴素逻辑,并发环境下两个线程同时读到余量1,各自走完业务逻辑再回写,就会超卖。正确做法是用Lua脚本在Redis内原子完成校验和扣减:
local key = KEYS[1] local current = tonumber(redis.call('hget', key, 'remain')) if current == nil then return -1 end if current <= 0 then return 0 end redis.call('hincrby', key, 'remain', -1) redis.call('hincrby', key, 'sold', 1) return 1这段脚本的逻辑很直观:先读余量,没有则返回-1表示key不存在;余量已空返回0表示无号;否则原子扣减,返回1表示成功。Redis单线程特性保证了这个脚本在并发下不会产生竞态条件,这就是分布式锁在这里的“内功”。
外部再套一层分布式锁的时候要注意锁粒度。对“某个医生某天的号源”加锁,key就是lock:schedule:{doctorId}:{date},而不是对整个服务加锁。锁的实现用Redisson的公平锁或普通锁都可以,但要设置合理 leaseTime 和等待时间,我一般设leaseTime 30秒、waitTime 2秒,超过等待时间直接返回“系统繁忙”而不是无限阻塞。锁内只做Redis扣减,不做数据库操作,这样锁的占用时间极短,不会拖垮吞吐。
3.3 Kafka异步化:从同步扣号到异步落单
扣减成功之后,绝对不能在请求线程里直接创建数据库订单,否则数据库连接池会成为短板。我的方案是扣号成功后马上把一条“挂号预约消息”发给Kafka的appointment-order-topic,然后直接返给用户“预约中,请稍后查看结果”,同时把订单状态置为“处理中”写入Redis。
消费者拿到消息后执行真正的业务:插入预约订单表、扣减MySQL中的号源库存、更新Redis中的订单状态、通过消息给用户推送挂号结果。这里有个关键设计:消息中必须携带唯一业务键,比如userId + doctorId + date + timeSlot生成的订单号,消费端通过唯一索引保证同一笔预约不会重复创建。
有人会问,Kafka异步化之后,如果消费者挂了怎么办?答案是消息不会丢,它滞留在Kafka里,消费者恢复后继续消费。这种“最终一致”的思路在医疗场景里是实践过的:核心结论是“不要求MySQL和Redis每时每刻严格一致,但事件驱动最终一定一致”。面试官如果追问“消费者什么时候可能丢消息”,你就可以展开讲提交时机和重试机制,这部分后面专门细说。
3.4 订单状态流转的消息设计
挂号不只是“创建一个订单”这么简单,它后续还有支付、改签、取消、就诊提醒条状态变化,这些变化驱动着大量下游动作。我的做法是把每一条状态变化都作为事件发到Kafka对应topic,各下游服务只订阅自己关注的事件。
比如订单创建事件发到appointment-created,支付服务订阅后触发支付请求;支付成功发到appointment-paid,问诊服务订阅后创建问诊会话;医生添加问诊结论后发到consultation-completed,处方服务订阅后生成处方待审核事件。这套事件驱动模式的好处是,新业务接入时不用改老服务的代码,只需要订阅新topic,比如未来加上“用药提醒”能力,订阅支付成功事件即可。
topic分区设计上,按用户维度做分区key,保证同一个用户的事件进入同一分区,消费时就能做到局部有序。比如一个用户不可能同时支付和取消,但如果事件乱序,就可能出现“取消在前、支付在后”的脏逻辑。分区数建议设为消费者数的整数倍,方便水平扩展,我常用的配置是3个副本、12个分区。
4. 高频面试题:分布式锁、缓存一致性、消息可靠性
4.1 Redis分布式锁的三问三答
面试官关于分布式锁至少有四个必问点:为什么不能用数据库锁?setnx和redisson有什么区别?锁过期了业务没执行完怎么办?锁粒度怎么控制?
第一问的答案是性能和数据量,数据库行锁在单机事务里很好用,但在并发量破万时锁等待、死锁检测会把数据库拖垮,而且跨服务跨库后数据库锁直接失效。第二问,setnx只是最底层原语,直接用会踩两个坑:忘记设置过期时间导致死锁,或者业务执行超过过期时间导致锁提前释放。Redisson的核心价值是“看门狗”机制,自动续期,默认30秒过期,每10秒检查一次,业务没执行完就续期到下一个30秒。第三问,如果锁到期但业务还没执行完,Redisson已经处理了,如果是自研锁,要么把过期时间设得足够大,要么在finally里释放并检查value。
第四问才是拉开差距的地方。锁粒度不能太粗,对整个医生加锁会导致同一个医生所有患者互斥;也不能太细,比如对“某个时段”加锁,如果时段划分过密会导致大量锁冲突。我一般的经验是按“医生+日期+时段”维度加锁,既不牺牲并发度又保证号源扣减安全。这组问答能答顺,基本就能证明你不是背概念。
4.2 缓存与数据库一致性:双删和延时双删的原理与取舍
缓存一致性问题在医疗系统里远比电商敏感,比如医生停诊后排班缓存没删,患者会挂上已停诊的号;支付成功但订单缓存还是待支付,用户端展示就会错乱。面试官爱问“更新数据库之后怎么更新缓存”,最常见的坑是“先更新数据库再更新缓存”,这会导致两个操作之间有一个窗口期,并发读请求读到旧值。
我推荐的思路是Cache Aside Pattern加延迟双删。具体流程:更新数据库→删除Redis缓存→等待几百毫秒→再次删除缓存。为什么用删而不是更新?因为删除操作天然幂等,没有旧值覆盖新值的问题。为什么延迟双删?因为先删除缓存后,另一个线程可能刚好读数据库并写回旧缓存,延迟再删一次能把这个顽固脏数据清掉。
延迟时间不能拍脑袋定,建议设置为“读请求写缓存的最慢耗时”加上几百毫秒缓冲。我一般在压测环境统计读接口P99耗时,如果P99是200ms,延迟删就设500ms。这套方案能覆盖99%的场景,如果碰到极端并发导致仍不一致,就要靠消息驱动的异步补偿了,比如监听Binlog变更后刷新缓存,这可以作为加分项提出来。
4.3 Kafka的消息可靠性:三端配置缺一不可
Kafka消息可靠性的面试问答,核心要答清楚“消息从生产到消费,哪个环节会丢,怎么防”。三个环节都是风险点:生产者发送失败、Broker副本未同步、消费者拉取后没提交就宕机。
生产者端配置acks=all,意思是必须等到所有ISR副本都写入成功才返回成功;同时设置retries=3和enable.idempotence=true,后者通过producer的PID和sequence number避免重试导致的重复消息。Broker端设置min.insync.replicas=2,要求至少两个副本同步,配合副本因子3使用,这样即使一个Broker宕机也不会丢数据。消费者端是重灾区,很多人为了省事把enable.auto.commit设为true,系统每5秒自动提交offset,如果消息处理到一半宕机,恢复后offset已经提交了,这批消息就丢了。生产环境必须手动提交:
try { // 处理业务逻辑,比如创建订单 process(message); // 业务成功后提交offset ack.acknowledge(); } catch (Exception e) { // 记录错误并重试,重试失败后进入死信Topic dlp.sendToDlq(message); ack.acknowledge(); // 死信处理完成后也提交,避免无限重试 }注意这里的原则是“业务成功才能提交offset”,并且异常消息进死信队列而不是一直重试阻塞。死信Topic加一张消息追踪表,人工或定时任务定期捞出来补偿,这才是可靠消息的完整闭环。
4.4 消息重复消费与顺序性:幂等设计是关键
即使做到上述配置,重复消费依然会发生,因为Kafka至少一次的投递语义下,消费者处理完但提交offset前崩溃,重启后会再消费一次。面试官问到这里,期盼的答案是“幂等设计”,而不是“保证消息不重复”。
医疗系统里最典型的幂等场景是“同一笔挂号请求被消费两次只能产生一个订单”。我的做法是消费开始时先查Redis里是否存在order:create:{orderNo}的key,存在说明已处理则直接返回;不存在则用SET key value NX EX 300尝试占位,只有占位成功才能走业务逻辑。这一步的本质是“分布式锁的另一种用法”,面试时点破这一点会加很多分。
顺序性问题在挂号场景不太突出,但处方流转场景很关键:一张处方要先创建再审核,如果审核消息先到,业务就会报错。解决方案是topic分区key用处方号,同一张处方的所有消息进入同一分区,分区内消息顺序有保证。消费者如果是多线程处理,需要在内存中按key排队;如果单线程消费,天然有序。我在项目里用的是分区key加单分区消费多实例的模式,也就是每条消息都带处方号,消费者数等于分区数,保证单一消费者线程处理同一处方的事件。
5. 生产环境的坑:Redis超时、Kafka积压与性能调优实录
5.1 Redis command timed out排查实录
热词里有“Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException”,这个异常我在生产环境遇到不下三次,几乎每次都是同一个根因:大Key阻塞了Redis事件循环。
第一次排查时,监控显示Redis平均延迟只有2ms,但总有少量请求超时超过3秒。后来用redis-cli --bigkeys扫描,发现有一个Hash类型的key存了近百万个字段——是排班数据,每个医生每天一个字段,长期累积下来没人清理。Redis是单线程处理命令,执行这个超大Hash的遍历操作时,其他所有请求都得排队,超时就是这样来的。解决方法是拆分大Key:按日期维度拆成多个key,每次只读写当天的数据;同时给每个key设置合理的过期时间,并加定时任务清理历史数据。
另一个常见的超时原因是Redis连接池配置不合理。Lettuce是异步连接,默认池大小只有8,高并发下连接不够用会排队等待,表现为偶发超时。建议生产配置最小空闲连接数16,最大连接数64,连接超时2秒,读取超时3秒。注意最大连接数不是越大越好,连接数过大会增加服务端线程开销,最好压测找出拐点。
5.2 Kafka消息延迟高与堆积的处理思路
“Kafka消息延迟高”是另一个高频痛点,面试官问到这个通常是想看你的排查路径。我先说我踩过的案例:某次问诊高峰期,挂号成功消息延迟从秒级飙升到分钟级,用户反馈“号挂上了但一直收不到确认”。
排查分四步。第一步看Kafka监控,消费组Lag是否持续增长,确认是消费慢还是生产慢;第二步看消费者线程数与分区数是否匹配,当时我们只有3个消费者但分区有12个,9个分区处于“有人订阅但没线程消费”的假死状态,把消费者线程调到12后吞吐立刻翻倍;第三步看消费耗时,发现消费端里面有个MySQL慢查询,每条消息处理耗时200ms以上,把这个SQL的联合索引加上,降到20ms;第四步看网络和GC,太频繁的Full GC也会导致消费者停顿。
如果堆积已经发生,最快的补救手段是临时增加消费能力,两个方向:增加消费者实例数(前提是分区数足够分摊),或者开消费者端批量处理配置,比如max.poll.records从默认500调到2000。还有一个“快速排出”的临时方案是把堆积消息先转到单独的“重放Topic”,用专门的临时服务以更高吞吐处理和修复,等堆积清完再停掉。
5.3 从压测数据看参数调优
压测是检验架构的唯一标准,没有压测数据支撑的方案在面试里会显得单薄。我做挂号场景压测时用过一套比较标准的参数组合,分享出来可以参考:
Redis方面,连接池最大连接数64,Lua脚本扣号不耗时,单Redis实例可以支撑每秒两万次扣减操作,但前提是没有大Key和慢命令。Kafka方面,生产者batch.size=16384、linger.ms=5,小消息聚合发送能显著提升吞吐;消费者fetch.min.bytes=1024、fetch.max.wait.ms=500,控制拉取频率。Spring Cloud Gateway方面,线程池最大线程数从默认的200调到400,但配合Sentinel的QPS阈值(按压测结果×0.8)防止把下游打死。
压测过程中最值得注意的是“雪崩效应”的验证。我们压到1.5倍容量后手动关停一台挂号服务,观察Sentinel是否会触发降级、Kafka是否堆积暴增、Gateway是否把请求正确分发到存活节点。这一轮演练跑下来,你才能说自己对系统稳定性有底。面试时聊这段历程,比空谈“高可用”有力得多。
6. 面试答题心法与几个加分扩展点
6.1 答题结构:场景→方案→细节→风险
面试官不是要听你说答案,是要听你说思路。我的建议是每一次回答都固定用这套结构:先交代业务场景,再给出整体方案,然后用两三句话讲实现细节,最后主动暴露和补上风险点。
比如问“Redis如何支撑高并发”,别上来就说“缓存用户信息”,而是拆成:挂号放号瞬间用户流量集中在几十个热key上→先用Redis缓存号源并把扣减逻辑收敛到Lua脚本→同时用Sentinel做单key限流保护Redis→同时预热阶段提前加载并设置过期时间避免穿透。答完主干主动说“这套方案的一个风险点是缓存和数据库的一致窗口,我是用延迟双删加异步补偿兜底的”。这样回答的层次感远超“Redis快”三字经。
这个结构也适用于你向面试官提问的环节。比如反问“你们这边的峰值QPS大概是多少,分布式锁的粒度是按什么维度划分的”,既显得有实战经验,又能判断团队水平。我见过很多候选人专业技能不差,但回答没有层次,被面试官一路追问到最后乱了阵脚,其实只要把“场景→方案→细节→风险”四个节点把控好,大部分追问都能提前覆盖到。
6.2 几个不容易想到的加分扩展点
如果你前面都答稳了,想再拉开一点差距,可以主动抛出以下扩展点。一是“缓存与数据库最终一致的补偿机制”,不只是双删,还可以讲监听Binlog变更、用增量消息刷新缓存;二是“分布式事务在问诊支付场景的取舍”,不用讲Seata全套,重点说为什么挂号这个场景适合用事务消息(本地事务表)实现最终一致,而不是强一致方案;三是“灰度发布在医疗系统的特殊性”,比如新版本挂号服务先灰度10%流量给内部测试账号,并做AB对比异常率,医疗系统容不得全量上线事故。
对于Kafka,可以加一条“Schema演进”的见解:消息体用统一的事件模型,字段变更兼容旧消费者,消费者侧做字段默认值兜底,避免上游改字段立刻炸掉下游服务。对于Spring Cloud,可以讲Sentinel规则的动态配置如何基于Nacos持久化,以及熔断降级的Metrics如何接入监控大屏,这些细节都能体现你真的维护过生产系统。
我个人在实际项目里的体会是,Java大厂面试问这套组合,最后拼的往往不是你会多少个组件API,而是面对一个陌生业务场景时,能不能快速定位流量瓶颈、选出关键组件、并讲出每一步设计的约束和代价。互联网医疗系统恰好把这三种组件的使用场景压缩到了一个系统里,如果你能把挂号这一个核心链路从头到尾讲透,应付大多数架构类问题都会轻松很多。这个题背后真正的价值,是逼你把“技术栈”转化成“解决问题的能力”,想明白这一点,面试就已经赢了一半。