写 Java 写久了,你总会撞见一些让人血压升高的代码。
查个用户信息,请求要先从 Controller 跳到 Facade,再到 Service、BizManager、DomainService,最后才落到 Repository。六层调用,前五层除了转发参数什么都没干。一个打折功能,满减和折扣两种规则而已,却被设计成策略接口 + 两个实现 + 一个工厂 + 一个反射注册中心。还有个日活不到五百的内部系统,被拆成十几个微服务,改一行代码要拉五个团队开会。
这些代码看着都挺"专业":模式齐整、分层规范、架构图画满一整屏。可它们干的是同一件事——把本来简单的问题搞复杂了。这就是过度设计。
什么是过度设计
说白了就是:手头这点需求,用不着这么复杂的方案。
这里有个关键区别要分清。提前设计(design upfront)是好事,你总得想清楚再动手。过度设计不是想太多,是方向想岔了,为了那些根本不会发生的需求,提前把架子搭好了。
怎么判断一段设计是不是过度?我有个很朴素的标准:要是把这层抽象、这个模式、这个服务删掉,业务照样跑,而且接下来半年大概率不会因为这个决定出事,那它多半就是多余的。
下面分代码和架构两层来看。
代码层面的过度设计
例 1:接口泛滥
1 | // Every service has an interface, but only ever one implementation. |
这套 IUserService 配 UserServiceImpl 的写法,老项目里一抓一大把。问题是它从头到尾就一个实现。多出来的这个接口,除了让 IDE 多跳一次、让项目多一个文件,没带来任何东西。
有人会说,这是为了"以后好扩展,好 Mock"。先说 Mock,现在的 Mockito 早就能直接 mock 具体类了。再说扩展,我见过的项目里,这种预留的接口九成都没等到第二个实现。
1 |
|
什么时候真该抽接口?等你确实写出了第二个实现,或者这个接口要被好几个模块依赖、需要隔一隔依赖方向的时候。在那之前,有个经验法则叫 Rule of Three:一样的东西出现三次,再动手抽象。
例 2:过度的分层
1 |
|
四层调用,前三层全在转发。读代码要开四个文件,打断点要打四次,可每一层都没干任何实事。
1 |
|
两层就够了。分层这件事,目的是把职责分开,不是凑数。每一层都该有它存在的理由,要么是被多处复用,要么是隔离了什么。如果某一层的工作只是把参数递给下一层,它就是噪音,删掉对谁都好。
例 3:模式的炫技
1 | public interface DiscountStrategy { |
就两条折扣规则,搞了接口、两个实现、一个工厂、一个注册表。看着像那么回事,可两分支的事。
1 | public BigDecimal applyDiscount(Order order, DiscountType type) { |
一个 switch 就完事了。等折扣规则真多到十几条、还隔三差五要加、甚至要让运营自己配的时候,再上策略模式也不迟。
一个常见的误区,是看到问题先想"该套哪个模式"。模式是用来解决问题的,不是用来证明你会写代码的。GoF 那本书叫《设计模式》,可不叫《你非得用的模式》。
例 4:配置化滥用
1 | # application.yml |
什么都往配置文件里塞,连一些一辈子都不会变的常量也是。结果就是,看代码的人根本不知道这些数字代表什么,改个值得翻半天 yml,写单测还得 mock 配置中心。
1 | public final class UserConstants { |
真正该配置化的,是那些在不同环境取值会变、或者要在不停机的情况下调的值,比如数据库地址、第三方密钥、限流阈值。那些不会动的业务常量,老老实实写成代码里的常量,反而清晰得多。
例 5:DTO / VO / BO / PO / Entity 一拆到底
1 | class UserDTO {} // for controller |
一个用户对象,硬生生拆成五层,层与层之间用 BeanUtils.copyProperties 来回拷贝。一个简单的增删改查,一半代码在搬数据。
大多数场景下,UserDTO(对外)加 User(领域加持久化)两个对象就够。等持久化模型和业务模型真的差异很大、不得不分的时候,再单独抽 PO。
这些层是为了应对"不同方向的变化"才存在的,持久化的变化节奏和对外契约的变化节奏不一样。它不是用来对齐教科书的。
架构层面的过度设计
代码层面的过度设计,影响是局部的,忍一忍、改一改还能收拾。架构层面要是过度了,拖垮的是整个团队的交付节奏。
例 1:微服务滥用
一个日活几百的内部管理系统,拆出用户、订单、商品、支付、通知、搜索一堆服务,外加网关和配置中心。改个小需求要动三四个服务,本地开发根本起不全依赖。
应该从模块化单体(Modular Monolith)开始。按业务边界把模块划清楚,但部署在一个进程里。等某个模块的团队规模、部署节奏、性能要求真的撑不住了,再把它单独拆出去。
微服务的成本大头,是分布式系统本身带来的:网络会抖、数据要一致、链路要追踪、服务要发现、配置要下发。这些代价只有在团队和业务复杂到一定程度时才划算。Netflix 拆微服务,是因为它有上千号工程师和全球流量,换个体量你得自己掂量。
例 2:过早引入消息队列
所有写操作都先丢进 Kafka,下游消费了再落库,美其名曰"解耦、异步、可扩展"。可实际吞吐量低得很,一天几千条消息,却要天天排查丢消息、重复消费、顺序乱、最终一致性对不上的问题。
能用同步就用同步。真要上 MQ,通常是这么几种情况:一是削峰,瞬时流量远超下游能扛的量,比如秒杀;二是可靠地异步投递,有些耗时操作不想卡住主流程,比如发邮件、跑报表;三是系统之间要解耦,生产者和消费者是不同团队,发布节奏对不齐。
MQ 不会让系统更简单。它只是把同步的麻烦,换成了异步的麻烦。后一种麻烦,排查起来通常更让人头疼。
例 3:DDD 战术过度
一个标准增删改查的后台,非要套上完整的 DDD 战术:聚合根、值对象、领域服务、仓储、防腐层,再叠个 CQRS 和事件溯源。新建一个用户,请求要走应用服务、领域服务、聚合根,还得处理领域事件。
业务简单的时候,贫血模型加 Service 加 Repository 就够了。DDD 那套战术设计,是为业务规则很厚、状态机很复杂、不变量要跨多个实体保证的场景准备的。要是你的"领域逻辑"主要就是 CRUD,那这套东西就是负担。
这里要分清楚:DDD 是一套建模的思路,不是非得照搬的那一堆战术名词。
例 4:分布式事务的滥用
两张表的简单写入,因为分了库,就上 Seata、Saga、TCC,搞一堆补偿逻辑和事务协调器,出了问题像在破案。
先想想这几个问题:能不能合到一个库,用本地事务搞定?能不能用业务幂等加本地消息表加重试?是不是真的要强一致,多数业务其实接受最终一致就行。
分布式事务应该是兜底的招,不是默认选项。每加一种分布式一致性方案,运维复杂度就上一个台阶。
为什么会写出过度设计的代码
这一节,我觉得是全文最该认真看的。知道病根在哪,比知道症状重要。
-
简历驱动开发。 用新技术堆简历。“项目用了微服务 + Kafka + K8s + DDD”,写在简历上,怎么都比"一个单体 Spring Boot 应用"唬人。复杂度成了个人履历的垫脚石,代价由整个团队和后面接手的人扛。
-
对扩展性的错估。 “以后用户量大了怎么办?”"以后要支持好几种支付怎么办?"这么一想,就提前把扩展性极好的架子搭好了。可现实是,大多数系统的"以后"根本不会来;就算来了,当初预留的扩展点十有八九也对不上路,业务早变了。这就是 YAGNI 反复说的那句话:别为想象中的需求写代码。真正的可扩展,是代码足够简单清晰,需求来了能快速改,而不是提前预留一堆用不上的接口。
-
用技术复杂度盖住对业务的不懂。 一个人没把业务吃透的时候,往往会本能地在技术上"加戏"。技术是确定的、可控的,业务是含糊的、难缠的。用一堆模式和架构把简单业务包起来,能给自己一种"我在干有技术含量的事"的错觉。但真厉害的人是反着来的,他们想办法把复杂业务用最简单的技术说清楚。
-
教条主义。 《设计模式》《企业应用架构模式》《领域驱动设计》都是好书,但它们讲的是工具,不是义务。书看多了,容易养成"先想用哪个模式"的惯性,而不是先想"这问题到底有多复杂,要不要上模式"。
-
架构图崇拜。 一整屏方框和箭头的架构图,在评审会上确实唬人。但图上每多一个方框,线上就多一个可能挂掉的组件、多一份要维护的代码。PPT 里的优雅和运行时的复杂度,常常是反着的。
-
缺乏反馈,也不敢重构。 代码一旦按复杂的方式写出来,后面就很少有人敢动它。沉没成本加"改坏了谁负责"的顾虑,让过度设计的代码能一直活下去。一个团队要是没有持续重构的习惯,复杂度只会单方向往上堆。
-
KPI 和"技术含量"。 当晋升考核很看"技术深度"的时候,工程师有动力把简单问题做复杂。一个漂亮的简化方案,在汇报时往往不如一个自研中台来得亮眼。这是组织层面的问题,但确实存在。
我自己也没少踩这些坑。刚工作那两年,写个东西恨不得把二十三种设计模式全塞进去,觉得用了模式就是高级。后来接手过别人的烂代码,也被自己当年埋的雷炸过几次,才慢慢明白,能不写就不写、能少写就少写,才是真本事。
如何避免过度设计
知道了原因,再说怎么办。下面这几条是我自己用着顺手的,没什么高深的,都是些具体动作。
-
先把功能跑通,再说设计。 过度设计最容易在还没动手时发生。脑子里先搭好一座完美架构,然后硬把业务塞进去。换个顺序:先把最直接的实现写出来、跑起来,等真的撞上重复、撞上变化,再回头抽象。演化出来的设计,几乎总比一开始预判的准。
-
守住 Rule of Three。 同样的东西,第一次直接写,第二次忍受重复,第三次才动手抽象。提前抽象的代价,往往比容忍几次重复更大,因为你猜中正确抽象点的概率,真的不高。
-
每一层抽象都要有个"现在就成立"的理由。 加一个接口、一层封装、一个模式之前,先回答它解决了哪个具体的痛点。如果答案含糊,是"以后可能用得上""看起来更规范"之类,那就先别加。抽象的门票,是当下真实存在的复杂度。
-
把"能不能删"放进 code review。 设计阶段做加法很容易,做减法很难。团队 review 的时候,除了查 bug 和规范,值得专门问一句:这里面有没有哪一层、哪个类、哪个接口是多余的?把"简化"当成一个明确的事项,而不是顺手带过。
-
拿数据说话,别拿想象说话。 要不要加缓存、要不要拆服务、要不要上 MQ,最好都有真实指标撑着,比如慢查询、QPS、错误率、响应时间。没有数据的"优化",十有八九是在给自己和后人挖坑。
这几条没什么技术含量,难的是每次都照着做。
什么时候应该用复杂架构
说了这么多"别过度",得把话讲全:复杂架构本身没罪,关键是用对地方。下面是个简单的判断框架。
四个判断维度
| 维度 | 倾向简单 | 倾向复杂 |
|---|---|---|
| 团队规模 | 1-2 个小团队 | 多个独立团队,需独立交付 |
| 业务复杂度 | CRUD 为主,规则简单 | 状态多、规则交织、领域厚 |
| 性能/规模 | 低并发、数据量小 | 高并发、海量数据、对延迟敏感 |
| 演进节奏 | 需求稳定,迭代慢 | 多团队并行开发,部署节奏不一 |
| 可用性要求 | 可接受短时停机 | 核心链路,要求高可用、故障隔离 |
具体技术的引入时机
- 微服务:有多个能独立负责的团队,领域边界清楚,需要独立部署和故障隔离的时候。压垮单体的常常不是流量,是团队协作的成本。
- 消息队列:要削峰、要可靠异步、要跨系统解耦,而且能接受最终一致性的时候。
- DDD 战术设计:领域规则复杂、不变量多,贫血模型已经写不下业务逻辑的时候。
- 分布式事务:跨服务强一致是硬性要求,其他办法(本地事务、幂等重试)都走不通的时候。
- 缓存层:读多写少、计算昂贵,数据库顶不住读压力的时候。
- 分库分表:单表数据量真的成了瓶颈(一般千万级以上、并且有明显性能问题)的时候。
一个心法
复杂度不会消失,它只会搬家。
引入一套复杂架构,本质上是做一笔交换:用它解决的问题,换掉它自己带来的麻烦。只有前者的麻烦明显更大,这笔账才划算。
举个例子。你的单体系统一周挂一次、每次影响几千用户,那引入服务隔离、限流、熔断的复杂度是值的。可要是它一年都难得挂一次,用户也就那么几十个,这些"高可用"设施就是纯负担。
减法思维
对付过度设计,最趁手的武器是想减法。
写完一段代码,问问自己,能不能少一层、少一个模式?做完一个方案,问问自己,哪些组件是现在非有不可的,哪些是"以后再说"?看别人的方案,也问问自己,要是只留一半,系统还跑得起来吗?
有人会把"做减法"等同于偷懒,这其实是误解。把一个复杂系统做简单,比把一个简单系统做复杂难得多。它要求你真的看透问题的本质,分得清哪些复杂度是问题本身带的(essential),哪些是我们自己加戏加出来的(accidental)。
最后
下次动手写代码、画架构之前,先停下来问一句:这事,真的需要这么复杂吗?
十有八九,答案是不需要。