极兔物流 — 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库方案来讲。

分片策略是水平分库,按订单号做hash取模路由到8个库。每个库本身再做读写分离,1主2从,写走主库,读走从库。订单号采用公司统一的订单号生成方案,自带分片基因——生成时就把分库信息编码进去了,路由时直接从订单号里提取分库号,不需要额外计算。

分片键选订单号,因为80%以上的查询场景是按订单号查。但有个问题,用户查看"我的所有订单"是按用户ID查,怎么定位到哪个库?这个场景我们没有用基因法,而是通过在用户ID维度建索引表解决——用户ID映射到订单号列表,再路由到具体库查详情。索引表本身就是按用户ID分片,查"我的订单"时先查索引表拿到订单号列表,再回订单分片库取详情。这套方案是公司框架团队设计好的,业务侧配一下分片规则就能用。

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

扩容方面,从8库扩到16库的时候,我们做的是翻倍扩容——直接把原来的一组从库升级为主库,每个新主库再挂新的从库。翻倍扩容的好处是不需要做数据迁移,原来 hash(orderId) % 8 = n 的记录,扩容后 hash(orderId) % 16 = nn+8,正好分布在原来的主库和新升主库上,只需要调整路由规则就行。这个方案是框架团队设计好的,业务侧配一下路由规则就完成扩容,非常省事。

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

数据保留策略这块也要说一下——MySQL只保留6个月热数据,历史数据归档到HBase。归档任务每天跑,把6个月前的订单数据写入HBase后从MySQL删除。查询历史订单走HBase,对用户透明。这样MySQL的单表数据量始终保持可控,不会因为历史数据堆积影响查询性能。

关于ShardingSphere,携程当时用的是自研的分库分表中间件,基于MyBatis拦截器实现SQL路由。当时ShardingSphere(还叫Sharding-JDBC)还在早期阶段,功能不完善,公司内部有自研的中间件,出问题能快速修复。但ShardingSphere在2020年4月已经成为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,叫QMQ,由框架团队维护,支持顺序消息和事务消息。公司已有的MQ基础设施稳定,运维和监控体系完备,没必要引入开源组件增加运维负担。创业项目因为是从0开始,用过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万,老系统是基于Java的单体架构,单库MySQL,所有订单逻辑堆在一个项目里。最严重的一次春节抢票高峰,系统挂了2小时,影响几十万用户,VP直接打电话过来。那次故障之后,重构成了必须做的事,但问题在于——火车票业务不能停,没有窗口期让你停下来重构。

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

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

架构上我把老系统拆成订单核心、状态机、支付、通知4个微服务,数据层做8库分片、1主2从读写分离。分片路由用订单号hash取模,订单号是公司统一的生成方案,自带分片基因——路由时直接从订单号提取分库号,不需要额外计算。按用户ID查"我的订单"通过索引表解决,用户ID映射到订单号列表再路由。扩容用翻倍扩容,8库扩16库时从库升主库就行,不用迁移数据,这是框架团队设计好的方案。数据保留6个月,历史归档到HBase。

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

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

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


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

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

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

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

执行过程中确实踩了坑,双写的两个写操作不是原子的,出现过3笔数据不一致。具体原因是——写入老系统成功后、写入新系统之前应用挂了,老系统有数据新系统没有;还有一种更隐蔽的情况是,两个写操作分别在不同事务里,老系统事务提交了但新系统事务回滚了。解决方案是本地消息表——业务表和消息表在同一个库,业务SQL和插入消息表的SQL在同一个本地事务里提交,事务提交后后台任务轮询消息表,把未发送的消息投递到MQ,由MQ触发新系统的写入。日终对账作为兜底,每天凌晨比对新老系统的订单数据,发现差异告警并自动修复。

这个决策让我学到的道理是,对于关键系统,宁可前期多投入,也不要在可靠性上取巧。双写方案花了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年前后,携程火车票业务从代销模式转向直连模式,订单量快速增长。老系统是基于Java的单体架构,单库MySQL,所有订单逻辑在一个项目里。日均订单量从50万涨到100万时,系统开始频繁出问题,DB连接池打满、接口超时、订单状态不一致。最严重的一次,春节抢票高峰期系统挂了2小时,影响了几十万用户,VP直接打电话过来。

个人职责

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

技术选型依据

  1. 微服务框架用公司自研SOA框架:携程内部有成熟的SOA框架,服务注册、发现、治理都封装好了,团队5个人没必要从零搭Spring Cloud全家桶。自研框架的好处是出问题能直接找框架团队定位,不需要自己踩开源组件的坑。

  2. 分库分表用公司自研ORM框架:基于MyBatis拦截器实现SQL路由,由公司框架团队维护。当时ShardingSphere还不成熟,自研ORM框架可控性强,路由规则、扩容逻辑都封装好了,业务侧只需配分片规则就行。

  3. 消息队列用公司自研QMQ:携程内部的MQ中间件,支持顺序消息和事务消息,由框架团队维护。没有选Kafka或RocketMQ是因为公司已有稳定的MQ基础设施,运维和监控体系完备,没必要引入新组件增加运维负担。

  4. 订单状态机自研:没有用开源的状态机框架,因为订单状态流转有业务特殊性,自研更灵活。核心就是一个状态转移表 + 事件驱动。

遇到的挑战及解决方案

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

老系统在跑,新系统要上线,不能停机。解决方案是双写 + 灰度切流: - 新老系统并行运行,写入时双写(先写老系统,再写新系统) - 读流量先10%走新系统,观察1周无问题后逐步放量到30%→50%→100% - 双写一致性通过本地消息表 + 日终对账保证

踩过的坑:双写的两个写操作不是原子的,出现过3笔数据不一致。具体原因是——写入老系统成功后、写入新系统之前,应用挂了。老系统有数据、新系统没有,读流量切到新系统后用户看不到订单。更隐蔽的一种情况是,两个写操作分别在不同的事务里,老系统事务提交了但新系统事务回滚了,也导致不一致。

解决思路是让"业务操作 + 消息发送"在同一个本地事务里,保证要么一起成功要么一起回滚。具体方案是本地消息表——业务表和消息表在同一个库,业务SQL和插入消息表的SQL在同一个事务里提交。事务提交后,后台定时任务轮询消息表,把未发送的消息投递到MQ,由MQ触发新系统的写入。投递成功后标记消息已发送。如果投递失败,轮询任务下次重试。消息表里加了创建时间索引,轮询只扫最近一段时间内的未发送消息,避免全表扫描。

这个方案上线后,再没出现过数据不一致。日终对账作为兜底,每天凌晨比对新老系统的订单数据,如果发现差异告警并自动修复。

挑战2:分库分表方案与扩容

分库分表的路由方式是订单号hash取模。订单号采用公司统一的订单号生成方案,自带分片基因——生成时就把分库信息编码进去了,路由时直接从订单号里提取分库号,不需要额外计算。这样按订单号查询直接定位到具体库,不需要扫全库。按用户ID查"我的订单"的场景,通过在用户ID维度建索引表解决——用户ID映射到订单号列表,再路由到具体库。

数据库架构是1主2从,主库写、从库读。分8个库,每个库多张表。扩容采用翻倍扩容——8库扩到16库时,直接把原来的一组从库升级为主库,每个新主库再挂新的从库。翻倍扩容的好处是不需要做数据迁移,原来hash(orderId) % 8 = n的记录,扩容后hash(orderId) % 16 = n或n+8,正好分布在原来的主库和新升主库上,只需要调整路由规则就行。这个方案是框架团队设计好的,业务侧配一下路由规则就完成扩容。

数据保留策略是MySQL保留6个月热数据,历史数据归档到HBase。归档任务每天跑,把6个月前的订单数据写入HBase后从MySQL删除。查询历史订单走HBase,对用户透明。复杂条件查询走ES异构索引。

挑战3:分布式事务

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

挑战4:状态机统一管理

老系统的订单状态散落在多个服务里,谁都能改,出了问题查不清。解决方案是设计统一状态机: - 状态变更只能通过预定义的动作触发 - 每次变更产出领域事件,下游订阅 - 幂等保护:UPDATE order SET status=? WHERE id=? AND status=?,影响行数为0说明已变更

状态机核心实现细节

这是整个重构中我自己设计并写了核心代码的模块,面试追问时可以展开讲。

状态转移表定义——核心数据结构是一张预定义的状态转移表,存在DB里,启动时加载到内存。结构是 (当前状态, 触发动作) → (目标状态, 产出事件列表, 校验规则列表)。以火车票订单为例,关键转移如下:

INIT     + CREATE    → CREATED    [OrderCreatedEvent]
CREATED  + PAY       → PAID       [OrderPaidEvent, TriggerTicketEvent]
PAID     + ISSUE_OK  → ISSUED     [OrderIssuedEvent]
PAID     + ISSUE_FAIL→ TICKET_FAIL [TicketFailedEvent]
ISSUED   + CONFIRM   → COMPLETED  [OrderCompletedEvent]
任意状态  + CANCEL    → CANCELLED  [OrderCancelledEvent, RefundTriggeredEvent]

核心引擎实现逻辑——状态机的入口是一个 fire(order, action, context) 方法,执行流程:

  1. 查表校验:从转移表中查找 (order.status, action) 对应的规则,找不到说明该动作在当前状态不合法,直接拒绝(抛IllegalStateException)。这一步杜绝了任何服务随意改状态的可能。
  2. 前置校验:执行校验规则列表。比如 CANCEL 动作的前置校验是"如果已出票,必须先发起退票",PAY 动作的前置校验是"支付金额必须等于订单金额"。
  3. 乐观锁更新:用 UPDATE order SET status = targetStatus WHERE id = ? AND status = currentStatus 做幂等保护。影响行数为0说明并发场景下状态已被其他线程变更,本次操作放弃。
  4. 发布领域事件:更新成功后,遍历产出事件列表,通过QMQ发布异步事件。下游各服务(出票、通知、数据同步)各自订阅处理,完全解耦。
  5. 记录状态变更日志:每次变更写一条流水记录(orderId, fromStatus, toStatus, action, operator, timestamp),用于问题排查和审计。

为什么自研不用开源状态机框架——评估过Spring Statemachine和Squirrel-foundation,放弃的原因:一是我们的状态转移表需要动态加载、运行时修改(运营有时要紧急禁用某个转移路径),开源框架大多在启动时写死配置;二是我们需要和DB事务深度绑定(乐观锁更新+事件发布要在同一个事务边界内),开源框架的事务控制不够灵活;三是我们的状态机要支持"回滚"操作(比如出票失败要从PAID回退到CREATED重试),这不是标准有限状态机的特性,需要扩展。整个引擎核心代码不到800行,维护成本可控,没必要引入一个重量级框架。

实际遇到的挑战与解决

项目成果

个人贡献

经验总结与反思

回头看,几个关键决策是对的。双写迁移虽然复杂,但保证了业务无损;状态机统一管理是长期收益最大的决策,后续所有状态相关需求都受益;分库分表用公司统一订单号做hash路由,配合翻倍扩容,后续两次扩容都很平滑。

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


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

项目背景

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

个人职责

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

技术选型依据

  1. 任务调度自研:抢票任务的调度逻辑有业务特殊性(优先级、合并、重试),开源调度框架不适用。

  2. 多线程并发用Java ThreadPoolExecutor:可控性强,能精确控制并发度和拒绝策略。

  3. 余票监控用爬虫 + 推送双通道:爬虫主动抓取 + 12306开放接口被动接收,互为补充。

遇到的挑战及解决方案

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

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

解决方案是任务分级 + 任务合并: - 任务分级:按用户等级、订单金额、余票紧张度给任务打分,高分优先 - 任务合并:同一车次同一区间的多个请求合并成一个,减少对12306的请求量 - 线程池控制并发度,队列做缓冲

挑战2:抢票成功率提升

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

解决方案是多引擎并发: - 同时发起多个抢票请求(不同引擎、不同IP),哪个先回来用哪个 - 有频率控制和IP池轮换,避免被12306封禁 - 失败自动重试,但有重试次数限制

挑战3:余票预测和预热

解决方案是余票监控 + 预测: - 监控12306余票变化趋势 - 放票前提前预热连接和请求 - 退票后立即触发抢票任务

项目成果

个人贡献

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

经验总结与反思

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

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

任务合并引擎核心实现细节

任务合并是整个抢票系统最核心的创新,面试追问时可以展开讲。

合并Key设计——合并的前提是多个抢票请求的查询条件完全相同。合并Key的定义是 (车次号, 出发站, 到达站, 日期, 座位类型),5个字段组合成一个String作为HashMap的key。同一个Key下的多个请求,只需要对12306发一次查询,查到余票后所有等待该Key的请求都能尝试抢票。

合并数据结构——核心是一个 ConcurrentHashMap<MergeKey, MergeBucket>,MergeBucket里存的是该Key下所有等待中的抢票请求列表(List<TicketTask>),以及一个合并后的执行状态。用ConcurrentHashMap保证多线程并发提交任务时的线程安全,用computeIfAbsent原子操作保证同一个Key的Bucket只创建一次。

MergeBucket {
    MergeKey key;
    List<TicketTask> waitingTasks;  // 等待中的抢票请求
    volatile int status;            // PENDING/RUNNING/DONE
    CompletableFuture<Result> future; // 合并执行的异步结果
}

合并执行流程——调度线程每隔200ms扫描一次MergeMap,找出所有PENDING状态且达到合并窗口期的Bucket。合并窗口期是500ms——第一个请求进来后等500ms,这期间同一Key的请求都会被合并进来。500ms后提交到线程池执行,状态改为RUNNING。执行线程拿到余票后,按优先级排序waitingTasks,依次尝试抢票:有票就分配给当前最高优先级的请求,扣减余票,继续下一个;余票为0就停止,剩余请求标记为"余票不足"。

优先级打分公式——score = 用户等级权重 × 3 + 订单金额权重 × 2 + 等待时长权重 × 1。用户等级VIP最高,订单金额高的优先(高价值订单对平台收益贡献大),等待时长长的适当补偿。三个权重的比例是业务定的,我做了AB测试调优。

为什么不用MQ做合并——初期考虑过用MQ的group by能力做消息合并,但MQ的合并是基于消费端去重的,不是基于查询条件的语义合并。而且我们需要精确控制合并窗口期和执行时机,MQ的投递时机不可控。自研合并引擎的代码量也不大(核心300行左右),可控性远优于依赖MQ。

实际挑战与解决 - 合并窗口期设定:窗口期太短合并效果差,太长用户等得急。500ms是压测出来的——放票瞬间1秒内同一车次通常有几百个请求,500ms能合并60%以上,用户体感延迟可接受。 - 任务分配公平性:初期纯按优先级,低等级用户几乎抢不到。加了等待时长权重后,同一优先级内等待最久的优先,兼顾效率和公平。 - Bucket内存泄漏:如果某个Key长时间没有新请求进来,Bucket一直占内存。加了一个定时清理任务,超过5分钟没有新请求的Bucket自动清除。


项目3:全自动出票系统

项目背景

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

个人职责

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

技术选型依据

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

  2. 爬虫服务用独立技术栈:出票和调度用Java,爬虫用Python(爬虫生态更成熟)。

遇到的挑战及解决方案

挑战1:出票流程自动化

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

解决方案: - 验证码识别对接打码平台(初期)+ 自建识别模型(后期) - 异常处理设计三级策略:自动重试→规则匹配处理→转人工 - 出票失败重试3次后转人工,人工处理后系统自动继续

挑战2:引擎故障切换

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

解决方案: - 心跳检测 + 自动切换:调度服务监控出票服务状态,故障后自动切到备用引擎 - 降级策略:所有引擎不可用时,订单进入"待出票"状态,人工兜底 - 12306恢复后,自动处理积压订单

挑战3:出票结果回填

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

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

项目成果

经验总结与反思

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

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

三引擎互备故障切换核心实现细节

三引擎互备是出票系统的核心容错机制,面试追问时可以展开讲。

引擎注册与心跳——每个引擎启动时向调度服务注册,注册信息包括 engineId, engineType(API/CRAWLER/MANUAL), endpoint, weight。调度服务维护一个引擎列表,心跳检测每3秒一次:对每个引擎发一个轻量级的health check请求(GET /health),连续2次失败标记为DOWN,恢复后标记为UP。引擎状态变更时发布事件,通知调度策略更新。

调度策略实现——出票请求进来后,调度服务从引擎列表中选出所有UP状态的引擎,按优先级排序:API引擎优先(速度快、稳定)→ CRAWLER引擎次之(速度慢但可用)→ MANUAL引擎兜底(人工介入)。选出引擎后下发出票指令,如果该引擎在指定时间内(15秒)未返回结果,标记为TIMEOUT并尝试下一个引擎。核心逻辑是一个带退避的轮询:

selectEngine(order):
    for engine in sorted(engines, by=priority):
        if engine.status == UP and engine.currentLoad < engine.maxLoad:
            return engine
    return MANUAL_ENGINE  // 所有自动引擎不可用,降级到人工

引擎隔离与熔断——每个引擎的调用走独立的线程池,不共享。这样做的好处是,一个引擎的慢调用不会拖垮其他引擎的线程。用Sentinel对每个引擎做熔断配置——单个引擎异常率超过30%就熔断,熔断后不再选中,30秒后放一半流量试探,成功则恢复,失败则继续熔断。

结果回填与幂等——出票成功后,引擎回调调度服务的ticketCallback(orderId, result)接口。这个接口必须幂等——用orderId做去重表,已处理的回调直接返回成功。结果通过QMQ广播给订单服务(更新状态)和通知服务(发短信/邮件),各下游异步处理,互不阻塞。

为什么三引擎不是双引擎——出票场景特殊在依赖外部系统12306,外部系统不稳定是常态。双引擎(一主一备)下,如果主引擎挂了,备引擎也可能同时受影响(比如12306整体不可用,两个引擎都连不上)。三引擎的第三个是"爬虫引擎"——走浏览器模拟的方式出票,和API引擎走不同的通道,12306的API故障不影响爬虫通道。这样只要12306不是完全宕机,至少有一个引擎可用。

实际挑战与解决 - 引擎状态抖动:某个引擎网络不稳定,频繁UP/DOWN切换,导致请求在不同引擎间来回跳。加了冷却期——引擎从DOWN恢复到UP后,先进入WARMING状态持续30秒,只分配少量流量验证,稳定后才进入FULL状态。 - 出票结果不一致:极端情况下两个引擎同时对同一订单出票成功(比如API引擎超时但实际成功了,爬虫引擎也成功了),导致重复出票。在回调接口加分布式锁(Redisson的tryLock(orderId, 5s)),只有一个引擎能回填结果,另一个拿不到锁直接放弃。 - 人工兜底的积压处理:12306整体不可用时所有订单进入待出票队列。12306恢复后要快速处理积压。我们做了自动积压处理——调度服务每分钟检查待出票队列长度,如果大于0且引擎恢复UP,自动按优先级批量重新提交出票请求。


项目4:Global Rail GDS 国际化

项目背景

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

个人职责

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

技术选型依据

  1. Adapter模式对接多供应商:每个供应商的接口协议不同(有的SOAP、有的REST、有的XML),用Adapter模式做统一适配,上层调用统一接口。

  2. 多时区多币种处理:用Java 8的Time API处理时区,币种转换用独立的汇率服务。

遇到的挑战及解决方案

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

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

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

挑战2:多时区多币种

解决方案: - 所有时间内部统一用UTC存储,展示时按用户时区转换 - 币种统一用最小货币单位(分)存储,避免浮点精度问题 - 汇率独立服务,定时刷新

挑战3:团队从0组建

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

项目成果

经验总结与反思

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

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

Adapter模式 + 统一领域模型核心实现细节

多供应商接入的核心难点是"对外统一、对内差异",面试追问时可以展开讲。

统一领域模型设计——我们定义了一套标准的 InternationalRailOrder 领域模型,包含以下核心字段:

InternationalRailOrder {
    String orderId;              // 统一订单号
    Station departure;           // 出发站(含经纬度、时区)
    Station arrival;             // 到达站
    ZonedDateTime departTime;    // 出发时间(UTC+时区)
    ZonedDateTime arriveTime;    // 到达时间
    Money price;                 // 价格(金额long分 + 币种ISO 4217)
    String supplierCode;         // 供应商代码(SNCF/DB/TRAINLINE...)
    SupplierRawData rawData;     // 供应商原始数据(JSONB,保留完整响应)
}

所有上层服务(搜索、下单、退改)只操作这个模型,完全不感知供应商差异。rawData字段保留供应商的原始响应,用于排查问题和处理供应商特殊逻辑。

Adapter注册与路由——每个供应商实现一个 SupplierAdapter 接口:

SupplierAdapter {
    String getSupplierCode();                          // "SNCF"
    List<InternationalRailOrder> search(SearchRequest req);   // 搜索
    OrderResult book(BookRequest req);                 // 下单
    RefundResult refund(RefundRequest req);             // 退改
    CancelResult cancel(CancelRequest req);             // 取消
}

所有Adapter通过Spring的依赖注入注册到一个 AdapterRegistry,按supplierCode做路由。上层调用 registry.getAdapter(supplierCode).book(req) 就行,不需要知道背后是哪个供应商。新增供应商只需要实现Adapter接口并注册,不改任何上层代码——这就是开闭原则。

协议转换的复杂性处理——每个供应商的协议不同:SNCF用SOAP+XML,DB用REST+JSON,Trainline用REST+XML。我们在Adapter内部做了分层:

多币种精度处理——这是国际化最容易踩坑的地方。核心原则:所有金额内部用long类型存储最小单位(分/便士/欧分),绝不使用double/float。显示时再除以100转成元。币种用ISO 4217标准三字母码(CNY/USD/EUR/GBP)。汇率服务定时从第三方拉取最新汇率,缓存在Redis里,TTL 5分钟。报价时锁定当前汇率,汇率波动不影响已报价订单。

实际挑战与解决 - 供应商接口变更频繁:SNCF平均每季度改一次接口字段。Adapter层的好处就体现出来了——接口变了只改Adapter的Convert层,上层完全不受影响。我们做了接口变更监控——Adapter解析供应商响应时,如果遇到未识别的字段,记录告警,提前发现接口变更。 - 时区转换踩坑:初期有一次把伦敦当地时间直接当UTC存了,导致跨时区查询时间偏差8小时。后来强制所有时间入库前必须转成UTC(ZonedDateTime.toInstant()),展示时按用户时区转回来。在Adapter的Convert层统一做时区转换,上层不需要关心。 - 供应商异常码映射:每个供应商的错误码体系不同,SNCF用4位数字码,DB用HTTP状态码+自定义message。我们在Adapter层做了统一异常码映射——把供应商异常码转成我们的标准ErrorCode枚举,上层处理异常只看标准码。


项目5:动态缓存策略

项目背景

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

个人职责

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

遇到的挑战及解决方案

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

解决方案: - 基于历史访问数据做热度分析,Top 20%线路定为热数据 - 动态调整:实时监控访问频率,新冒出的热门线路自动升级为热数据

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

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

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

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

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

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

项目成果

经验总结与反思

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

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

Canal + Binlog热数据推送核心实现细节

热数据主动推送是分级缓存的核心,面试追问时可以展开讲。

Canal部署架构——Canal Server部署为集群模式,每个实例伪装成MySQL的从库,订阅订单库的Binlog。Canal Server把Binlog事件推送到Canal Client(我们的缓存更新服务),Client解析Binlog事件,提取变更的订单数据,推送到Redis。部署了2个Canal实例做HA,通过ZooKeeper做主备切换,同一时刻只有一个实例消费Binlog,避免重复推送。

数据流转链路——完整链路是:MySQL Binlog → Canal Server → Canal Client → 消息解析 → 热度判断 → Redis写入。关键在热度判断这一步——不是所有Binlog变更都推缓存,只有热数据才推。Canal Client收到变更事件后,先查热度标记(Redis里一个Set,存所有热数据的Key),如果变更的Key在热数据集合里,才推送缓存更新,否则跳过。

热数据标记与动态升降级——热数据标记基于滑动窗口计数器。每个线路的查询计数存为Redis的HyperLogLog(省内存),每5分钟汇总一次。汇总逻辑:最近30分钟访问量 > 阈值(日均访问量的Top 20%分位值)的线路标记为热数据,加入热数据Set;低于阈值的从Set中移除,对应的Redis缓存自然过期清除。升降级动作记录日志,方便排查缓存命中率变化。

推送一致性保证——Binlog顺序性由Canal保证(同一个表的事件按Binlog顺序投递)。但Canal Client处理事件时可能出现乱序(比如两个Canal Client实例并发处理)。解决方案是:Redis写入用 SET key value EX ttl 原子操作,并且每个缓存值带上Binlog的timestamp。写入前比较timestamp,只有比当前缓存值更新的才写入——用Redis的Lua脚本保证"比较+写入"的原子性。

实际挑战与解决 - Canal延迟抖动:大促期间Binlog量大,Canal消费延迟偶尔到秒级。我们加了延迟监控——Canal Client定期上报消费位点,监控服务算出延迟,超过500ms告警。延迟高时临时降级:热数据改用应用层直接写缓存(写DB后同步删缓存+异步回填),不依赖Canal推送。 - 缓存值序列化:初期用Java原生序列化,缓存值可读性差且跨语言不兼容。改成JSON序列化后可读性好,但体积大。后来改成Protostuff序列化,体积比JSON小60%,性能也好,只是不可读——我们额外维护了一个debug模式的JSON序列化开关,排查问题时打开。 - 大促前缓存预热:大促前需要提前加载热门线路数据到Redis。手动预热太慢,后来做成自动化的——大促前一天,跑一个预热任务,从历史访问数据中取大促期间Top线路,批量从DB加载到Redis。预热结果做校验,确保Redis中的数据和DB一致。


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

项目背景

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

个人职责

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

技术选型依据

  1. 设备接入层用Netty自研:设备资源有限,MQTT协议较重,自定义Protobuf协议更轻量,消息体积小,通信效率高。

  2. 后端用Spring Boot + MyBatis:团队Java背景,快速上手。

  3. 数据库MySQL + Redis:初期数据量不大,单库够用。Redis做设备状态缓存和分布式锁。

  4. 支付对接微信/支付宝:国内市场必须支持的两个支付渠道。

遇到的挑战及解决方案

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

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

解决方案:Netty自研设备接入层 - 连接管理:每个设备一个Channel,用ConcurrentHashMap维护设备ID→Channel映射 - 心跳检测:空闲检测Handler,60秒无心跳断开连接 - 断线重连:设备端自动重连,服务端清理旧连接建立新连接 - 协议解析:Protobuf编解码,消息体积小、解析快

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

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

解决方案:指令确认机制 - 指令下发后等待设备ACK,5秒未收到ACK自动重发 - 重发3次失败标记设备离线,通知运维处理 - 指令持久化到DB,设备ACK后更新状态

挑战3:支付分账系统

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

解决方案: - 支付成功后写交易流水,记录分账规则 - T+1结算,先记账后分账,每笔交易有对账流水 - 分账金额精确到分,避免浮点误差

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

解决方案: - 每周迭代发布,周一定需求周四周五开发测试 - 每天早上10分钟站会对进度 - 我负责架构和核心模块,另外2人负责业务开发和设备端 - 周五下午做回顾,持续改进流程

项目成果

经验总结与反思

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

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

Netty设备接入层核心实现细节

设备长连接管理是IoT平台的基础,面试追问时可以展开讲。

Netty Pipeline设计——每个设备连接对应一个Channel,Pipeline的Handler链如下:

IdleStateHandler(60s读空闲)  →  LengthFieldBasedFrameDecoder  →  ProtobufDecoder  →  DeviceMessageHandler  →  ExceptionHandler

连接管理实现——核心是一个 ConcurrentHashMap<String, Channel> 维护 deviceId → Channel 的映射。设备连接建立时(channelActive),从认证消息中提取deviceId,放入映射。设备断连时(channelInactive),从映射中移除。关键问题:设备重连时旧Channel可能还没关闭,新Channel已经进来——必须先关闭旧Channel再建立新映射,否则会出现"幽灵连接"(旧Channel占着映射,新Channel的消息发不出去)。我们用Channel的attribute存储deviceId,断连回调里校验attribute,只有当前Channel的attribute和映射中的Channel一致才移除,避免误删新连接。

指令下发可靠性——用户扫码开门的场景,指令丢了用户体验极差。实现流程:

  1. 指令先持久化到DB(状态=PENDING),再通过Channel下发到设备
  2. 启动一个延迟任务(ScheduledExecutor,5秒后执行),检查指令状态
  3. 设备收到指令后回复ACK,ACK回调更新DB状态=ACKED,取消延迟任务
  4. 如果5秒未收到ACK,延迟任务触发重发,最多重发3次
  5. 3次都未ACK,标记指令状态=FAILED,设备状态更新为离线,触发告警

用Redis的分布式锁保证同一设备同一时刻不会并发下发多个指令——tryLock("cmd:" + deviceId, 3s),拿到锁才下发,拿不到说明上一条指令还在等ACK,排队等。

协议版本兼容——这是后期踩的坑。初期协议只有v1,加新设备类型时需要扩展字段。我们在协议头加了version字段(1字节),ProtobufDecoder根据version选择对应的.proto定义反序列化。DeviceMessageHandler里对不同版本的消息走不同的处理分支。v1消息的缺失字段用默认值填充,保证向前兼容。

实际挑战与解决 - 连接数上限:初期单机承载500台设备没问题,到1000台时CPU飙高。排查发现是ProtobufDecoder的线程模型问题——默认所有Channel共享一个EventLoopGroup,连接数多了EventLoop线程繁忙。改成按设备分组,不同设备组的Channel分配到不同的EventLoop,减少线程竞争。 - 心跳丢失误判:网络抖动导致个别心跳包丢失,60秒超时断连后设备自动重连,频繁断连影响体验。改成连续3次心跳超时才断连(60s×3=180s容忍窗口),并加了一个ping-pong机制——服务端主动发ping,设备回pong,区分"网络断了"和"设备真的离线"。 - 指令积压:某次网络故障期间积压了上千条开门指令,网络恢复后瞬间下发导致设备处理不过来。加了指令限流——每台设备每秒最多接收5条指令,超出排队,通过Guava RateLimiter实现。


项目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生成的行程方案可能不符合业务约束(时间冲突、预算超支)。

解决方案:三层约束求解引擎 - 时间重叠检测:Segment排序扫描,O(nlogn)检测时间冲突 - 转移缓冲:按交通方式差异化设置缓冲时间(步行/包车/地铁不同) - 预算超支检测:实时计算累计花费,超预算输出违规报告 - 违规报告反馈给LLM自修正,形成闭环

挑战2:Function Calling的稳定性

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

解决方案: - 严格的JSON Schema校验,参数不合法直接拒绝并要求重新生成 - 关键参数(价格、日期)不信任LLM,由代码二次校验 - 设计fallback:LLM调用失败3次后降级为规则引擎生成

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

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

解决方案: - 11状态严格有向图订单状态机 - 每次状态变更用乐观锁:UPDATE order SET status=? WHERE id=? AND status=? - Webhook处理前查状态,已处理的状态直接返回200(告诉Stripe不要再发了) - 全量自动化测试覆盖所有状态转移路径

挑战4:多币种定价

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

解决方案:动态定价引擎 - 基准价格 × 季节系数 × 数量 - 6类资源分项计价(景点/酒店/包车/导游/交通/餐饮) - 跨年季节区间处理(行程跨年的季节系数分段计算) - 汇率独立服务,定时刷新,报价时锁定汇率

项目成果

经验总结与反思

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

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

约束求解引擎核心实现细节

约束求解是让LLM输出可控的核心机制,面试追问时可以展开讲。

三层校验Pipeline——行程生成后的校验是一个Pipeline,三层串行执行,任何一层发现违规就中断并生成违规报告:

Itinerary → TimeOverlapChecker → BudgetChecker → ConstraintReporter → (pass/reject+report)

时间重叠检测算法——这是第一层,核心算法是区间排序扫描。行程中的每个活动抽象为一个时间区间 [start, end],按start排序后,相邻区间如果 current.start < prev.end + buffer,则存在重叠。buffer是按交通方式差异化的缓冲时间——步行15分钟、地铁30分钟、包车10分钟。算法复杂度O(nlogn)(排序)+O(n)(扫描),行程里通常20-50个活动,性能不是问题。

def check_time_overlap(segments):
    sorted_segments = sorted(segments, key=lambda s: s.start)
    violations = []
    for i in range(1, len(sorted_segments)):
        prev, curr = sorted_segments[i-1], sorted_segments[i]
        buffer = get_buffer(prev.transport_mode)  # 步行15min/地铁30min/包车10min
        if curr.start < prev.end + timedelta(minutes=buffer):
            violations.append(TimeConflict(prev, curr, overlap=prev.end+buffer-curr.start))
    return violations

预算超支检测——第二层,累加行程中所有活动的花费(6类资源分项计价),如果超出用户设定的预算,输出违规报告。关键细节是汇率处理——不同资源的报价币种可能不同(酒店EUR、包车CNY、门票USD),统一按当前汇率转成用户的目标币种再累加。汇率在报价时锁定,后续汇率波动不影响已生成的行程。

违规报告反馈LLM自修正——校验不通过时,把违规报告拼接到Prompt里,让LLM重新生成。设计了一个最大修正轮数(3轮),防止LLM陷入死循环。3轮都修不过,降级为规则引擎生成——用固定的模板行程+用户约束填充,保证用户一定能拿到一个合规方案,只是体验不如LLM生成的灵活。

Function Calling Schema设计——LLM调用的Function定义了严格的JSON Schema,核心原则是"不信任LLM的任何数值输出":

实际挑战与解决 - LLM幻觉导致价格偏差:LLM有时会"编造"一个看起来合理但实际不存在的价格。二次校验机制解决了这个问题——拿LLM返回的resourceId去数据库查真实价格,如果和LLM返回的价格偏差超过20%,用真实价格替换LLM价格,同时记录告警监控幻觉率。 - 约束冲突无解:用户要求"5天行程预算500美元包含巴黎5星级酒店",约束求解无解。这种情况下返回"约束过紧"提示,建议用户调整预算或天数,而不是强行生成一个违规方案。 - Token消耗优化:初期每次生成行程消耗3000+ token,成本高。优化了Prompt——把6类资源的可选列表从全量缩减到按用户偏好Top 10,Prompt长度减少40%,token消耗降到1800左右。同时加了相似行程缓存——相同出发地+目的地+天数+预算范围的行程,如果近期生成过且资源价格没变,直接复用。


项目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双层路由 - 第一层规则引擎:关键词匹配 + 正则,覆盖高频意图(80%以上的请求) - 第二层LLM:规则引擎无法匹配的走LLM分类,覆盖长尾 - 四类意图:KB问答/插件调用/人工转接/闲聊 - 置信度阈值 + fallback降级:LLM置信度低于0.7时降级为KB问答

挑战2:RAG检索质量

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

解决方案: - SentenceSplitter做分块,控制chunk size在500-1000 token - BGE Embedding做向量化,中文语义召回效果好 - Compact模式生成——把召回的多个chunk合并成context给LLM生成答案 - 补充:关键词检索 + 语义检索的混合检索,提升召回率

挑战3:系统弹性

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

解决方案:四层限流 + 熔断 - Token Bucket四层限流:全局/用户/会话/渠道 - Circuit Breaker熔断:LLM服务异常率超过阈值自动熔断,走降级回复 - OpenTelemetry全链路TraceId传递,故障排查可追踪

挑战4:工程化质量

解决方案: - Protocol协议编程 + 依赖注入解耦,模块可替换 - Docker Compose一键部署 - GitHub Actions CI,代码合并前自动跑测试

项目成果

经验总结与反思

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

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

双层路由 + RAG Pipeline核心实现细节

意图识别和RAG检索是智能客服的核心,面试追问时可以展开讲。

规则引擎实现——第一层规则引擎用Python实现,核心是一个有序的规则列表,每个规则包含:匹配模式(关键词列表/正则表达式)、目标意图、置信度(固定1.0因为是确定性匹配)、优先级。请求进来后按优先级逐条匹配,命中的直接返回意图。规则定义存在YAML文件里,运营人员可以自行修改,热加载无需重启。

rules:
  - name: "退款咨询"
    keywords: ["退款", "退货", "退钱", "不想要了"]
    intent: REFUND_CONSULT
    priority: 10
  - name: "物流查询"
    pattern: ".*(运单|快递|物流).*(查询|到哪|进度|什么时候).*"
    intent: LOGISTICS_QUERY
    priority: 20

规则引擎覆盖了80%+的高频意图,平均响应时间<5ms。只有规则引擎无法匹配的请求才走LLM(平均响应时间1-3秒),整体平均响应时间从纯LLM方案的2秒降到200ms。

RAG检索Pipeline——KB问答的完整链路:

用户问题 → Embedding(BGE) → Qdrant检索(语义Top5) → 关键词检索(Top5) → RRF融合 → 去重排序 → Context拼接 → LLM生成(含引用来源) → 答案

关键实现细节: - 混合检索:纯语义检索对专有名词(产品型号、订单号格式)召回差,纯关键词检索对语义相似但用词不同的问法召回差。两者用RRF(Reciprocal Rank Fusion)融合——对每个文档,计算 score = Σ 1/(k + rank_i),k=60是经验值。语义检索和关键词检索各返回Top5,融合后取Top5作为最终context。 - Context长度控制:LLM的context window有限(GPT-4 8K),检索回来的chunk不能全部塞进去。我们的策略是:5个chunk按相关度排序,依次拼接,直到总token数接近context上限的70%(留30%给Prompt和生成),超出的chunk丢弃。 - 引用来源标注:生成的答案要求LLM标注信息来源——每个知识点后面标注 [来源:文档名-章节],方便用户验证答案的可靠性,也方便排查RAG召回质量。

四层限流实现——Token Bucket算法,四层嵌套:

全局限流(1000 QPS) → 渠道限流(Web:600/飞书:400) → 用户限流(10 QPS/user) → 会话限流(2 QPS/session)

每层一个令牌桶,请求逐层消耗令牌,任何一层令牌不足直接返回429。用Redis的Lua脚本实现原子化的令牌获取和计数,避免并发竞态。

熔断降级策略——LLM服务异常率超过30%触发熔断,熔断后不走LLM,降级方案是:意图识别降级为纯规则引擎(只覆盖高频意图,长尾意图返回"暂无法识别");KB问答降级为关键词匹配FAQ(从向量检索退化为ES全文检索,准确率下降但可用);插件调用和人工转接不受影响(不依赖LLM)。

实际挑战与解决 - RAG召回的"看似相关实则无关":有些chunk和query语义相似但回答不了问题(比如用户问"退货流程",召回了"退款政策"的chunk,语义相关但不是用户要的)。解决方案是在LLM生成的Prompt里加一个判断步骤——先让LLM判断"检索到的context是否足以回答用户问题",判断为否时追加一轮检索或转人工。 - 飞书消息格式适配:飞书的消息是Block Kit格式(JSON结构),不是纯文本。解析用户消息时要处理Markdown、富文本、@机器人等各种格式。我们做了统一的消息适配层——飞书消息进来后先转成纯文本再送意图识别,回复时把纯文本答案转成飞书Block Kit格式。 - 多轮对话上下文:初期只处理单轮对话,用户追问时丢失上下文。后来加了对话历史管理——用Redis存最近5轮对话(key是sessionId),每轮把用户问题和系统回答都存下来。后续请求的Prompt里拼接对话历史,LLM就能理解上下文。


项目9:SGS DDD重构

项目背景

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

个人职责

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

遇到的挑战及解决方案

挑战1:团队对DDD不熟悉

解决方案: - 做了2次DDD内部培训,从概念到实战案例 - 选一个模块做试点,带着团队一起做,边做边学 - 建立DDD设计评审机制,所有新模块的设计必须经过评审

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

解决方案: - 不做一次性大重构,按模块逐步迁移 - 新需求用DDD做,老代码维持现状,涉及老代码时顺手重构 - 用防腐层(ACL)隔离新旧代码,老代码通过ACL适配新领域模型

挑战3:团队效率提升

解决方案: - 建立Code Review机制(交叉审查) - 技术分享周会(每人轮流讲) - 2周一迭代的交付节奏 - 交付周期缩短20%

项目成果

经验总结与反思

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

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

防腐层(ACL) + 渐进式迁移核心实现细节

渐进式重构的关键是"新老代码共存不互相污染",防腐层是实现这一目标的机制,面试追问时可以展开讲。

防腐层的本质——ACL是DDD战略设计中的一个模式,全称Anti-Corruption Layer。它的作用是在限界上下文的边界上做一层隔离翻译——外部系统的数据模型和API进来时,先经过ACL转成当前上下文的领域模型,领域模型不会直接暴露给外部。反过来,领域模型输出时也经过ACL转成外部系统期望的格式。

ACL在SGS的具体实现——SGS的老系统有一个叫WorkOrderService的Service类,5000行代码,所有工单相关的逻辑都在里面。我们的DDD重构要把工单拆成一个独立的限界上下文,定义自己的领域模型。但老系统的其他模块还在调WorkOrderService的方法,不能一下全改。

解决方案是在新领域上下文的边界上加ACL:

// 新领域上下文对外提供的接口
interface WorkOrderACL {
    WorkOrderDTO getWorkOrder(String id);          // 查询
    void updateStatus(String id, WorkOrderStatus status);  // 状态变更
    List<WorkOrderDTO> queryByCondition(WorkOrderQuery query);  // 条件查询
}

// ACL的内部实现——早期走老系统,后期切到新领域模型
class WorkOrderACLImpl implements WorkOrderACL {
    private LegacyWorkOrderService legacyService;  // 老系统的Service
    private WorkOrderDomainService domainService;  // 新的领域Service
    private boolean useNewModel = false;           // 灰度开关

    WorkOrderDTO getWorkOrder(String id) {
        if (useNewModel) {
            WorkOrder order = domainService.findById(id);
            return DomainConverter.toDTO(order);   // 领域模型 → DTO
        } else {
            LegacyWorkOrder legacy = legacyService.getById(id);
            return LegacyConverter.toDTO(legacy);  // 老模型 → DTO
        }
    }
}

关键点:外部调用方只看ACL接口,不感知背后是新模型还是老模型。这样我们可以在新领域模型开发完成后,通过灰度开关逐步把流量从老模型切到新模型,和老系统的双写切流思路一致。

渐进式迁移策略——不做一次性大重构,按模块逐步迁移。具体策略是"新需求用DDD,老代码不动,涉及老代码时顺手重构":

  1. 新需求来了,用DDD的领域模型和聚合根实现,走新领域Service
  2. 老功能不动,继续走老Service
  3. 新需求涉及修改老功能时——把老功能的逻辑从老Service迁到新领域模型,老Service改为调ACL(实际走新领域模型)
  4. 每个模块迁完后,灰度开关切流验证,全量后删掉老Service的废弃代码

领域模型与代码同步——这是DDD落地的最大坑:领域模型画在图上,代码实现和模型脱节,时间一长模型就变成了摆设。我们做的保障措施: - 模型代码一体化:用PlantUML从代码的类结构自动生成领域模型图,保证图和代码始终一致 - 设计评审强制:所有新需求在设计评审时,先在领域模型图上标注变更点,评审通过再写代码 - 包结构映射:代码的包结构严格对应领域模型的限界上下文——com.sgs.workorder.domain对应工单上下文,com.sgs.inspection.domain对应检验上下文,不允许跨包直接引用领域对象

实际挑战与解决 - 团队DDD学习曲线陡:做了2次培训,团队还是觉得概念多、上手难。后来改成"带做不带讲"——选了一个最简单的模块(通知模块),我带着团队一起用DDD做一遍,从领域事件定义到聚合根设计到代码实现,全流程走一遍。做完之后团队对DDD的理解明显加深,比纯听培训有效得多。 - ACL初期设计太薄:最早的ACL只是简单的方法委托,没做模型转换。后来新老模型的字段差异越来越大,ACL的转换逻辑变复杂。重构了ACL,加了LegacyConverterDomainConverter两个独立的转换器类,各自负责一个方向的模型映射,ACL本身只做路由,职责清晰。 - 老Service的废弃代码清理:灰度切流完成后,老Service里有些方法已经没人调了,但没人敢删(怕遗漏调用方)。我们用IDE的"Find Usages"全局搜索+运行时AOP切面记录调用,双重验证后确认无调用才删。删代码也有节奏——不是一次全删,按模块逐批删,每批删后跑一轮回归测试。


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

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

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里。成功的就是持有者,失败的线程自旋等待。轻量级锁适合两个线程交替执行、没有真正竞争的场景。

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

补充一点,偏向锁在JDK 15之后被废弃(JEP 374),因为偏向锁的维护成本高于收益,且现代CAS操作已经足够快。JDK 15之后默认就是轻量级锁起步。如果是JDK 15+的环境,面试时要注意这点。

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维度的索引表解决——用户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亿/天,但架构是可以水平扩展的。


模块九:DDD领域驱动设计学习专篇

本模块为零基础学习者系统讲解DDD,从概念到实践到面试,确保完全没接触过DDD的人也能看懂、记住、会用、能答。

一、DDD基础概念详解

1.1 一句话理解DDD

DDD的核心思想就一句话:先搞懂业务,再写代码;让业务和技术说同一种语言。

传统开发的问题是什么?产品说"工单",开发理解成"任务",数据库里叫"ticket",三个名字指同一个东西。每次沟通都要翻译一遍,成本极高。DDD要解决这个问题——所有人对同一个概念用同一个词,这个词写在代码里、写在文档里、说在会议里,这就是"通用语言"(Ubiquitous Language)。

1.2 核心术语详解(用订单系统举例)

领域(Domain)

领域就是你要解决的业务问题域。比如"火车票订单系统",它的领域就是火车票交易——从用户下单到支付到出票到退改的整个业务闭环。领域不是技术概念,是业务概念。

子域(Subdomain)

一个大领域太复杂,要拆成小领域。子域分三种: - 核心域(Core Domain):公司靠它赚钱的,必须自己做好。比如订单状态流转、抢票算法——这是携程火车票的命脉,外包不得。 - 支撑域(Supporting Domain):为核心域配套的,可以自己做也可以买。比如通知服务——重要但不是核心竞争力,可以用现成方案。 - 通用域(Generic Domain):所有公司都需要的,直接买。比如用户认证、权限管理——没必要自研。

面试重点:为什么要区分?因为资源有限,核心域投入最精锐的力量,通用域能买则买。

限界上下文(Bounded Context)

这是DDD最重要的概念,也是最容易被忽略的。

一个领域里的同一个词,在不同上下文中含义不同。比如"订单": - 在定价上下文里叫"报价单"——关心价格、折扣、币种 - 在支付上下文里叫"支付单"——关心支付方式、交易流水、退款 - 在订单上下文里叫"订单"——关心状态、时间、用户

虽然底层可能对应同一行数据,但不同上下文关注的字段不同、操作不同、生命周期不同。限界上下文就是划定一个边界,边界内的语言和模型是统一的,边界外的通过接口/事件交互。

实际落地时,一个限界上下文通常对应一个微服务(但不强制,也可以是一个模块)。关键原则是:上下文之间不共享数据库,不共享领域模型,通过API或领域事件通信。

实体(Entity)

有唯一标识、有生命周期、可变的对象。比如订单(Order)——有orderId,从创建到支付到完成,状态在变,但orderId不变,始终是同一个订单。

值对象(Value Object)

没有唯一标识、不可变的对象。比如金额(Money)——100元就是100元,不需要一个ID来区分"这个100元"和"那个100元"。两个Money对象只要金额和币种相同,就是相等的。

值对象的好处:不可变所以线程安全;不需要ID所以简单;语义明确——Money比"BigDecimal金额+String币种"两个散落的字段清晰得多。

聚合根(Aggregate Root)

聚合是一组相关对象的集合,聚合根是这组的"入口"和"事务边界"。外部只能通过聚合根操作聚合内的对象,不能绕过聚合根直接改里面的实体。

比如订单聚合:Order是聚合根,OrderItem(订单项)是内部实体,Money(金额)是值对象。外部不能直接修改某个OrderItem的数量,必须通过Order的方法来改——order.changeItemQuantity(itemId, newQty),这样Order可以在内部做校验(比如"已支付的订单不能改数量")。

聚合根的关键意义:它定义了事务一致性边界。一个事务只应该修改一个聚合根。如果一次操作要同时改两个聚合根,要么它们应该在同一个聚合里,要么用最终一致性(领域事件+MQ)。

领域事件(Domain Event)

领域中发生的有业务意义的事情。比如"订单已支付"(OrderPaidEvent)、"出票成功"(TicketIssuedEvent)。领域事件用于跨聚合、跨上下文的异步通信——订单上下文发布OrderPaidEvent,出票上下文订阅,触发出票。这样上下文之间解耦,不需要直接调用对方的接口。

领域服务(Domain Service)

有些业务逻辑不属于任何一个实体或值对象,而是跨实体的操作。比如"转账"——涉及两个账户,不属于任何一个Account,这时候用领域服务TransferService.transfer(from, to, amount)

仓储(Repository)

聚合根的持久化接口。OrderRepository提供save(order)findById(id)。领域层只定义接口(OrderRepository接口),基础设施层实现(JpaOrderRepository实现类)。这样领域层不依赖具体的持久化技术,可以独立测试。

防腐层(Anti-Corruption Layer, ACL)

当前上下文和外部系统之间的翻译层。外部系统的数据模型进来时,先经过ACL转成当前上下文的领域模型,防止"外部概念腐蚀内部模型"。比如订单上下文要调支付系统的接口,支付系统返回的是PaymentResponse,通过ACL转成订单上下文的PaymentResult,订单上下文不直接碰支付系统的数据结构。

1.3 DDD分层的标准架构

┌─────────────────────────────────────┐
│  接口层 (Interface/API)             │  ← Controller、DTO、数据转换
├─────────────────────────────────────┤
│  应用层 (Application)               │  ← 应用服务、编排、事务、领域事件发布
├─────────────────────────────────────┤
│  领域层 (Domain)                    │  ← 聚合根、实体、值对象、领域服务、领域事件
├─────────────────────────────────────┤
│  基础设施层 (Infrastructure)        │  ← Repository实现、MQ、外部接口调用、ACL
└─────────────────────────────────────┘

依赖规则:领域层不依赖任何其他层(最核心、最纯粹),应用层依赖领域层,基础设施层实现领域层定义的接口。这就是依赖倒置——领域层定义OrderRepository接口,基础设施层提供JpaOrderRepository实现。

二、DDD最佳实践指南

2.1 领域建模方法

方法论:事件风暴(Event Storming)

事件风暴是一种协作式建模工作坊,产品、开发、业务方一起参加,用彩色便利贴在白板上建模。

操作流程: 1. 梳理领域事件(橙色便利贴):先把业务流程中发生的所有事件写出来,比如"订单已创建"、"订单已支付"、"出票成功"、"出票失败"、"订单已取消"。按时间线从左到右排列。 2. 识别命令(蓝色便利贴):每个事件由什么命令触发?比如"创建订单"命令触发"订单已创建"事件,"支付"命令触发"订单已支付"事件。 3. 识别聚合(黄色便利贴):命令作用在哪个聚合上?比如"创建订单"和"支付"都作用在"订单"聚合上。 4. 划分限界上下文(大绿色框):把相关的聚合圈在一起。如果两个聚合的"订单"含义不同,它们应该在不同的上下文里。 5. 识别读模型/策略(粉色便利贴):哪些视图或规则需要关注这些事件?比如"用户订单列表"需要订阅"订单已创建"事件来更新视图。

关键原则: - 从事件出发,不要从数据模型出发 - 业务方必须参加,开发自己建模一定会遗漏业务规则 - 先画大图再拆分,不要一开始就纠结上下文边界

2.2 聚合设计策略

聚合设计四原则

  1. 通过聚合根边界保护一致性:聚合内的所有修改必须经过聚合根,保证不变量(invariant)不被破坏
  2. 聚合要小:一个聚合只包含维持一致性所必需的实体和值对象。OrderItem属于Order因为"订单项必须在订单内才有意义",但"收货地址"不属于Order——它是独立的值对象,可以在不同订单间共享
  3. 通过ID引用其他聚合:聚合A需要关联聚合B时,只存B的ID,不存B的完整对象。需要B的数据时通过Repository加载。这样聚合之间没有直接引用,降低耦合
  4. 一个事务只修改一个聚合:如果一次操作要改多个聚合,用领域事件做最终一致性,不用分布式事务

什么时候该合并/拆分聚合: - 如果两个对象总是在同一个事务里一起修改,它们应该在同一个聚合里 - 如果两个对象可以独立修改,即使逻辑上有关系,也应该拆成两个聚合 - 如果一个聚合变得太大(比如Order里面有20个实体),说明可能需要拆分

2.3 代码示例与项目结构

标准DDD项目结构(以订单上下文为例):

order-context/
├── interfaces/                    # 接口层
│   ├── controller/
│   │   └── OrderController.java   # REST接口,接收DTO
│   ├── dto/
│   │   ├── CreateOrderRequest.java
│   │   └── OrderResponse.java
│   └── assembler/
│       └── OrderAssembler.java    # DTO ←→ 领域对象 转换
├── application/                   # 应用层
│   ├── service/
│   │   └── OrderApplicationService.java  # 应用服务(编排,不含业务逻辑)
│   ├── event/
│   │   └── OrderEventPublisher.java      # 领域事件发布
│   └── command/                   # 命令对象
│       └── CreateOrderCommand.java
├── domain/                        # 领域层(最核心,不依赖任何其他层)
│   ├── model/
│   │   ├── aggregate/
│   │   │   └── Order.java         # 聚合根
│   │   ├── entity/
│   │   │   └── OrderItem.java     # 实体
│   │   ├── vo/
│   │   │   ├── Money.java         # 值对象
│   │   │   └── OrderStatus.java   # 值对象(枚举)
│   │   └── event/
│   │       ├── OrderCreatedEvent.java
│   │       └── OrderPaidEvent.java
│   ├── service/
│   │   └── PricingService.java    # 领域服务(跨聚合的逻辑)
│   └── repository/
│       └── OrderRepository.java   # 仓储接口(只有接口,实现在infrastructure层)
└── infrastructure/                # 基础设施层
    ├── repository/
    │   └── JpaOrderRepository.java # 仓储实现
    ├── mq/
    │   └── RocketMQEventPublisher.java  # 事件发布实现
    └── acl/
        └── PaymentACL.java        # 防腐层(对接支付系统)

聚合根代码示例

// 领域层 - 聚合根
public class Order {
    private String orderId;              // 唯一标识
    private OrderStatus status;          // 值对象
    private List<OrderItem> items;       // 内部实体
    private Money totalAmount;           // 值对象
    private List<DomainEvent> events;    // 待发布的领域事件

    // 业务方法 — 所有修改都通过方法,不暴露setter
    public void pay(Money paidAmount) {
        if (this.status != OrderStatus.CREATED) {
            throw new IllegalStateTransitionException("只有CREATED状态才能支付");
        }
        if (!paidAmount.equals(this.totalAmount)) {
            throw new AmountMismatchException("支付金额必须等于订单金额");
        }
        this.status = OrderStatus.PAID;
        this.events.add(new OrderPaidEvent(this.orderId, paidAmount));
    }

    public void cancel() {
        if (this.status == OrderStatus.ISSUED) {
            throw new IllegalStateTransitionException("已出票不能直接取消,请走退票流程");
        }
        this.status = OrderStatus.CANCELLED;
        this.events.add(new OrderCancelledEvent(this.orderId));
    }

    // 查询方法
    public boolean isPaid() { return this.status == OrderStatus.PAID; }
    public List<DomainEvent> getEvents() { return Collections.unmodifiableList(this.events); }
}

注意几个关键设计: - pay()方法里做了状态校验和金额校验——业务规则在领域对象内部,不在Service层 - cancel()拒绝已出票订单的直接取消——不变量保护 - 领域事件在方法内产生,由应用层统一发布 - 不暴露setter,外部无法绕过业务规则直接改状态

应用服务代码示例

// 应用层 - 应用服务(编排,不含业务逻辑)
public class OrderApplicationService {
    private OrderRepository orderRepo;
    private EventPublisher eventPublisher;

    @Transactional
    public void payOrder(String orderId, Money paidAmount) {
        // 1. 加载聚合根
        Order order = orderRepo.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        // 2. 调用领域方法(业务规则在领域对象里)
        order.pay(paidAmount);
        // 3. 持久化
        orderRepo.save(order);
        // 4. 发布领域事件
        order.getEvents().forEach(eventPublisher::publish);
    }
}

应用服务很薄——只有加载→调用领域方法→保存→发布事件四步。所有业务逻辑都在领域层,应用层只做编排。

2.4 DDD落地的关键陷阱

  1. 过度设计:MVP阶段拆9个上下文,交付被拖慢。正确做法是MVP拆3个核心上下文,业务验证后再细化。我的TripPlan就是踩了这个坑。
  2. 贫血模型:领域对象只有getter/setter,业务逻辑全在Service层——这本质上是三层架构穿了DDD的衣服。聚合根必须有行为方法,不能只做数据容器。
  3. 跨聚合事务:一个事务改多个聚合根,导致聚合边界失去意义。正确做法是一个事务一个聚合根,跨聚合用领域事件。
  4. 通用语言不落地:画了领域模型图但代码里的命名和模型不一致,时间一长模型就成了摆设。必须保证代码中的类名、方法名和领域模型一致。
  5. 不建防腐层:直接把外部系统的DTO透传到领域层,导致外部模型腐蚀内部模型。必须加ACL做隔离翻译。

三、DDD面试高频问题与答案

Q1:什么是DDD?为什么要用DDD?

DDD(Domain-Driven Design)是一种以业务领域为核心驱动软件设计的方法论。核心思想是让业务和技术说同一种语言,先理解业务再建模,而不是先建表再写代码。

为什么要用DDD?解决三个问题。一是沟通成本高——产品说"工单"开发理解成"任务"DB里叫"ticket",DDD的通用语言统一概念。二是业务逻辑散落——三层架构下业务逻辑散落在Service层各处,改一个需求要改N个Service,DDD把逻辑收敛到聚合根内部,改动不扩散。三是微服务拆分无依据——按技术层拆(一个用户服务一个订单服务)导致服务臃肿,DDD的限界上下文提供业务语义上的拆分依据。

加分项:举自己项目中的例子——SGS重构,新业务接入成本降低30%,主要来自通用语言对齐减少了沟通成本。

Q2:限界上下文怎么划分?

限界上下文的划分依据是业务语义,不是技术层次。划分原则:

  1. 语言边界:同一个词在不同上下文里含义不同,就应该分开。"订单"在定价上下文和支付上下文关注点完全不同,是两个上下文。
  2. 独立性:一个上下文应该能独立完成一个完整的业务动作。如果两个上下文总是需要同步调用才能完成一个操作,可能应该合并。
  3. 演进性:MVP阶段少拆,业务验证后再细化。我的经验是MVP拆3个(行程规划、订单支付、资源查询),比一开始拆9个好得多。

常见错误:按技术层拆(一个服务只做CRUD)、拆太细(一个聚合一个服务)、不问业务自己拍脑袋拆。

加分项:提事件风暴工作坊——和产品一起建模,不是开发自己闭门造车。

Q3:聚合根怎么设计?事务边界怎么定?

聚合根设计的核心原则是——聚合根是事务一致性的边界

具体来说: - 聚合内的所有修改必须经过聚合根,保证不变量不被破坏 - 一个事务只修改一个聚合根 - 聚合之间通过ID引用,不持有对方的完整对象 - 聚合要尽量小,只包含维持一致性必需的对象

判断两个对象是否在同一个聚合里:如果它们总是在同一个事务里一起修改,就在同一个聚合里;如果可以独立修改,就拆开。

加分项:举例——订单和订单项在同一个聚合(修改订单项数量要校验订单状态),但订单和用户不在同一个聚合(修改用户信息不需要改订单)。

Q4:DDD和微服务是什么关系?

DDD和微服务不是等同关系。限界上下文是微服务拆分的理想依据,但不强制一一对应。

一个限界上下文可以是一个微服务,也可以是一个模块(在单体架构里先按上下文组织代码,后期再拆服务)。关键是不管部署形态如何,代码结构要按上下文组织,这样将来拆微服务时改动最小。

我的实践——携程订单系统,初期是单体架构,但代码已经按订单核心、状态机、支付、通知四个上下文组织。后来拆微服务时,每个上下文拆成一个服务,改动很小。如果一开始代码没有按上下文隔离,拆服务的代价会大得多。

加分项:提"先按DDD组织代码,再按需拆服务"的渐进策略,比"一开始就拆微服务"风险低得多。

Q5:充血模型和贫血模型有什么区别?

贫血模型:领域对象只有属性和getter/setter,没有业务方法,业务逻辑全在Service层。本质上是"面向数据库编程"——先设计表,再生成Entity,业务逻辑写在Service里。

充血模型:领域对象有属性也有行为,业务规则在领域对象内部。外部调用order.pay(amount),pay方法内部校验状态、金额,设置新状态,产生事件。Service层很薄,只做编排。

贫血模型的问题:业务逻辑散落在Service层的各个方法里,改一个业务规则要找多个Service,容易遗漏。充血模型把规则收敛到聚合根内部,改动不扩散。

加分项:用代码对比——贫血的orderService.pay(orderId, amount)vs充血的order.pay(amount),后者业务规则在领域对象内部,更内聚。

Q6:领域事件怎么用?和MQ是什么关系?

领域事件是领域中发生的有业务意义的事情,用于跨聚合、跨上下文的异步通信。

使用流程:聚合根在业务方法内产生事件→应用层取出事件→通过MQ发布→其他上下文订阅处理。

领域事件和MQ的关系:领域事件是业务概念(OrderPaidEvent),MQ是技术实现(RocketMQ/Kafka)。领域层定义事件类型,基础设施层决定用哪个MQ发送。这样领域层不依赖MQ技术。

关键设计:事件发送要和业务操作在同一个事务里(本地消息表),保证事件不丢失。消费端要幂等处理,因为MQ可能重复投递。

加分项:提本地消息表方案——业务SQL和消息插入在同一个事务里,后台轮询发送,消费端幂等。这就是我们订单系统用的方案。

Q7:防腐层(ACL)是什么?什么时候用?

防腐层是当前上下文和外部系统之间的翻译层,防止外部概念腐蚀内部领域模型。

什么时候用:当前上下文需要调用外部系统的接口,但外部系统的数据模型和当前上下文的领域模型不一致。比如订单上下文调支付系统的PaymentResponse,通过ACL转成订单上下文的PaymentResult,订单上下文不直接碰支付系统的数据结构。

ACL的核心价值:外部系统的接口变更只影响ACL的转换逻辑,领域层完全不受影响。这和Adapter模式的思想一致,但ACL更强调"保护内部领域模型不被腐蚀"。

加分项:举SGS项目的ACL实现——灰度开关控制走新领域模型还是走老Service,外部调用方只看ACL接口,不感知背后实现。

Q8:你在项目中DDD踩过什么坑?

我在TripPlan项目踩了最大的坑——MVP阶段拆了9个限界上下文,过度设计。

当时觉得DDD就应该把所有概念都建模成上下文,拆了需求收集、行程引擎、约束求解、定价、订单、支付、通知、用户、资源查询9个。结果是前期在领域模型设计上花了太多时间,拖慢了交付。有些上下文根本没必要独立——通知用领域事件解决不需要单独成上下文,用户管理用现成的认证服务就行。

如果现在重做,MVP拆3个——行程规划、订单支付、资源查询。业务跑起来再细化。DDD拆分应该跟着业务复杂度走,业务没起来就拆9个,是给自己加负担。

第二个坑是贫血模型——初期聚合根只有getter/setter,业务逻辑写在ApplicationService里,改一个规则要翻遍Service代码。后来重构成充血模型,业务规则收敛到聚合根内部,改动终于不扩散了。

加分项:这两个坑都是DDD初学者最常见的错误,面试官会觉得你有真实踩坑经验,不是纸上谈兵。

四、DDD学习路径规划

4.1 分阶段学习路径

第一阶段(1周):建立概念框架

目标:理解DDD的核心概念和整体思路,能看懂DDD相关的文章和讨论。

学习内容: - 精读本文档的"基础概念详解"部分,每个概念用自己的项目场景举例 - 读《领域驱动设计》Eric Evans原著的第1-3章(核心概念),不用读全书,先理解思想 - 看ThoughtWorks的DDD入门文章,理解事件风暴的基本流程

检验标准:能用自己的话解释限界上下文、聚合根、领域事件这三个核心概念,能说清楚DDD和三层架构的区别。

第二阶段(1周):理解代码结构

目标:能看懂DDD的代码组织方式,理解充血模型。

学习内容: - 精读本文档的"最佳实践指南"中的代码示例和项目结构 - 在GitHub上找一个DDD实战项目(推荐spring-boot-ddd或ddd-practices),对照项目结构理解每一层的作用 - 重点理解:为什么领域层不依赖基础设施层(依赖倒置),为什么聚合根要有行为方法(充血模型),为什么DTO和领域对象要分开转换

检验标准:给一个三层架构的Service,能把它改写成DDD的聚合根+应用服务结构。

第三阶段(1周):结合自己项目建模

目标:能对自己熟悉的项目用DDD方法建模。

学习内容: - 用事件风暴方法对自己项目建模——先写领域事件,再识别聚合,再划分上下文 - 和本文档中的项目1、项目7、项目9对比,看自己的建模思路和实际踩坑是否一致 - 重点思考:你的项目哪些是核心域/支撑域/通用域?MVP应该拆几个上下文?哪些聚合的边界可能画错?

检验标准:能画出自己项目的领域模型图(事件+聚合+上下文),能说出为什么这样拆、不这样拆会怎样。

第四阶段(持续):面试准备

目标:能流畅回答DDD相关面试问题。

学习内容: - 熟记本文档"DDD面试高频问题与答案"部分的8个问题 - 每个问题的回答都要结合自己项目举例,不能只背概念 - 准备2-3个DDD踩坑故事(过度设计、贫血模型、通用语言不落地),面试时主动讲

检验标准:模拟面试,每个DDD问题能在2分钟内流利回答,追问2-3层不卡壳。

4.2 推荐学习资源

必读(按优先级排序): 1. 本文档 — 结合面试场景,最实用 2. 《实现领域驱动设计》(Vaughn Vernon) — 比Eric Evans的原著更实操,有代码示例 3. 《领域驱动设计》(Eric Evans) 第1-3章 — 理解核心思想,全书可选读

参考项目: 1. spring-boot-ddd — Spring Boot + DDD标准分层 2. ddd-practices — DDD实战示例集合

选读: 1. 《Clean Architecture》— 理解分层和依赖规则 2. Martin Fowler的BoundedContext文章 — 限界上下文详解 3. Event Storming官方教程 — 事件风暴操作方法

4.3 掌握程度自评

级别 能力标准 对应面试水平
L1 概念了解 能说出DDD的核心术语定义 能回答"什么是DDD"
L2 原理理解 能解释限界上下文划分原则、聚合设计原则、为什么充血优于贫血 能回答设计类问题
L3 项目实战 有真实DDD项目经验,踩过坑,能结合项目举例 能应对追问和深挖
L4 架构决策 能判断项目是否适合DDD、MVP拆几个上下文、什么时候用领域事件 vs 直接调用 能回答架构决策类问题

面试目标:至少达到 L3,理想 L4。L1/L2只有概念没有实战,面试官一追问就见底。


模块十:反问清单

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