极兔物流 — Java后端团队Leader岗 面试准备(详细版)


模块一:自我介绍

标准版(约1分30秒)

面试官您好,我是吴来,软件工程本科,提前一年毕业,GPA 4.0。

我的职业主线是交易系统架构和技术团队管理。在携程待了12年,从开发做到资深研发经理,管过20多人的团队,负责火车票的订单、抢票、出票、国际业务这些核心系统。最有代表性的是主导了订单系统重构,把日均订单峰值从100万做到1000万以上,可用性做到了99.99%。独立设计的抢票系统拿过集团CEO年度大奖。

离开携程之后,我在SGS做了半年技术经理,带10人团队做DDD重构。之后创业,作为技术合伙人做了三个从0到1的项目。一个是IoT共享平台,设备在线率99.5%,月流水破百万。一个是TripPlan,用LLM Agent做行程规划SaaS,从对话到支付做了端到端闭环。还有一个是基于RAG的AI智能客服系统。

我的核心能力在三个方向:高并发交易系统架构、DDD和技术团队管理、AI Agent的工程化落地。极兔这个岗位,物流场景的高并发、运单状态机、分库分表这些核心问题,都是我深度实战过的,所以很有兴趣来聊聊。

精简版(约45秒)

您好,我是吴来,12年Java研发和6年团队管理经验。携程12年,主导火车票订单系统重构,日均订单从100万做到1000万以上,可用性99.99%,抢票系统获CEO大奖。之后创业做了IoT平台和两个LLM Agent项目,都有商业落地。核心能力是高并发交易系统架构、DDD设计和团队管理。极兔物流的高并发运单场景和我的经验高度匹配,所以来面试这个岗位。


模块二:个人素质类问答

Q1:你最大的优势是什么?

我最大的优势是从技术到业务到管理的完整闭环能力。

很多技术管理者擅长其中一两个环节,要么技术强但不懂业务,要么懂业务但技术细节跟不上,要么带团队但不碰架构。我的经历恰好把这三条线都走通了。

技术上,千万级订单系统的架构是我亲手设计落地的,分库分表、状态机、高可用这些不只是纸上谈兵。

业务上,创业做IoT平台,从0到1做到月流水破百万。这个过程中技术决策直接绑着商业结果——设备在线率影响用户付费,支付链路稳定性影响资金安全。

管理上,3人到20多人的团队都带过,不同阶段管理方式不一样。3人团队要的是身先士卒,20人团队要的是分层授权和机制建设。

这个闭环能力让我接到一个需求时,能同时从技术上怎么做、业务上值不值得做、团队上谁来做什么节奏三个维度去评估,而不是只看一个面。


Q2:你最大的缺点是什么?

我比较明显的一个问题是,有时候会过于追求技术方案的完美,前期设计投入太多时间。

举个例子,做TripPlan的时候,我在领域模型设计上花了不少时间,想把9个模块的边界都划分清楚再动手写代码。后来发现有些模块在MVP阶段其实可以合并,过度设计反而拖慢了交付。

意识到这个问题之后我做了调整。一是引入MVP思维,先用最简方案验证业务假设,再迭代优化架构;二是在团队内部建立设计评审机制,让其他成员帮我判断设计是不是过度了。现在我更注意"够用就好,可演进"这个平衡。


Q3:你怎么应对压力?

携程12年,每年春节抢票就是压力测试。印象最深的是有一年春节高峰,DB连接池被打满,系统响应急剧变慢,客服电话被打爆。

我的处理套路比较固定——先稳住情绪,再分步走。

先是止血,不急着查根因,先恢复服务。当时关掉非核心功能释放资源,同时启动降级预案。服务稳住之后才去查根因,定位到是一个慢SQL导致连接泄漏,紧急加了索引。中间还有件事不能少,就是信息同步,拉故障群,每10分钟同步一次进展,让上下游和领导知道我们在处理,不至于慌。故障处理完不是结束,复盘一定要做,把整个过程过一遍,产出改进项。

简单说就是:先解决事,再安抚人,最后防复发。


Q4:你的学习方式是什么?

我会分广度和深度两块。

广度这块,主要靠信息源,关注一些高质量的技术博客和深度文章。我不追热点,更多是建立一个"知道有这个东西、大概做什么"的认知索引,需要的时候再深入。

深度这块一定得通过做项目。比如学LLM Agent,我不是看文档看会的,是做TripPlan的时候在真实场景里踩过坑才理解——Function Calling的参数schema怎么设计才稳定,约束求解的算法复杂度怎么控制,支付回调的状态机怎么保证幂等,这些都是做出来的。

所以我对团队也是这个要求,想学新技术,找一个真实场景用起来,光看Demo没用。


Q5:你为什么从携程离开?

离开携程是一个深思熟虑的决定,核心原因是想在业务和技术的边界上做更多探索。

携程12年,从开发做到资深研发经理,管过最核心的系统,拿过CEO大奖,技术上该证明的都证明了。但越到后面,我越觉得自己在一个大厂的螺丝钉位置上,业务方向是公司定的,技术选型受限于公司技术栈,个人对商业方向的影响力有限。

我想验证自己两件事:一是能不能从0到1把一个商业模式跑通,二是除了Java之外,新技术栈能不能快速上手并产出商业价值。

所以出来创业,做了IoT平台和AI Agent项目。这两年验证了我两个判断,我能做0到1的商业项目,我的技术迁移能力也没问题。但创业也让我看清了一些现实——TripPlan的入境游市场规模有天花板,获客成本比预期高,B端付费意愿不够强。这不是技术能解决的问题,是赛道和资源的问题。我作为一个技术合伙人,没有流量和渠道资源,很难在这个赛道上单独跑出来。

现在选择回来做Java后端Leader,不是退回去,是带着更完整的视角回到更大的平台。做过创业的人,看技术决策的维度会不一样,会更关注业务价值和成本,而不只是技术本身。极兔这种规模的物流平台,业务复杂度和技术挑战都足够大,能让我在大平台上发挥更大的影响,这是创业阶段给不了的。


Q6:你怎么看待失败?

失败本身没有价值,对失败的复盘才有。

创业这两年,我也有过判断失误。比如TripPlan,产品做得不错,技术架构也完整,但市场验证花的时间不够。等产品打磨得比较完善了去推,才发现入境游市场的获客成本比预期高很多,B端旅行社的付费意愿也没想象中那么强。

这次让我反思了两点。一是技术人创业最大的陷阱是过度投入产品、忽视市场验证。应该先用最糙的方案跑通商业闭环,而不是把架构设计得再完美。二是MVP的核心不是最小可行产品,是最小可验证假设——要验证的不是产品能不能做出来,而是有没有人愿意付钱。

这些经验我带到了后面的项目和团队管理里。现在做需求评审,我第一个问题永远是"这个需求验证什么业务假设",而不是"这个需求怎么做"。


Q7:你最大的失败或遗憾是什么?

最大的遗憾是TripPlan的市场验证做得不够早,这件事我到现在还在反思。

技术架构上我花了很多心思,DDD拆分、约束求解引擎、支付状态机,从技术角度看是一个完整的作品。但产品打磨上花的时间太多了,等产品做得比较完善了去推市场,才发现入境游市场的获客成本比预期高很多,B端旅行社的付费意愿也没想象中那么强。

如果重来一次,我会在产品只有最核心功能的时候就去验证市场,而不是等所有功能都做完了再去。具体说就是先把"对话生成行程"这一条链路跑通,拿去给5家旅行社试用,看他们愿不愿意付定金。哪怕只有一家愿意,也能验证商业闭环;如果一家都不愿意,就说明方向有问题,及时调整,不至于在架构上投入太多。

这个经历让我对"创业"有了更客观的认识——技术只是成功的一个条件,不是充分条件。市场、获客、商业模式,这些因素的重要性不比技术低。


Q8:你和领导意见不合的时候怎么处理?

携程做订单系统重构时,我直属领导一开始倾向于Binlog同步方案,因为省事、快。但我评估后觉得Binlog延迟不可控,交易场景不能用。

我不会直接反对,而是带着数据去沟通。写了一页纸的方案对比,把双写和Binlog在数据一致性、回滚能力、故障恢复时间三个维度做了对比,每个维度用具体数字说明。另外找了一个小范围场景做了双写方案的PoC,证明方案可行。

领导看了对比和PoC结果,认同了我的判断。后来系统迁移成功,零故障,他也觉得当时那个决策是对的。

所以和领导意见不合,不要只说"我觉得不行",要拿数据和事实说话。领导也是技术出身,分析有道理他会认真考虑。当然,如果最终他还是坚持,我会执行,但会把风险点和应对预案准备好,执行过程中密切关注。


模块三:专业技能类问答

Q1:Java并发编程,你在实际项目中怎么用的?

我讲一个携程抢票系统的实际场景。

抢票的核心特点是,放票瞬间有大量请求涌入,但实际可抢的票很少。所以不能让所有请求都直接打到12306,需要做请求调度和合并。

我用的是线程池加任务队列加任务合并的方案。用户发起抢票请求后,不直接调12306接口,而是提交到线程池的任务队列。有一个调度线程定期扫描队列,把同一车次同一区间的多个请求合并成一个。合并后的请求由工作线程执行,结果通过CompletableFuture回调分发给各个用户。

线程池参数调优这块花了不少时间。我们通过压测定了核心线程50、最大线程200、队列容量5000,拒绝策略用CallerRunsPolicy,让提交任务的线程自己执行,防止任务丢失。这里有个风险要注意——CallerRunsPolicy会占用提交线程,如果任务执行慢,可能把Tomcat工作线程耗尽导致雪崩。我们用在抢票场景是因为单次任务执行时间可控(几百ms),不会长时间占用。如果是耗时不可控的任务,我会改成"拒绝时记录到DB/Redis,后台异步重试",不用CallerRunsPolicy。

线程安全方面,任务合并的地方用了ConcurrentHashMap,key是车次加区间的组合,value是等待结果的Future列表。多个线程同时往里put的时候,用computeIfAbsent保证原子性。另外抢票结果回填用了StampedLock的乐观读模式——大部分情况下只是读取任务状态,不涉及写入,乐观读比synchronized性能好很多。StampedLock有个使用陷阱要提一下,tryOptimisticRead之后必须validate,validate失败要降级为readLock重新读,否则会有可见性问题。另外StampedLock早期版本(Java 8)有CPU飙升的bug,Java 10之后才稳定,我们当时已经升到Java 11。


Q2:Spring Cloud微服务,你的实战经验?

我从Spring Cloud Netflix组件时代用到了Spring Cloud Alibaba时代。

携程订单系统微服务拆分,最早用Eureka做服务注册发现,后来迁到Nacos;Feign做服务间调用,Hystrix熔断降级,后来换成Sentinel;Zuul做网关,后来换成Gateway;Config做配置中心。

拆分策略上,我的经验是按业务能力拆,不按技术层次拆。订单系统我拆成4个服务——订单核心、状态机、支付、通知,每个服务独立数据库,服务间通过Feign和MQ通信。

这里有几个坑要提一下。服务粒度不能拆太细,我见过有的团队把一个订单系统拆成十几个服务,一次下单要调七八个服务,链路太长,性能和可用性都受影响。我的原则是,一个服务应该能独立完成一个完整的业务动作。

熔断降级这块,关键调用一定要配熔断。我们用Sentinel配置了QPS限流和异常比例熔断,阈值要分链路看——支付这种核心链路异常率超过5%就熔断,因为支付失败对用户感知太强;非核心调用比如通知、数据同步放宽到10%。阈值不是拍脑袋定的,要结合历史上正常时段的异常率基线,正常时段异常率1%的接口,设5%就有5倍冗余;正常时段就3%的接口,设10%才合理。降级逻辑也要预先设计好,不能临时想。

分布式事务方面,我们用的是本地消息表加MQ的最终一致方案,没用Seata。原因很简单,Seata的AT模式性能开销大,TCC模式开发成本高。本地消息表虽然不是强一致,但对订单场景够用。

到一定规模后,服务治理比服务拆分还重要。我们做了全链路TraceId追踪,每个请求一个唯一ID,贯穿所有服务调用;还有服务依赖关系可视化,定期扫描Feign调用关系生成依赖图,发现循环依赖及时治理。


Q3:分库分表你具体怎么做的?

我用携程订单系统的8库方案来讲。

分片策略是水平分库,按订单ID做hash路由到8个库。每个库本身再做读写分离,1主2从,写走主库,读走从库。

分片键选订单ID,因为80%以上的查询场景是按订单ID查。但有个问题,用户查看"我的所有订单"是按用户ID查,怎么定位到哪个库?我们用了基因法,生成订单ID的时候,把用户ID的低4位编码到订单ID的某几位里。这样按订单ID路由时,可以直接用订单ID算hash。按用户ID查询时,从用户ID提取那几位基因,就能定位到对应的库,不需要扫所有库。

跨片查询有几种处理方式。按用户ID查订单用基因法定位到特定库。复杂条件查询,比如按时间、按状态查,走ES异构索引,先从ES查出订单ID列表,再回主库查详情。配置表、字典表这类小表做广播表,每个库都存一份。

扩容方面,从8库扩到16库的时候,我们做的是倍增扩容。每个库的数据按新规则拆成两半,一半留在原库,一半迁移到新库。迁移过程用Binlog同步保证数据一致,迁移完成后做数据校验,确认无差异后切流。

分库分表后join的处理,原则上不做跨库join。需要关联的数据两个处理方式,一是应用层组装,先查A表拿到ID再查B表,二是数据冗余,把常用关联字段冗余到一张表里。

关于ShardingSphere,携程当时用的是自研的分库分表中间件,基于MyBatis拦截器实现SQL路由。当时ShardingSphere还不成熟,公司内部有自研的中间件,出问题能快速修复。但ShardingSphere在2023年已经成为Apache顶级项目,现在很成熟了。如果现在让我选,新项目直接用ShardingSphere-JDBC或ShardingSphere-Proxy,没必要自研。自研中间件的维护成本太高,除非有特殊定制需求。ShardingSphere的分片策略、广播表、绑定表、SQL路由这些概念和我们的自研方案是一致的,我的经验可以直接迁移过去。


Q4:Redis缓存,你的设计思路?

我在携程设计了"主动+被动"分级缓存方案,核心是按数据热度差异化处理。

热数据走主动推送。热门线路余票信息QPS上万,源数据变更时主动推送到Redis,保证缓存始终最新。查询直接命中缓存,不需要查DB。实现方式是Binlog监听加Canal推送,DB变更后毫秒级同步到缓存。

冷数据走被动拉取。长尾线路查询访问频率低但总量大,用Cache-Aside模式,先查缓存,miss了再查DB回填,设一个合理的过期时间。

效果比较明显,热数据查询延迟从秒级降到毫秒级,Redis资源还省了60%——这个60%是按Redis内存占用算的,分级缓存上线前后做了一次对比,之前所有线路数据都常驻缓存,分级之后只有Top 20%热数据常驻,冷数据按需加载。冷数据不需要常驻缓存,省下的就是这部分内存。

一致性方面,我们用的是"先更新DB,再删除缓存",配合延迟双删。为什么不更新缓存?因为更新有并发覆盖风险,两个写请求交叉执行,缓存可能被旧值覆盖。删除更安全,下次查询自然会从DB加载最新值。延迟双删是为了应对极端场景——读线程在删除缓存前已经读到旧值准备回填。所以更新DB后先删一次,延迟500毫秒再删一次。这个500毫秒不是拍脑袋定的,是我们压测时观测到的主从延迟P99,实际应该根据监控动态调整。如果主从延迟抖动超过这个值,延迟双删会有窗口期风险,更彻底的方案是订阅Binlog删缓存,但当时我们的基础设施还不具备这个能力。

缓存三大问题也得处理。穿透——查不存在的数据,用布隆过滤器挡一层,对不存在的key缓存空值,设短TTL。击穿——热点key过期瞬间大量请求打到DB,用互斥锁setnx,只允许一个线程回填。雪崩——大量key同时过期,TTL加随机偏移避免集中过期,同时做本地缓存加Redis的多级缓存。


Q5:消息队列,你的使用经验?

携程主要用公司内部MQ,基于Kafka和RocketMQ改造的,创业项目用过RabbitMQ。

核心场景就三个。一是异步解耦,订单创建后要触发出票、通知、数据同步这些下游动作,同步调用链路太长,任何一步出问题都会影响下单。改成订单创建后发MQ消息,下游各订阅各的,互不影响。二是削峰填谷,抢票高峰期下单请求先进队列,消费端按自己能力消费,队列做了削峰,保护后端服务。三是最终一致,跨服务的数据一致性用本地消息表加MQ,业务操作和消息发送在同一个本地事务里,后台任务轮询发送未确认的消息。

几个关键设计点要说一下。消费幂等——MQ的At-Least-Once语义意味着消息可能重复投递,我们用唯一键加去重表保证幂等,消费前先查去重表,已处理过的消息直接跳过。顺序消费——订单状态变更必须有序,不能出现"已完成"在"已支付"之前。RocketMQ的话用顺序消息,把同一订单的消息路由到同一个queue,消费端单线程消费。死信队列——消费失败的消息进入死信队列人工介入,我们设了最大重试3次,超过就进死信。

RocketMQ和RabbitMQ的选型,物流场景我倾向于RocketMQ。三个原因:一是顺序消息能力更强,queue级别有序,运单状态变更天然需要有序;二是支持事务消息,两阶段提交,下单加扣库存这个场景可以用;三是海量堆积能力更强,物流大促期间消息量暴增,RocketMQ的堆积处理能力优于RabbitMQ。RocketMQ底层CommitLog顺序写、ConsumeQueue索引、消息过滤这些机制,保证了它的高性能和可靠性。


Q6:你对DDD的理解和实战经验?

我的DDD实战有两段经历——SGS的企业级项目重构和TripPlan的从0到1设计。

核心理解是,DDD不是先写代码再分层,是先把业务搞清楚再建模。它的价值不在于技术,在于让业务和技术说同一种语言。

战略设计上,TripPlan我拆了9个限界上下文——需求收集、行程引擎、约束求解、定价、订单、支付、通知、用户、资源查询。拆分依据是业务语义,每个上下文有自己独立的领域模型和语言。比如"订单"在定价上下文里叫"报价单",在支付上下文里叫"支付单",到订单上下文里才是"订单"。底层可能是同一份数据,但不同上下文关注的角度不同。

战术设计上,以订单上下文为例,聚合根是Order,是事务一致性的边界,外部只能通过Order操作内部实体;实体是OrderItem订单项,有自己的生命周期但依附于Order;值对象是Money金额,没有身份标识、不可变;领域事件有OrderConfirmed、DepositPaid这些,用于通知其他上下文。

落地收益上,在SGS做DDD重构时,新业务接入成本降低了约30%。这个30%我是这样算的——取重构前后各半年内的同类新需求,对比从需求评审到上线的平均交付周期。重构前同类需求平均10个工作日完成,重构后降到7个工作日左右,省下的3天主要是沟通对齐和代码改动扩散的减少。原因不在代码层面,而是产品和开发对业务概念的理解对齐了。之前产品说"工单",开发理解成"任务",DB里叫"ticket",三个名字一个东西,沟通成本极高。DDD的通用语言让所有人对同一个概念用同一个词,需求沟通效率明显提升。

也踩过坑。早期做DDD容易过度设计,把所有概念都建模成聚合根,导致聚合根之间引用复杂,性能也差。后来学会了按事务边界划分聚合——如果一个操作需要同时修改两个对象,它们应该在同一个聚合里;如果不需要,就拆开。

TripPlan的9个上下文也是过度设计的例子。如果现在重做,MVP阶段我会拆3个——行程规划、订单支付、资源查询。通知用领域事件解决,不需要单独成上下文;用户管理用现成的认证服务;定价合并到行程规划里,等业务跑起来再拆。MVP阶段的核心是验证"对话生成行程"这个商业闭环,不是把架构做得再完美。DDD拆分应该跟着业务复杂度走,业务没起来就拆9个,是给自己加负担。


Q7:你怎么做技术选型?

技术选型我一般走四步。

先把需求和约束搞清楚——不是什么技术好就用什么,是看场景适合什么。要搞清楚业务场景是什么、QPS预期多少、数据量级多大、一致性要求强还是弱、团队熟悉什么技术栈。

然后列候选方案,至少两到三个,每个方案的优势和风险都写清楚。

接着做PoC验证关键假设——比如某个方案能不能支撑我们预期的QPS,某个特性是不是满足业务需求。PoC不需要做完整功能,只验证最关键的不确定点。

最后是团队共识。技术选型不能一个人拍板,拉上核心成员一起评估,因为选型不只看技术,还要看团队维护能力。

举个例子,TripPlan为什么选FastAPI而不是Spring Boot?LLM的IO密集型特性,异步是刚需,Python的asyncio生态成熟;AI相关的SDK,LlamaIndex、LangChain都是Python优先,用Java要自己包一层;团队只有3个人,Python开发效率更高。而且性能瓶颈不在框架,在LLM调用本身,框架差异可以忽略。但如果是传统交易系统,我还是选Java——类型安全、生态成熟、性能稳定、团队熟悉。技术选型没有银弹,看场景。


Q8:高可用架构怎么设计?

高可用是体系工程,不是单点技术。我的框架是事前防御、事中止血、事后复盘。

事前防御要做几件事。冗余部署——服务多实例,DB主从,Redis集群,单点全部消除。限流降级——网关层Token Bucket限流,应用层Sentinel熔断,核心链路保护,非核心降级。监控告警——全链路监控接口RT、错误率、资源使用率,分级告警,P0电话、P1短信、P2即时通讯。还有压测——大促前全链路压测,找瓶颈点,提前扩容或优化。

事中止血这块,故障发现靠监控告警加人工巡检补充;快速恢复——能回滚的立即回滚,不能回滚的降级,止血优先于查根因;信息同步——故障群每10分钟同步进展,关键决策记录下来。

事后复盘也几条。根因分析用5-Why法挖到根本原因,不停在表面;改进落地——每个故障至少产出一个改进项,有责任人和deadline;机制固化——同类问题通过自动化手段预防,不依赖人的自觉。

携程订单系统99.99%可用性就是这么建起来的。不是没有故障,而是故障发生后的恢复速度足够快,一年累计故障时间控制在53分钟以内。


Q9:微服务服务治理你怎么理解?

服务治理我关注五个维度。

服务注册发现,从Eureka迁到Nacos,Nacos支持CP和AP两种模式切换,更灵活。服务上下线要graceful,先从注册中心摘除,等in-flight请求处理完再关闭服务。

负载均衡,Feign默认的Round Robin不够用,我们用加权随机,根据服务实例的响应时间和成功率动态调权重,响应慢的实例分到的请求少。

熔断降级,Sentinel配置了三条规则——QPS限流防流量突增;异常比例熔断,核心链路5%、非核心10%,阈值基于正常时段异常率基线定的;RT熔断响应时间超阈值熔断。降级逻辑要预先设计好,不能临时想。

链路追踪,全链路TraceId,每个请求生成唯一ID,通过HTTP Header传到所有下游服务。排查问题用一个TraceId就能拉出整条调用链。我们用的SkyWalking,自动埋点,无侵入。

服务依赖治理,定期扫描服务间调用关系生成依赖图。核心原则是依赖单向、无环。发现循环依赖要立即治理,不然一个服务出问题会连锁影响。


Q10:你怎么保证代码质量?

代码质量我管三个层面。

规范层——统一编码规范,以阿里巴巴Java开发手册为基础,团队做补充。Checkstyle、SonarQube在CI阶段自动检查,不通过不让合并。

Review层——所有代码必须经过Review才能合并主分支。我做分级Review,核心模块的PR我亲自review,非核心模块由组内骨干交叉review。Review不只看语法,重点看设计合理性、边界条件处理、日志完整性。

测试层——单元测试覆盖核心算法和业务逻辑,覆盖率不低于70%;集成测试覆盖关键接口的端到端测试,放在CI流水线自动执行;上线前还要做接口压测,确认性能达标。

在SGS建立Code Review机制时,初期团队有抵触,觉得耽误时间。我先自己带头review别人的代码,而且review意见要具体、有建设性,不是挑刺。三个月后团队形成了习惯,代码质量明显提升,交付周期反而缩短了20%——因为返工减少了。


Q11:MongoDB在物流场景中怎么用?

MongoDB我在携程场景里没用过,但物流场景用MongoDB做运单轨迹存储这个思路我理解。

运单从揽收到签收,中间经过多个分拨节点,每个节点有时间、地点、操作人、状态变更等信息。这种数据结构用MySQL存比较别扭,要么用JSON字段,要么建多张关联表,查询效率不高。MongoDB的文档模型天然适合,一次查询就能拿到完整的运单轨迹。

分片集群方面,MongoDB支持自动分片,分片键可以选运单号,写入和查询都按运单号路由,和MySQL分库分表的路由逻辑一致。副本集保证高可用,和MySQL主从复制一个道理。

索引策略上,运单轨迹主要查询场景是按运单号查全链路,运单号建唯一索引;按时间范围查某段时间的运单,时间字段建索引;按状态查处理中的运单,状态字段建索引;查询条件比较复杂的可以建复合索引。

和MySQL的选型对比,运单轨迹这种非结构化、嵌套层次深的数据,MongoDB更合适。但运单核心交易数据,比如运单金额、结算信息,还是放MySQL,因为交易数据需要强一致性和事务支持。实际架构通常是MySQL存交易核心数据,MongoDB存轨迹和日志类数据,两者互补。


Q12:XXL-Job分布式定时任务怎么用?

XXL-Job我在携程用的是内部调度平台,原理相通。XXL-Job是分布式任务调度的标配,核心是调度中心加执行器架构。

路由策略有几种——轮询,任务均匀分配到各执行器;故障转移,某个执行器挂了自动切到其他执行器;分片广播,同一任务的所有分片在所有执行器上同时执行,适合处理大数据量的批处理任务。物流场景里,比如每天凌晨对所有运单做状态统计,用分片广播,每个执行器处理一部分运单,并行处理,效率高。

分片广播处理大数据量的思路是这样——假设有100万条运单要统计,分10个分片,每个分片10万条。执行器通过XXL-Job的分片参数拿到自己的分片索引和总分片数,比如分片索引是0,就处理运单ID hash后对10取模等于0的那批运单。数据天然分散,各执行器互不干扰。

失败重试,任务执行失败自动重试,可以配重试次数。重试后仍然失败就触发告警,人工介入。

和Spring Cloud集成也简单,XXL-Job的执行器就是一个Spring Boot应用,引入依赖、配置调度中心地址、写任务Handler就行。任务Handler上加@XxlJob注解,里面写业务逻辑。


Q13:TiDB你了解吗?和分库分表方案怎么对比?

TiDB我没有在生产用过,但做过比较深入的技术调研。TiDB是分布式NewSQL数据库,兼容MySQL协议,对应用层透明,不需要分库分表中间件。架构上我了解它的三个核心组件——TiDB Server负责SQL解析和协议层,无状态可水平扩展;TiKV负责数据存储,用Raft共识保证多副本一致;PD负责调度和元数据管理。底层存储用LSM Tree,写入先落MemTable再Flush到SST,合并靠Compaction。

和传统分库分表对比,分库分表方案有几个痛点——跨片查询复杂,需要基因法、ES异构索引这些额外手段;扩容麻烦,需要数据迁移,虽然能做在线扩容,但操作成本高;事务支持有限,跨分片事务需要分布式事务方案,要么牺牲一致性,要么牺牲性能。TiDB的自动分片解决了这些问题,对应用层透明,不用关心数据在哪个分片上,扩容时自动rebalance,跨分片事务原生支持(两阶段提交)。

但TiDB也有代价。性能上,单条简单查询的延迟比MySQL高,因为多了分布式协调的开销,OLTP场景的点查性能不如MySQL;运维复杂度上,TiDB的组件比MySQL多,PD、TiKV、TiDB Server三个组件要分别运维;成本上,TiDB推荐的硬件配置比MySQL高,至少要SSD。

选型建议——如果业务量确实大到分库分表维护成本太高,或者有HTAP需求,既要OLTP又要实时分析,TiDB是好选择。如果业务量还在单库或简单分库分表能扛住的阶段,没必要引入TiDB的复杂度。极兔的场景,如果运单量持续增长,分库分表维护成本越来越高,TiDB是一个值得评估的方向。

虽然我没在生产用过TiDB,但有信心快速上手。分布式数据库的核心问题——分片、事务、一致性、扩容——我在分库分表上实战过,原理是相通的。基因法对应TiDB的自动分片,本地消息表对应TiDB的两阶段提交,倍增扩容对应TiDB的rebalance。这些底层问题的解法在不同方案里是映射关系,迁移学习成本主要在运维和调优,不在架构理解。如果极兔在用TiDB或TDSQL,我能比较快地接手。

Oracle和TDSQL的话,Oracle是传统关系型数据库,金融、电信行业用得多。TDSQL是腾讯的分布式数据库,和TiDB类似,都是MySQL兼容的分布式方案。原理相通,如果极兔在用TDSQL,上手会比较快。


Q14:全链路压测你怎么做?

全链路压测是保障系统在高流量下不崩溃的核心手段。我在携程每年春节前都做。

压测方案设计有几个要点。压测目标要明确——系统QPS目标是多少、RT P99不能超过多少毫秒、错误率不能超过多少。压测场景要真实,不能只压单个接口,要模拟真实用户行为链路,比如物流场景,用户下单、运单创建、状态变更、轨迹查询,这条链路要完整压测。压测数据要隔离,用影子表、影子key,不能污染线上数据。

压测工具,我们当时用JMeter加自研压测平台,JMeter做脚本编排,自研平台做压测任务调度和结果汇总。现在开源选择更多,比如JMeter分布式、Gatling、wrk2。

压测执行分三轮。第一轮基线摸底,用较低并发找到当前系统的性能基线。第二轮瓶颈定位,逐步加压,找到第一个瓶颈点——可能是数据库连接池、Redis连接数、服务的线程池。第三轮验证扩容,根据瓶颈点做优化或扩容后,再次压测验证效果。

压测后的容量规划也很重要。根据压测结果算出单机QPS,再根据业务预期增长算出需要的机器数量,留20%到30%的余量。大促期间还要做弹性扩容的准备,Kubernetes的HPA或者手动扩容。


Q15:MySQL回表查询是什么?怎么避免?

回表是这样一回事。InnoDB的索引分聚簇索引和二级索引。聚簇索引的叶子节点存的是完整行数据,主键索引就是聚簇索引。二级索引的叶子节点只存主键值和索引列值。所以走二级索引查的时候,先在二级索引树上拿到主键,再拿主键回到聚簇索引上读完整行——这第二步就是回表,多一次IO。

举个订单系统的例子。订单表上有 user_id 的二级索引,查 SELECT * FROM order WHERE user_id = 123,先在 user_id 索引树上找到主键 order_id,再回聚簇索引查整行。如果这个查询返回1万条订单,就要回表1万次,IO开销很大。

怎么避免?核心思路是让索引覆盖查询字段,走覆盖索引。把 SELECT * 改成 SELECT order_id, user_id,因为二级索引的叶子节点本来就存了这两个值,不用回表。或者建联合索引 (user_id, create_time),查询 SELECT order_id, user_id, create_time FROM order WHERE user_id = 123 也能走覆盖索引。

实际项目里,我会要求团队避免 SELECT *,只查需要的字段。另外对高频查询,会专门设计覆盖索引。但覆盖索引也有代价——索引字段多了,索引体积大,写入变慢。所以要看查询频率和写入频率的权衡,不是所有查询都要做覆盖索引。


Q16:索引下推(ICP)是什么?

索引下推是MySQL 5.6引入的优化,全称Index Condition Pushdown。核心是把原本在回表之后才执行的过滤条件,下沉到索引扫描阶段完成,减少回表次数。

举个例子说明。订单表有联合索引 (user_id, status),注意是联合索引但 status 不在最左前缀的连续位置上。查询 SELECT * FROM order WHERE user_id = 123 AND status = 'PAID' AND amount > 100

没有索引下推的情况:存储引擎用 user_id 在联合索引上扫描,拿到所有 user_id = 123 的记录的主键。然后回表读完整行,再由Server层过滤 status = 'PAID'amount > 100。如果 user_id = 123 有1000条,就要回表1000次。

有索引下推的情况:存储引擎在联合索引上扫描 user_id = 123 的同时,直接在索引上判断 status = 'PAID'(status在联合索引里),只有满足条件的才回表。如果1000条里只有100条 status = 'PAID',就只回表100次,省了900次。amount > 100 这个条件因为 amount 不在索引里,还是要回表后才能判断。

索引下推的适用场景是联合索引 + 范围查询或者多条件查询。在执行计划里能看到 Using index condition 就说明用了索引下推。我在做慢SQL优化时会关注这个,有些慢查询加上合适的联合索引,配合索引下推,性能能提升几倍。


Q17:Spring源码你看过哪些?说说你的理解

Spring源码我重点看过三个模块——IoC容器、AOP、事务。看源码不是为了背流程,是为了遇到问题时能快速定位。

IoC容器核心是BeanFactory和ApplicationContext。Bean的生命周期我理得比较清楚——实例化、属性填充、初始化、销毁。实例化走 createBeanInstance,属性填充走 populateBean,初始化走 initializeBean,里面依次执行 BeanPostProcessorbeforeInitializingBeanafterPropertiesSetinit-methodBeanPostProcessorafter。AOP的动态代理就是在 BeanPostProcessorafter 阶段做的,AbstractAutoProxyCreator 包一层代理。

循环依赖的处理是面试常问的点。Spring用三级缓存解决单例的setter注入循环依赖——singletonObjects(成品)、earlySingletonObjects(半成品)、singletonFactories(工厂)。A依赖B、B依赖A,A实例化后把工厂放进三级缓存,填充属性时发现依赖B,去创建B,B填充属性时发现依赖A,从三级缓存拿到A的工厂提前生成早期引用放进二级缓存,B完成创建后A继续完成。注意构造器注入解决不了,因为实例化阶段就需要依赖,还没到放缓存的步骤。

AOP核心是动态代理。接口走JDK动态代理(Proxy.newProxyInstance),类走CGLIB(生成子类)。我看过 JdkDynamicAopProxyinvoke 方法,里面是责任链模式,把各种Advice包成 MethodInterceptor,按顺序执行。事务的 @Transactional 就是一个 MethodInterceptor,在方法前开启事务、方法后提交或回滚。

事务核心是 PlatformTransactionManagerTransactionInterceptor。声明式事务的传播行为我理解得比较透——REQUIRED(默认,有就加入没有就新建)、REQUIRES_NEW(总是新建,挂起当前)、NESTED(嵌套,基于保存点)。我在订单系统里踩过坑,一个方法里调另一个 @Transactional 方法,传播行为没搞对,内层异常把外层事务也回滚了。后来理清传播行为才解决——内层用 REQUIRES_NEW 就和外层事务隔离了。

看源码的方法我是带着问题看的,不是从头到尾读。遇到Bean创建失败、事务不生效这些问题,我会去源码里打断点跟一遍流程,跟几次就对原理有感觉了。


模块四:工作经验类问答

Q1:你在携程最有成就的一件事?

最有成就感的是抢票系统拿CEO大奖,但对我个人成长最大、也是我经历过最难的一件事是订单系统重构。难不在技术本身,难在"边开飞机边换引擎"——业务不能停,老系统在崩,新系统没验证过,还要在春节前完成。

先说当时有多难。2015年业务从代销转直连,订单量从50万快速涨到100万,老系统是基于.NET的单体架构,单库MySQL。最严重的一次春节抢票高峰,系统挂了2小时,影响几十万用户,VP直接打电话过来。那次故障之后,重构成了必须做的事,但问题在于——火车票业务不能停,没有窗口期让你停下来重构。

我做的是双写加灰度切流方案。这个方案的难处在于,新老系统要并行运行,写入要双写保证一致,读流量要逐步切流。任何一步出问题,就是数据丢失或者订单错乱,这是交易系统,错一笔都是大事。

执行过程中确实踩了坑。双写的两个写操作不是原子的,上线第三天就发现3笔订单数据不一致——老系统有、新系统没有。当时我的第一反应是冷汗都下来了,因为如果这个比例扩大,就是重大事故。我们紧急停下切流,花了一天时间排查,定位到是并发写场景下的事务边界问题。解决方案是引入本地消息表,把业务操作和消息发送放在同一个本地事务里,后台任务轮询发送未确认的消息。这个坑让我学到,双写方案的一致性保障必须在设计阶段就考虑进去,不能等出了问题再补。

架构上我把老系统拆成订单核心、状态机、支付、通知4个微服务,数据层做8库分片加读写分离。这里有个决策很难——分片键选订单ID,但用户查"我的订单"是按用户ID查,怎么定位到哪个库?我设计了基因法,生成订单ID时把用户ID的低4位编码进去,按用户ID查询时提取基因定位到特定库。这个方案是反复推敲过的,也做了PoC验证才敢上线。

状态机统一是另一个难点。老系统的订单状态散落在多个服务里,谁都能改状态,出了问题查不清。我设计了一套统一状态机,状态变更只能通过预定义的动作触发,每次变更产出事件,下游订阅。这个改动牵涉的上下游非常多,我花了两周时间逐一和上下游团队对齐,才把改造方案敲定。

最终结果是系统平稳支撑日均1000万以上订单,春节高峰零P0故障,可用性99.99%。这个99.99%我说明一下口径——统计的是核心下单链路接口可用性,按"失败请求/总请求"算,统计周期是自然年。非核心接口比如订单导出、对账报表这些不在统计范围内,它们允许降级。一年累计53分钟的故障预算,主要消耗在几次小规模的网关抖动和一次Redis主从切换上。

这个项目对我成长最大,不是技术多复杂,而是我第一次完整经历了从问题识别到架构设计到平滑迁移到效果验证的全流程。整个过程最大的考验不是写代码,是在高压下做决策——每一个技术选型都伴随着风险,每一个里程碑都不能延期,任何一个失误都可能影响几十万用户。这之后我对"重构"这件事的信心和判断力都不一样了。


Q2:你在携程做过的最难的决策?

最难的是迁移期间的双写和停机迁移决策。

当时有三个选项。停机迁移——简单直接,但业务方完全不可接受,火车票不能停售。双写加灰度切流——业务无损,但开发量大、风险也大,双写一致性不好保证。Binlog同步加切流——中等复杂度,但Binlog有延迟,交易场景不能接受。

我选了双写方案,核心原因是交易系统不能有任何数据丢失风险。双写虽然开发量大,但每一步都可以验证,出问题可以随时回滚。Binlog方案虽然省事,但延迟不可控,一旦出现数据不一致,排查和修复的代价更大。

执行过程中确实踩了坑,双写的两个写操作不是原子的,出现了3笔数据不一致。解决方案是本地消息表——业务操作和消息发送在同一个本地事务,后台任务轮询发送未确认的消息,消费端幂等处理。

这个决策让我学到的道理是,对于关键系统,宁可前期多投入,也不要在可靠性上取巧。双写方案花了2周开发,换来了迁移期间零故障。如果选Binlog方案省了2周开发,但出了事故可能要花2个月收拾。


Q3:你怎么处理和产品经理的分歧?

携程做订单系统重构时,产品和我就有过一次比较大的分歧。产品要求新功能不能停,因为业务在增长;技术要求停下来重构,因为老系统已经不稳了。两边都去找了VP。

我的处理方式是这样。先把问题量化——我没跟产品说"老系统不行了",而是列数据,过去3个月,老系统每周至少2次线上故障,每次影响多少万用户,运维投入多少人力。这个数据让VP意识到问题的严重性。然后给折中方案而不是二选一——我提了721原则,70%精力做重构,20%做必要的新功能,10%做技术优化,不是完全不接需求,是优先级调整。最后算长期账——如果现在不重构,3个月后春节高峰系统一定扛不住,到那时候再重构,代价是现在的5倍。VP是能算这个账的。

最终VP拍板按我的方案执行,结果春节零P0故障,产品也服气了。

所以技术决策不能只说技术,一定要量化业务影响,让业务方和老板能算账。你说系统有风险,没人听;你说不重构可能影响春节100万用户下单,VP立马拍板。


Q4:你带团队遇到过什么困难?

带20多人团队时最大的困难是骨干流失。

有一年,我团队两个核心骨干先后被其他公司挖走,一个订单系统主负责人,一个出票系统主负责人。两个关键位置同时空缺,团队士气也受影响。

我的处理分几步走。短期止血——我自己顶上订单系统的日常开发,出票系统交给一个有潜力的中级开发,我带他做Code Review。这段时间我自己加班比较多,但保证了系统不出问题。中期补人——从内部转岗了一个对业务有了解的资深开发,外部招了2个人。新人上手需要时间,我做了两件事加速,一是写了一份系统架构文档,之前很多知识在老员工脑子里;二是安排师徒制,每个新人配一个老人带。长期机制——这次事件让我意识到人才梯队的重要性,之后做了几件事:每个核心模块至少2个人懂,主负责人加backup,避免单点依赖;建立骨干成长计划,定期和骨干聊职业发展,了解他们的诉求;适当轮岗,让骨干有成长空间,不至于做腻了想走。


Q5:创业经历给你带来了什么改变?

创业两年,对我改变最大的不是技术,是看问题的视角。

在大厂做技术管理,很容易陷入技术思维,接到需求就想怎么做,很少想这个需求值不值得做。创业之后,因为每个决策都直接关系到公司能不能活下去,我被迫养成了先问业务价值、再问技术方案的习惯。

举个例子,做IoT平台的时候,团队想做一个设备远程诊断功能,技术上挺有意思。但我问了一个问题——这个功能能让更多客户买单吗?答案是,不能,现有客户没提过这个需求,潜在客户也不关心。所以这个功能被砍掉了,精力放在支付分账优化上,因为那直接影响资金到账速度,客户很在意。

这个思维习惯我带到了所有项目里。现在做需求评审,我第一个问题永远是"这个需求验证什么业务假设"、"解决什么业务问题"、"ROI够不够",而不是"这个需求怎么做"。

技术决策也一样,现在我会更关注成本——不仅是开发成本,还有维护成本、迁移成本、团队学习成本。大厂的时候我不太在意这些,因为资源充裕;创业后每一分钱都是自己的,对成本的敏感度完全不同。


Q6:你怎么培养团队里的初级开发?

我培养初级开发的方法是刻意安排超出当前能力一点点的任务。

具体做法是这样。任务分配上,不会把最核心模块交给初级开发,但会给他分一个有挑战的子任务。比如做订单系统时,我会让初级开发负责订单查询接口而不是订单创建接口,查询复杂度低一些,但仍然涉及分库分表的路由逻辑。

辅导节奏上,任务开始前我会和他一起过一遍设计思路,确保方向对。开发过程中通过Code Review及时纠偏。但不会手把手教怎么写代码,那是他应该自己搞定的。

复盘机制上,每个迭代结束让他自己做复盘——做了什么、遇到什么问题、怎么解决的、下次怎么改进。我听完后给反馈,但不替他总结。

成长路径上,每个初级开发我都会规划一条成长路径。比如半年内能独立负责一个模块,一年内能带一个小需求,两年内能做技术方案设计。路径不是承诺,是方向,让他知道往哪里努力。

效果上,携程时期我带过的初级开发,有两个现在已经是其他公司的技术Leader了,这件事我比较骄傲。


Q7:绩效打分后,怎么和绩效C的下属开展绩效沟通?

绩效C沟通是管理里最敏感的一环,我的原则是"对事不对人、有据可依、给出路"。

沟通前我会做两件事。一是把数据备齐,C不能是主观印象,要有具体事例支撑——哪几个需求延期了、哪几次线上故障是他的责任、Code Review 反复出过什么问题。二是提前和HR对齐,确认评级流程合规,避免沟通时被动。

沟通时我会分三步走。先直接告知结果,不绕弯子,"这次绩效你的评级是C",避免给错觉。然后讲清楚为什么是C,每个扣分项配具体事例,"上次订单状态机那个bug,根因是边界条件没考虑,这个在之前review时提醒过"。这一步关键是让对方理解C的依据,而不是觉得被针对。最后聊改进方向和下个周期的预期,"下个周期我们重点解决XX,我会在这些方面多给你支持"。

沟通后还要做一件事,就是持续关注。C对人的打击是很大的,有些人会消沉,有些人会想离职。我会比平时更频繁地和他1对1,及时发现问题。如果他能调整过来,下个周期拿到B甚至A,这是最理想的。如果实在调整不过来,也要给一个明确的期限和退出路径,不拖泥带水。

我遇到过一次比较棘手的情况,一个老员工拿C后情绪很大,觉得自己被亏待。我的处理是先听他讲完,让他把情绪释放出来,然后拿数据说话——我把团队所有人的绩效贡献做了匿名对比,他看了之后自己就理解了。后来他调整过来了,半年后绩效回到B。绩效沟通的核心不是让人开心,是让人认清现状、看到出路。


模块五:职业规划类问答

Q1:你为什么选择极兔?

三个原因。

一是物流赛道的技术挑战足够大,而且和我的经验高度匹配。物流是典型的高并发加复杂状态流转加强一致性要求场景,运单从揽收到签收,经过多个物理节点,每个节点都有状态变更,这和我在携程做的订单状态机是同构的。物流大促期间运单量暴增,和春运抢票的流量特征也很像。我的高并发架构经验、分库分表实战、缓存设计思路,可以直接迁移过来。

二是极兔的增长空间和业务广度吸引我。东南亚市场、跨境电商、仓配一体化,这些业务都在快速扩张,意味着技术架构会持续被挑战,不会是维护存量系统。我喜欢这种业务倒逼技术升级的环境。

三是我的经验能快速产生价值,不需要太长磨合期。订单系统、状态机、高并发、分库分表、团队管理,这些是我在P0场景下实战过的,不是从书上看来的。到极兔之后能快速上手,不会需要半年才能产出。


Q2:你的职业规划是什么?

未来3年我分三个阶段。

第一年融入和证明。快速理解极兔的业务和现有架构,找到最大的技术痛点和优化空间。通过一到两个有影响力的技术项目证明自己,比如运单系统的性能优化、某个核心链路的重构。如果是Leader岗,还要理顺团队流程,建立Code Review和技术分享机制。

第二年架构升级和团队建设。推动核心系统的架构升级——运单系统微服务化改造、缓存体系优化、全链路压测体系建设。这些是我在携程做过的事,知道怎么做、踩过什么坑。团队层面培养两到三个核心骨干,让他们能独立承担子系统。

第三年技术体系建设。从单个系统升级到体系建设——可观测性体系、稳定性保障体系、研发效能工具链。让团队的交付质量和效率有质的提升,而不只是靠堆人。


Q3:你对Leader岗的理解?

Leader岗核心是三件事。

一是技术决策。Leader要做架构决策、技术选型、技术债务治理。这要求Leader自己技术过硬,不能只做管理脱离技术。我自己保持写代码的习惯,不一定写业务代码,但核心模块的Code Review一定参与,关键设计自己动手。

二是团队赋能。Leader的价值不是自己能做多少,是能让团队做多少。我的做法是定方向、给资源、建机制、做兜底——方向定了团队知道往哪走,资源给了团队有能力走,机制建了团队走得稳,兜底做了团队敢走。

三是业务对齐。Leader要能把技术决策翻译成业务价值,让业务方和老板理解为什么要做这个技术优化;也要能把业务需求翻译成技术方案,让团队理解做这个东西的业务意义。


Q4:你期望的工作环境是什么样的?

我期望的工作环境有三个特征。

一是技术决策有话语权。不是说我说的就是什么,而是技术方案能被认真讨论和尊重。如果业务方一句话就能推翻经过充分论证的技术方案,那技术团队就变成了纯执行,做不出好系统。

二是业务和技术有良性互动。技术不是接需求的工具,而是业务的合作伙伴。产品提需求,技术评估合理性和可行性,双方共同讨论优先级和方案。这种互动关系下,技术能发挥更大价值。

三是有容错空间。做有挑战的事情一定会犯错,关键是能从错误中学习。如果一个环境对错误的容忍度极低,大家都会选择不求有功但求无过,技术创新就无从谈起。


模块六:项目深度准备

项目1:携程火车票订单系统重构

项目背景

2015年前后,携程火车票业务从代销模式转向直连模式,订单量快速增长。老系统是基于.NET的单体架构,单库MySQL,所有订单逻辑在一个项目里。日均订单量从50万涨到100万时,系统开始频繁出问题,DB连接池打满、接口超时、订单状态不一致。最严重的一次,春节抢票高峰期系统挂了2小时,影响了几十万用户,VP直接打电话过来。

个人职责

我是订单团队的负责人,从架构设计到落地实施全程主导。团队当时15人左右,我负责整体方案设计、关键技术决策、迁移计划制定,同时参与核心代码编写和Code Review。

技术选型依据

  1. 微服务框架选Spring Cloud:当时公司Java生态已经成熟,Spring Cloud是主流选择。Netflix组件(Eureka/Feign/Hystrix/Zuul)虽然后面有些停止维护,但当时是生产可用的。
  2. 分库分表自研中间件:当时ShardingSphere还不成熟,公司内部有自研的分库分表中间件,基于MyBatis拦截器实现SQL路由。选择自研是因为可控性强,出问题能快速修复。
  3. 消息队列用公司内部MQ:基于Kafka改造,支持顺序消息和事务消息。没有选RocketMQ是因为公司已有稳定的MQ基础设施,没必要引入新组件。
  4. 订单状态机自研:没有用开源的状态机框架,因为订单状态流转有业务特殊性,自研更灵活。核心就是一个状态转移表 + 事件驱动。

遇到的挑战及解决方案

挑战1:平滑迁移,业务不中断

老系统在跑,新系统要上线,不能停机。解决方案是双写 + 灰度切流

踩过的坑:双写的两个写操作不是原子的,出现过3笔数据不一致。解决方案是本地消息表——业务操作和消息发送在同一个本地事务里,后台任务轮询发送未确认的消息。

挑战2:分库分表后的跨片查询

按订单ID分片后,按用户ID查"我的订单"需要扫所有库。解决方案是基因法——生成订单ID时把用户ID的低4位编码进去,按用户ID查询时提取基因定位到特定库。复杂条件查询走ES异构索引。

挑战3:分布式事务

订单创建涉及订单服务、库存服务、支付服务,需要保证一致性。解决方案是本地消息表 + MQ最终一致——不做强一致,用最终一致。原因是Seata的AT模式性能开销大,TCC开发成本高,对订单场景最终一致够用。

挑战4:状态机统一管理

老系统的订单状态散落在多个服务里,谁都能改,出了问题查不清。解决方案是设计统一状态机

项目成果

个人贡献

经验总结与反思

回头看,几个关键决策是对的。双写迁移虽然复杂,但保证了业务无损;状态机统一管理是长期收益最大的决策,后续所有状态相关需求都受益;分库分表的基因法设计,避免了跨片查询的性能问题。

也有几个能改进的地方。双写方案的前期设计应该更充分——本地消息表应该在设计阶段就引入,而不是出了问题再补。微服务拆分粒度可以更粗一些,初期拆得太细导致调用链路过长,后来做了合并。监控告警也应该更早建设,初期靠人工巡检,出过几次没及时发现的问题。


项目2:抢票系统(CEO大奖)

项目背景

12306放票瞬间流量极高,多家OTA在抢同一批余票。携程之前的抢票成功率中等,经常被竞争对手压一头。业务方找到我,希望提升抢票成功率。

个人职责

独立设计整个抢票系统的架构和核心算法。团队有3-4个开发协助实现,但核心方案是我一个人设计的。

技术选型依据

  1. 任务调度自研:抢票任务的调度逻辑有业务特殊性(优先级、合并、重试),开源调度框架不适用。
  2. 多线程并发用Java ThreadPoolExecutor:可控性强,能精确控制并发度和拒绝策略。
  3. 余票监控用爬虫 + 推送双通道:爬虫主动抓取 + 12306开放接口被动接收,互为补充。

遇到的挑战及解决方案

挑战1:高并发下的请求调度

放票瞬间可能有几万个抢票请求同时进来,但实际可抢的票可能只有几十张。不能让所有请求都打到12306。

解决方案是任务分级 + 任务合并

挑战2:抢票成功率提升

单次抢票成功率受12306接口限制,怎么提升整体成功率?

解决方案是多引擎并发

挑战3:余票预测和预热

解决方案是余票监控 + 预测

项目成果

个人贡献

独立设计了整个系统的架构和核心算法,包括任务分级机制、多引擎并发策略、任务合并逻辑、余票监控预测。

经验总结与反思

回头看,几个关键决策是有效的。任务合并是核心创新,既减少了对12306的压力,又提高了成功率;多引擎并发是简单但有效的策略;余票预测让抢票从被动等待变成主动出击。

也有踩坑的地方。初期对12306的反爬策略预估不足,被封过几次IP,后来做了IP池轮换。任务合并的分配逻辑初期纯按优先级,不够公平,后来加了"等待时长"权重。


项目3:全自动出票系统

项目背景

之前出票是半人工的——系统自动下单后,出票环节需要运营人员手动确认和处理异常。旺季人力成本高,出票速度慢影响用户体验。

个人职责

设计三引擎互备架构,主导核心出票流程和异常处理逻辑的设计。

技术选型依据

  1. 三引擎互备:出票服务、调度服务、爬虫服务三个独立服务,任一故障自动切换。选择三引擎而不是双引擎,是因为出票涉及外部系统(12306)不稳定,需要更强的容错。
  2. 爬虫服务用独立技术栈:出票和调度用Java,爬虫用Python(爬虫生态更成熟)。

遇到的挑战及解决方案

挑战1:出票流程自动化

之前依赖人工确认的关键环节是验证码识别和异常处理。

解决方案:

挑战2:引擎故障切换

三个引擎可能同时出问题(比如12306整体不可用)。

解决方案:

挑战3:出票结果回填

出票成功后要更新订单状态、发送通知、同步数据。

解决方案:出票结果通过MQ广播,订单服务、通知服务、数据服务各自订阅处理,解耦。

项目成果

经验总结与反思

几个关键设计是有效的。三引擎互备的容错设计是关键,保证了出票链路的稳定性;异常处理的三级策略,平衡了自动化率和人工兜底;MQ解耦让出票结果分发可扩展。

也有能改进的地方。验证码识别初期依赖打码平台,成本高且不稳定,应该更早投入自建模型。异常处理的规则库需要持续维护,初期没有建立规则更新机制,导致一些新异常类型没被覆盖。


项目4:Global Rail GDS 国际化

项目背景

携程火车票要拓展国际市场,需要集成中英法德等多国铁路产品,做国际分销。之前国内系统的架构不支持多国多供应商接入。

个人职责

组建团队从0到1研发,负责整体架构设计和供应商对接方案。

技术选型依据

  1. Adapter模式对接多供应商:每个供应商的接口协议不同(有的SOAP、有的REST、有的XML),用Adapter模式做统一适配,上层调用统一接口。
  2. 多时区多币种处理:用Java 8的Time API处理时区,币种转换用独立的汇率服务。

遇到的挑战及解决方案

挑战1:多供应商接口差异大

每个供应商的接口协议、数据格式、业务规则都不同。

解决方案:Adapter模式 + 统一领域模型。定义一套统一的"国际火车票"领域模型,每个供应商写一个Adapter做转换。上层服务只和统一模型打交道,不感知供应商差异。

挑战2:多时区多币种

解决方案:

挑战3:团队从0组建

解决方案:从国内火车票团队调了2个骨干,外部招了3个人。初期我亲自带,边做边培养。

项目成果

经验总结与反思

回头看,Adapter模式是多供应商场景的正确选择——后续新增供应商只需要写Adapter,不改上层逻辑。统一领域模型设计也让系统有良好的扩展性。

但有些地方可以做得更好。国际化初期对各国铁路业务规则差异预估不足,有些供应商的Adapter写得比较复杂,应该更早抽象公共逻辑。多语言支持初期也做得不够完善,只做了中英文,法德文是后补的。


项目5:动态缓存策略

项目背景

携程火车票查询量巨大,热门线路QPS上万。之前所有查询都走统一缓存策略,导致Redis资源紧张,同时冷数据占用了大量缓存空间。

个人职责

设计"主动+被动"分级缓存方案。

遇到的挑战及解决方案

挑战1:如何区分热数据和冷数据

解决方案:

挑战2:热数据缓存一致性

热数据要求实时性,不能有过期延迟。

解决方案:数据变更时主动推送到Redis,不依赖过期刷新。用Binlog监听 + Canal推送的方式,DB变更后毫秒级同步到缓存。

挑战3:冷数据缓存命中率低

冷数据访问频率低,但一旦访问也需要快速响应。

解决方案:Cache-Aside模式 + 合理的TTL。冷数据首次查询后缓存5分钟,命中率高且不会浪费空间。

项目成果

经验总结与反思

几个核心设计是有效的。分级缓存的核心思路——按热度差异化处理,是正确的方向;Binlog监听推送保证了热数据的实时性;动态热度调整让系统自适应业务变化。

也有能改进的。冷热数据的边界初期是人工设定的,后来才做成动态调整,应该一开始就做动态的。缓存预热机制也不够完善,大促前需要手动预热,应该做成自动化的。


项目6:IoT共享设备智能运营平台

项目背景

创业项目,做共享设备平台。设备投放后需要实时监控状态、处理用户租借请求、自动计费分账。

个人职责

技术合伙人,对全部技术战略和研发交付负责。从0到1搭建平台,团队3人。

技术选型依据

  1. 设备接入层用Netty自研:设备资源有限,MQTT协议较重,自定义Protobuf协议更轻量,消息体积小,通信效率高。
  2. 后端用Spring Boot + MyBatis:团队Java背景,快速上手。
  3. 数据库MySQL + Redis:初期数据量不大,单库够用。Redis做设备状态缓存和分布式锁。
  4. 支付对接微信/支付宝:国内市场必须支持的两个支付渠道。

遇到的挑战及解决方案

挑战1:海量设备长连接管理

设备通过TCP长连接和服务端通信,需要管理连接生命周期、心跳检测、断线重连。

解决方案:Netty自研设备接入层

挑战2:设备指令下发的可靠性

用户扫码租借时,服务端要向设备下发"开门"指令。指令丢了用户体验很差。

解决方案:指令确认机制

挑战3:支付分账系统

设备产生的收益要按比例分给平台和设备主,需要自动分账。

解决方案:

挑战4:3人团队如何高效交付

解决方案:

项目成果

经验总结与反思

回头看,Netty自研接入层是正确的选择,性能和可控性都满足需求。指令确认机制保证了关键操作的可靠性。轻量级敏捷流程也适合小团队,不搞重流程。

也有几个坑。初期设备协议设计不够前瞻,后续加新设备类型时改了协议,应该一开始就做版本兼容。分账系统的规则引擎初期写死了,后来客户要求灵活配置分账比例,做了重构,应该一开始就做成可配置的。


项目7:TripPlan — AI定制游行程规划SaaS

项目背景

面向入境游市场,用LLM Agent替代旅行社人工行程规划。传统方式旅行社人工规划需2-5天,成本高、效率低。

个人职责

从0到1独立完成架构设计和核心代码实现。采用DDD领域驱动设计,拆分为9个核心模块。

技术选型依据

  1. Python + FastAPI:LLM场景IO密集型,异步是刚需。FastAPI原生支持async,性能接近Node.js。AI相关SDK(LlamaIndex等)都是Python优先。
  2. PostgreSQL:支持JSONB字段,适合存储半结构化的行程数据。JSON操作符丰富,查询灵活。
  3. LLM用Function Calling:比纯Prompt更可控。LLM负责理解和创意,代码负责价格、状态、资金安全。
  4. Stripe支付:面向国际市场,Stripe覆盖面广,API设计规范,PaymentIntent + Webhook模式成熟。

遇到的挑战及解决方案

挑战1:LLM输出的不确定性

LLM生成的行程方案可能不符合业务约束(时间冲突、预算超支)。

解决方案:三层约束求解引擎

挑战2:Function Calling的稳定性

LLM的Function Calling参数偶尔会出错(字段缺失、类型错误)。

解决方案:

挑战3:支付状态机的幂等性

Stripe的Webhook可能重复发送,必须保证幂等。

解决方案:

挑战4:多币种定价

不同客户用不同币种(USD/EUR/CNY),汇率实时变动。

解决方案:动态定价引擎

项目成果

经验总结与反思

几个核心决策是有效的。"LLM负责理解和创意,代码负责价格/状态/资金安全"这个设计哲学是核心,保证了系统的可靠性;约束求解引擎让LLM输出可控,而不是放任LLM自由发挥。

也有几个明显的坑。最突出的就是9个限界上下文的拆分在MVP阶段过度设计了——行程规划、订单支付、资源查询3个就够,通知用领域事件解决、用户管理用现成认证服务,业务跑起来再细化。前期在领域模型上花的时间拖慢了交付。市场验证不够充分,产品做得完善了才发现获客成本高,应该更早做市场测试。LLM的成本控制也做得不够,每次生成行程的Token消耗较大,应该做缓存和Prompt优化。


项目8:AI智能客服系统

项目背景

面向企业场景的LLM + RAG智能客服,替代传统客服机器人。支持Web + 飞书双渠道接入。

个人职责

架构设计和核心模块实现。

技术选型依据

  1. LlamaIndex做RAG:LlamaIndex的IngestionPipeline设计完善,数据处理流程清晰,社区活跃。
  2. Qdrant做向量数据库:性能好,支持过滤查询,部署简单。比Pinecone更可控(自部署),比Milvus更轻量。
  3. BGE做Embedding:中文场景效果好,比OpenAI的ada-002在中文语义召回上更准。
  4. FastAPI + 异步架构:客服场景高并发,异步IO是刚需。

遇到的挑战及解决方案

挑战1:意图识别准确率

用户问题五花八门,单纯靠LLM做意图分类成本高且不稳定。

解决方案:规则引擎 + LLM双层路由

挑战2:RAG检索质量

向量检索的召回率不够,有些相关问题召不回来。

解决方案:

挑战3:系统弹性

客服系统可能遇到流量突增(比如产品出了问题,大量用户来问)。

解决方案:四层限流 + 熔断

挑战4:工程化质量

解决方案:

项目成果

经验总结与反思

几个核心设计是有效的。双层路由是性价比最高的方案,规则引擎处理高频,LLM处理长尾;四层限流设计让系统在大流量下可控;Protocol协议编程让模块解耦,后续替换LLM或向量库成本低。

也有能改进的。RAG的分块策略初期比较粗糙,应该做语义分块而不是固定长度分块;缺少用户反馈闭环,应该让用户对答案打分,用反馈数据持续优化检索和生成质量;飞书渠道的接入初期有些兼容性问题,应该更早做集成测试。


项目9:SGS DDD重构

项目背景

SGS的企业级数字化项目,老系统是传统的三层架构(Controller-Service-DAO),业务逻辑散落在Service层,代码臃肿,新业务接入成本高。

个人职责

技术经理,主导DDD重构,管理10人研发团队。

遇到的挑战及解决方案

挑战1:团队对DDD不熟悉

解决方案:

挑战2:老系统如何渐进式重构

解决方案:

挑战3:团队效率提升

解决方案:

项目成果

经验总结与反思

回头看,渐进式重构是正确的策略,不做一次性大重构,风险不可控。先试点再推广,降低团队学习曲线。DDD的核心价值是通用语言对齐,不只是代码层面的改进。

也有能改进的。DDD培训可以更实战化,初期偏理论,团队吸收效果一般。防腐层的设计初期不够完善,新旧代码边界有些模糊。应该更早建立领域模型的持续维护机制,模型和代码的同步容易脱节。


模块七:面试轮次应对策略

大厂一般多轮面试,每轮考察重点不同,应对策略也要差异化。

1面:技术基础 + 项目 + 管理能力

1面通常是技术面,考察基础扎实度和项目深度。

技术基础部分,高频考点是Java并发(线程池、锁、CAS)、Spring源码(IoC、AOP、事务传播)、MySQL(索引、锁、事务隔离级别、回表、索引下推)、Redis(缓存一致性、持久化、集群)、消息队列(幂等、顺序、堆积)。这部分我的策略是用"实际项目场景+原理"回答,不背概念。比如问索引下推,我会先讲原理,再讲在订单系统慢SQL优化里怎么用的。

项目部分,1面面试官会挑一个项目深挖,通常是2-3个"为什么"的追问层级。我的策略是准备一个最有挑战的项目故事——订单系统重构是首选,因为挑战多维(技术、业务、管理都有),经得起深挖。讲的时候用STAR结构(情境-任务-行动-结果),但不要说出STAR这个词,自然讲出来就行。关键是要有"当时有多难"、"我做了什么决策"、"踩了什么坑"、"结果怎样"这四个要素。

管理能力部分,1面可能初步问一下带团队的经验。策略是讲具体事例,不讲理念。比如问"怎么培养团队",我会讲携程时期怎么带初级开发,具体怎么分任务、怎么做Code Review、怎么规划成长路径,用事实说话。

后续轮次(交叉面/总监面/HR面)

交叉面通常跨团队,考察技术广度和协作能力。策略是多讲跨团队协作的案例,比如订单系统重构时怎么和上下游团队对齐。

总监面考察全局观和价值观。策略是多讲业务价值视角的决策,比如"为什么选这个方案"、"怎么看技术债"。这一轮不要太抠技术细节,要能跳出技术讲业务。

HR面考察稳定性和文化匹配。高频问题包括"为什么离开上家"、"为什么选我们"、"职业规划"。策略是真诚但有分寸,不要吐槽前公司,讲选择的原因要正向。

面试节奏控制

一个回答控制在2-3分钟,不要长篇大论。讲完一个点停顿一下,看面试官反应——他在记笔记就等一下,他想追问就让他问,他点头示意继续就往下讲。

如果面试官打断追问,说明他对你正在讲的点感兴趣,这是好事。停下来回答他的追问,回答完再回到原来的主线。

遇到不会的问题,直接说"这个我没深入研究过,但我的理解是...",不要硬装。诚实+有思考过程,比胡编强太多。


模块八:极兔面试高频真题专项

以下题目来源于脉脉、牛客、Boss直聘等平台极兔Java后端/技术专家岗的真实面经汇总,按考察频次和难度排序。每题都配了实战回答思路。

一、数据库高频题

Q1:InnoDB为什么默认用RC而不是RR?RR解决了什么RC没解决?

这个问题极兔几乎每场都问。核心要讲清楚RC和RR的区别、以及为什么InnoDB官方默认是RR但很多大厂改成RC。

先说区别。RC(Read Committed)解决了脏读,但会有不可重复读——同一事务里两次读同一行可能读到不同值。RR(Repeatable Read)解决了不可重复读,同一事务里多次读同一行结果一致。RR还能在一定程度上防幻读。

再说InnoDB的实现。RR靠MVCC实现一致性读,事务开始时生成ReadView,之后所有读都用这个ReadView,所以多次读到的是同一快照。RC是每次SELECT都生成新的ReadView,所以能看到其他事务已提交的更新。

为什么很多大厂把默认隔离级别从RR改成RC?主要是性能考虑。RR为了防幻读,会用间隙锁(Gap Lock)和临键锁(Next-Key Lock),锁的范围大,高并发写入场景下锁冲突严重,吞吐量下降。RC只用记录锁,不加间隙锁,并发性能好很多。而且大部分业务场景下,不可重复读不是问题——一个事务里多次读同一行本来就少见,即使出现了,业务上也能接受。

极兔的物流场景,运单状态变更是高频写入,如果用RR的间隙锁,锁冲突会很严重。用RC配合MVCC,既保证读到的都是已提交数据,又能保持高并发写入能力。我在携程订单系统也是用RC,道理一样。

Q2:MVCC原理讲一下

MVCC是多版本并发控制,核心思路是同一行数据保留多个版本,读操作读旧版本快照,写操作写新版本,读写不互相阻塞。

InnoDB的MVCC实现依赖三个东西。一是隐藏字段,每行有两个隐藏列——DB_TRX_ID(最近修改的事务ID)和DB_ROLL_PTR(指向undo log的指针)。二是Undo Log链,每次更新一行,旧版本写到undo log,通过DB_ROLL_PTR串成版本链。三是ReadView,事务读数据时生成一个快照,包含当前活跃事务列表。

读数据时怎么判断用哪个版本?拿到行的DB_TRX_ID,和ReadView里的活跃事务列表对比。如果DB_TRX_ID小于ReadView里最小活跃事务ID,说明这个版本在ReadView生成前就提交了,可见。如果DB_TRX_ID在活跃事务列表里,说明这个版本的事务还没提交,不可见,顺着DB_ROLL_PTR找上一个版本。一直找直到找到可见的版本。

RC和RR的区别就在于ReadView生成时机。RC每次SELECT都生成新的ReadView,所以能看到最新已提交的。RR在事务第一条读SQL时生成ReadView,之后都用这个,所以多次读到的是同一快照。

Q3:Undo Log、Redo Log、Binlog的区别

这三者面试必问,容易混淆。

Undo Log(回滚日志)作用是事务回滚和MVCC。记录的是修改前的旧值。事务执行过程中,先写undo log再修改数据。如果事务回滚,用undo log恢复。MVCC读旧版本也是从undo log链上找。undo log在undo表空间里,默认独立。

Redo Log(重做日志)作用是崩溃恢复,保证持久性。记录的是物理修改(哪个页哪个偏移量改了什么)。事务提交时先写redo log(WAL机制),再写数据页。数据库崩溃后重启,用redo log恢复已提交但未刷盘的数据。redo log是环形写,固定大小,循环覆盖。

Binlog(二归档日志)是MySQL Server层的日志,不是InnoDB独有的。记录的是逻辑修改(SQL语句或行变更)。主要用于主从复制和数据恢复。事务提交后写binlog。

三者的关系:InnoDB写redo log(prepare状态)→ 写binlog → 写redo log(commit状态),这是两阶段提交,保证redo log和binlog的一致性。我在做订单系统迁移时,就是通过监听binlog做数据同步的。

Q4:MySQL的锁有哪些?S锁X锁、间隙锁、临键锁

锁是极兔面试的高频考点,尤其是RC和RR锁的区别。

按粒度分,InnoDB主要是行锁,也有表锁(意向锁)。按类型分,S锁(共享锁,读锁)和X锁(排他锁,写锁)。S锁之间兼容,S和X不兼容,X和X不兼容。

行锁的实现有三种。记录锁(Record Lock)锁住索引上的一条记录。间隙锁(Gap Lock)锁住索引记录之间的间隙,防止其他事务往间隙里插入数据,目的是防幻读。临键锁(Next-Key Lock)是记录锁加间隙锁的组合,锁住一条记录和它前面的间隙。

RC和RR的锁差异是关键。RC只用记录锁,不用间隙锁(除了外键和唯一键检查)。RR用临键锁,锁的范围大。这就是RR并发性能差的原因——间隙锁会阻止其他事务在间隙里插入,并发写入受影响。

我在订单系统里踩过锁的坑。有个并发更新库存的场景,用了RR,两个事务同时更新不同订单但涉及同一区间,间隙锁冲突导致死锁。后来改成RC,问题解决。这也是为什么物流场景倾向用RC。

Q5:inner join和left join的区别?A表8条B表8条,join后最多最少多少条?

这是个快速判断题,极兔二面出现过。

inner join取交集,A表8条B表8条。最多64条(每条A和每条B都能匹配,8×8)。最少0条(一条都匹配不上)。

left join以左表为主,A表8条。最多64条(每条A匹配8条B)。最少8条(每条A都匹配不到B,但左表数据保留,B字段为null)。

延伸一下right join以右表为主,full join取并集,道理类似。


二、Java高频题

Q6:AQS原理讲一下,CAS呢

AQS(AbstractQueuedSynchronizer)是Java并发包的核心框架,ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock都基于它。

核心是两个东西。一是state变量,volatile int,不同的同步工具赋予不同的含义——在ReentrantLock里表示锁被重入了几次,在CountDownLatch里表示还剩多少个计数,在Semaphore里表示剩余许可数。二是CLH双向队列,获取锁失败的线程会被包装成Node节点入队,自旋等待前驱节点释放。

获取锁的流程以ReentrantLock的非公平锁为例。线程先CAS尝试把state从0改成1,成功了把当前线程设为持有者。如果state不是0,检查持有者是不是自己,是就state加1(重入)。都不是就进队列等待。公平锁的区别是,先检查队列里有没有排在前面的线程,有就不插队。

CAS(Compare And Swap)是AQS实现无锁操作的基础。三个参数——内存位置、预期值、新值。只有内存位置的值等于预期值时,才更新为新值,返回true。否则不更新,返回false。Java通过Unsafe类的native方法实现,底层依赖CPU的cmpxchg指令。CAS的问题是ABA——值从A改成B又改回A,CAS检测不到。解决办法是版本号(AtomicStampedReference)。

Q7:锁升级过程讲一下

这是JDK 1.6之后synchronized的优化,极兔二面问过。

synchronized的锁有四种状态,从低到高:无锁、偏向锁、轻量级锁、重量级锁。只能升级不能降级。

无锁状态,对象头没有锁信息。第一个线程访问时,JVM把对象头里的Mark Word设为偏向这个线程的ID,这就是偏向锁。之后这个线程进出同步块不需要CAS操作,只是判断一下对象头的线程ID是不是自己,效率最高。

当有第二个线程来竞争时,偏向锁升级为轻量级锁。JVM为每个线程在栈帧里分配Lock Record,用CAS把对象头的Mark Word交换到Lock Record里。成功的就是持有者,失败的线程自旋等待。轻量级锁适合两个线程交替执行、没有真正竞争的场景。

如果自旋超过一定次数(默认10次,自适应自旋会动态调整),或者有第三个线程来竞争,升级为重量级锁。重量级锁依赖操作系统的Mutex,未竞争到锁的线程会被挂起(park),切换到内核态,开销大。我们在抢票系统里用StampedLock而不是synchronized,就是因为抢票场景读多写少,synchronized的重量级锁开销太大。

Q8:CountDownLatch和Future用过吗?场景是什么

CountDownLatch是一个同步辅助类,允许一个或多个线程等待其他线程完成操作。核心是countDown()减计数和await()等待计数归零。

我在携程订单系统用过这个场景。订单创建后要并行查多个供应商的报价,等所有报价都回来再选最优。我用CountDownLatch,count设为供应商数量,每个查询线程完成后countDown,主线程await。这样把串行的N次查询变成并行,RT从N倍降到1倍。

Future是异步计算的占位符。提交一个Callable给线程池,返回Future,主线程可以继续做别的,需要结果时调future.get()。我在抢票系统里用了CompletableFuture(Future的增强版),多引擎并发抢票,每个引擎提交一个异步任务,anyOf拿到第一个成功的结果就用,cancel掉其他的。

CountDownLatch和Future的区别是——CountDownLatch是"等N个线程都完成",Future是"提交一个任务异步执行,需要时取结果"。一个是等待多个,一个是异步单个。

Q9:HashMap为什么引入红黑树?

JDK 1.8之前HashMap用数组加链表,hash冲突时同一个桶里用链表存。问题是链表太长时查询退化为O(n)。恶意构造hash冲突的攻击(hash碰撞攻击)能让查询性能急剧下降。

1.8引入红黑树,当链表长度超过8且数组长度超过64时,链表转红黑树。红黑树是自平衡二叉查找树,查找O(logn)。当节点数降到6时,退回链表(避免树的维护开销)。

为什么阈值是8?源码注释里有解释,理想情况下hash分布均匀,链表长度达到8的概率是0.00000006,几乎不会发生。一旦发生说明hash冲突严重,用树能显著提升性能。6是退化的阈值,7是缓冲带,避免在8附近频繁来回转换。


三、JVM高频题

Q10:JVM内存模型讲一下,哪些共享哪些私有

JVM运行时数据区分为线程共享和线程私有两类。

线程私有的有三个。程序计数器(PC Register),记录当前线程执行的字节码行号,线程切换后能恢复到正确位置。虚拟机栈,每个方法调用创建一个栈帧,存局部变量表、操作数栈、动态链接、方法出口。局部变量表存基本类型和对象引用。本地方法栈,和虚拟机栈类似,服务的是native方法。

线程共享的也有两个。堆(Heap),存对象实例和数组,GC的主战场。分新生代(Eden + 2个Survivor)和老年代。方法区(Method Area,1.8后是Metaspace),存类信息、常量、静态变量、JIT编译后的代码。1.8之前是PermGen(永久代),1.8改成了Metaspace,用本地内存,避免了永久代OOM的问题。

对象分配的过程。新对象先分配在Eden,Minor GC后存活的对象进Survivor,年龄默认到15进老年代。大对象(超过-XX:PretenureSizeThreshold)直接进老年代,避免在新生代来回复制。

Q11:G1和CMS的区别?线上GC调优怎么做

CMS(Concurrent Mark Sweep)是老年代垃圾收集器,目标是低停顿。并发标记、并发清除,停顿时间短。缺点是产生碎片(标记-清除算法,不压缩),并发模式失败时会退化为Serial Old,停顿很长。CMS在JDK 9被废弃,JDK 14移除。

G1(Garbage First)是JDK 9之后的默认收集器。把堆分成多个Region(默认2048个),每个Region可以是Eden、Survivor、Old或Humongous。G1优先回收垃圾最多的Region(Garbage First),所以叫G1。G1用标记-复制算法,没有碎片。停顿时间可预测,通过-XX:MaxGCPauseMillis设目标停顿时间,G1根据这个目标选择回收哪些Region。

两者的核心区别。CMS分代收集,G1分Region收集。CMS只管老年代,G1管整个堆。CMS有碎片,G1没有。CMS停顿时间不可控,G1可预测。

线上GC调优我做过。携程订单系统有一次Full GC频繁,STW导致接口超时。排查过程是先看GC日志,发现老年代占用持续增长,Full GC后回收很少,说明内存泄漏。用jmap dump堆内存,MAT分析发现是一个本地缓存没设过期时间,对象一直被引用。修复后Full GC消失。调优经验是,先定位是频繁Minor GC还是Full GC,Minor GC调大新生代或Survivor比例,Full GC要看是内存泄漏还是老年代太小。


四、中间件高频题

Q12:消息积压怎么处理?加了消费者还是没提升是什么原因?

消息积压是极兔物流场景的高频题,运单量暴增时很容易遇到。

常规处理思路。先看是生产端太快还是消费端太慢。生产端太快就限流,或者临时扩容消费端。消费端太慢就看是处理逻辑慢还是IO瓶颈。逻辑慢就优化逻辑,比如批量处理代替单条处理。IO瓶颈就优化数据库、加缓存。

如果加了消费者数量,消费速度还是没提升,可能有几个原因。一是消费逻辑里有锁竞争,多个消费者线程抢同一把锁,并行变成串行。二是下游瓶颈,比如数据库连接池打满,消费者都在等连接。三是消息分区不均,比如Kafka的partition数固定,消费者数超过partition数就没用了。四是消费逻辑里有批量操作,消费者多了但每次batch的量没变,总吞吐没提升。五是网络瓶颈,带宽打满了。

我在携程遇到过一次。抢票高峰MQ积压,加了消费者没用,排查发现是消费逻辑里要写DB,DB连接池只有50,消费者都卡在等连接。把连接池调到200,积压消除。

Q13:Redisson的看门狗机制讲一下

Redisson是Redis的Java客户端,分布式锁是它的核心功能之一。看门狗机制是为了解决锁过期但业务没执行完的问题。

普通的Redis分布式锁有个问题——设了过期时间,但持有锁的线程业务执行时间超过过期时间,锁被自动释放,其他线程拿到锁,导致并发问题。

Redisson的看门狗机制是这样。加锁时不设过期时间(或设-1),Redisson默认给30秒的过期时间,同时启动一个后台线程(看门狗),每10秒检查一次,如果锁还被当前线程持有,就续期到30秒。这样只要线程活着,锁就不会过期。业务执行完,主动释放锁,看门狗停止。

如果持有锁的进程崩溃了,看门狗也停了,锁会在30秒后自动释放,不会死锁。

看门狗的底层是Lua脚本,保证检查和续期的原子性。默认的续期时间可以通过lockWatchdogTimeout参数配置。

Q14:@Transactional事务失效的场景有哪些?内部调用怎么办?

这是极兔一面真题。事务失效的常见场景。

一是方法不是public的。Spring AOP代理只能拦截public方法,非public方法加@Transactional无效。

二是自调用。同一个类里方法A调方法B,B上的@Transactional不生效。因为AOP是基于代理的,自调用不经过代理,直接走目标对象的方法。极兔面试官追问"如果就是要内部调用怎么办"——解决方案有三个:把方法B拆到另一个类里注入调用;在类里注入自己的代理对象(AopContext.currentProxy());用编程式事务TransactionTemplate手动控制。

三是异常被catch了。方法里try-catch了异常没抛出,Spring感知不到异常,不会回滚。解决办法是catch后手动rollback,或者重新抛出。

四是异常类型不对。Spring默认只对RuntimeException和Error回滚,checked异常不回滚。要回滚checked异常,需要配rollbackFor = Exception.class。

五是数据库引擎不支持事务。MyISAM不支持事务,要换InnoDB。

六是传播行为配错。比如内层方法配了NOT_SUPPORTED,外层事务会被挂起。

我在订单系统踩过自调用的坑。订单服务里createOrder方法调本类的deductInventory方法,deductInventory上的@Transactional没生效,扣库存失败但订单创建成功了。后来把deductInventory拆到独立的库存服务里,通过Feign调用,事务才正常。


五、场景设计题

Q15:设计一个每日1亿数据量的订单系统,支持多维度查询

这是极兔一面的场景设计大题,考察架构设计能力。

数据存储层。1亿/天的数据量,单库肯定扛不住。按订单ID做水平分库分表,假设保留90天就是90亿数据,分16库×64表=1024张表,每张表约880万数据,单表可控。分片键用订单ID,配合基因法解决按用户ID查的跨片问题(基因法原理前面分库分表那题讲过,这里不重复)。冷数据归档到HBase或TiDB,只保留最近90天热数据在MySQL。

多维度查询。按订单ID、用户ID查走MySQL分片。按时间、状态、渠道等复杂条件查,走ES异构索引。通过Canal监听MySQL binlog,实时同步到ES。ES支持任意条件组合查询,分页、聚合都能做。管理后台的多维度查询直接打ES,不打MySQL。

架构层。网关层做限流和鉴权。应用层微服务化,订单服务、状态机服务、查询服务分开。查询服务专门服务多维度查询,读ES。订单服务负责写入,写MySQL。读写分离。

高可用。MySQL主从+分片,ES集群多副本,Redis集群缓存热点订单。全链路压测,大促前扩容。消息队列做异步解耦,订单创建后发MQ,下游各订阅各的。

数据一致性。订单写入MySQL后,通过binlog同步到ES,有秒级延迟。对实时性要求高的查询(比如刚下单立刻查),走MySQL。对实时性要求不高的多维度查询,走ES。如果有不一致,靠日终对账兜底。

这个方案的核心思路是写走MySQL保证事务,读走ES保证查询灵活性,通过binlog同步。这是大厂订单系统的标准架构,美团、京东都是类似方案。我在携程做的就是这套,只是数据量没到1亿/天,但架构是可以水平扩展的。


模块九:反问清单

  1. 极兔目前的运单系统架构是怎样的?最大瓶颈在哪里?
  2. 这个团队目前有多少人?主要的技术挑战是什么?
  3. 跨境电商业务的技术系统和国内是同一套吗?有没有额外的架构挑战?
  4. 对于Leader岗,公司对技术体系建设的期望是什么?短期最希望解决什么问题?
  5. 团队目前的技术栈和工具链是怎样的?有用到什么新技术方向的探索吗?