十一月的广州依然带着些许湿热,坐在图书馆的靠窗位置,我习惯性地打开IDE,准备重构一下大二时写的一个旧项目。看着满屏的Spring Cloud Alibaba依赖、Nacos配置和各种Feign Client,我停下了敲击键盘的手。
作为一名普通民办三本的软件工程学生,在过去的三四年里,我一直处于一种技术焦虑之中。为了在简历上多增加一些亮点,为了弥补学历上的不足,我曾疯狂地追逐各种技术栈的“高大上”。在技术社区里,“微服务”这个词自带光环,仿佛不用微服务,你的项目就落后了,你的技术就停滞了。这种风气在学生群体和初级开发者中尤为明显,大家似乎都在暗暗较劲,看谁的项目拆得更细,看谁引入的中间件更多。
但随着这两年参与了一些实际项目的开发,踩过了一些真实的坑,再加上在专业课程中对软件工程本质的反复咀嚼,我对微服务有了截然不同的看法。
单体架构,在现在的语境下似乎成了一个贬义词,代表着臃肿、老旧、难以维护。但很多人忽略了一个事实,许多在商业上取得巨大成功的产品,在很长一段时间内,甚至直到现在,核心系统依然是单体架构。比如Shopify,它的核心是一个巨大的Ruby on Rails单体应用,支撑着全球数百万商家的交易;再比如程序员每天都在用的Stack Overflow,凭借着精心优化的.NET单体架构,仅仅用极少的服务器就服务了千万级别的用户。
单体架构的优点被严重低估了。首先是部署的简单性。回想我大一时写的第一个Spring Boot项目,打成一个完整的jar包,扔到服务器上,一行java -jar命令,系统就跑起来了。没有复杂的环境依赖,没有容器编排的烦恼。其次是调试的极度便利。在单体应用中,所有的业务逻辑都在同一个JVM进程里。你在Controller层打一个断点,按下F9,你可以清晰地跟着执行线程,一步步深入到Service层、DAO层,你可以完整地看到整个调用栈和上下文变量。这种所见即所得的调试体验,在分布式系统中是极其奢侈的。
更重要的是,单体架构天然支持本地事务。一个订单创建的逻辑,涉及到扣减库存、生成订单记录、增加用户积分。在单体里,只需要在方法上加一个@Transactional注解,数据库的ACID特性会为你搞定一切。同时,模块之间的方法调用仅仅是内存级别的函数压栈和出栈,完全没有网络调用的开销,也没有序列化和反序列化的性能损耗。
当我们盲目地将一个原本清晰的单体应用拆分成微服务时,我们往往没有意识到自己将要面对怎样真实的成本。
在大三上学期,我和另外三个同学组队参加一个软件设计比赛,做的是一个校园二手交易平台。为了让架构图看起来“专业”,我们硬生生地把系统拆分成了用户服务、商品服务、订单服务、支付服务和消息服务。结果,这成了我们噩梦的开始。
拆分之后,我们首先要处理服务发现与注册,于是引入了Nacos。接着,原本一个注解就能解决的事务问题,变成了棘手的分布式事务。为了保证数据一致性,我们不得不引入Seata,去配置复杂的undo_log表,去理解AT模式下的全局锁机制,这让原本简单的业务逻辑变得异常晦涩。
服务间的通信也是个大坑。我们使用OpenFeign进行HTTP调用,随之而来的是各种网络超时、重试机制的配置。有一次,订单服务调用商品服务扣减库存,因为商品服务冷启动导致第一次调用超时,触发了Feign的自动重试,结果库存被多扣了一次。为了排查这个问题,在没有完善的链路追踪系统的情况下,我们四个人只能各自打开控制台,肉眼在一堆杂乱的日志中寻找相同的请求参数,那种无力感我至今记忆犹新。
此外,还有统一的配置管理、容器编排、每个服务独立的CI/CD流水线。我的那台16G内存的笔记本,每次本地启动整个微服务集群时,风扇都会狂转,内存占用率直逼95%。对于我们这样一个只有4个人的小团队,这些额外的运维复杂度和中间件学习成本,几乎吞噬了我们80%的开发时间。我们每天都在解决环境问题、网络问题、配置问题,真正用来思考业务逻辑的时间所剩无几。
那么,究竟什么时候才应该把系统拆分成微服务?
架构的演进从来不是因为某种技术“先进”才去采用,而是因为现有的架构无法解决当前遇到的问题。微服务本质上解决的是组织架构和系统扩展性的问题。
第一个触发点是团队规模的增长。如果你的团队只有三五个人,大家坐在一起,吼一嗓子就能沟通清楚,单体架构是最高效的。但如果你的研发部门扩张到了几十人甚至上百人,几百个开发人员同时在一个代码库里提交代码,每天都在处理Git冲突,每次发布都要协调所有业务线的测试进度,这时候,多个团队需要独立迭代、独立部署,微服务的边界就成了团队协作的边界。
第二个触发点是模块边界的清晰度。在一个系统中,不同模块的变更频率往往差异巨大。比如一个电商系统,营销活动的逻辑可能每天都在变,而底层的用户鉴权模块可能半年都不改一次。如果它们绑定在同一个单体里,营销模块的每一次微小改动,都需要把鉴权模块也跟着重新部署一次,这无疑增加了风险。当模块之间的职责边界足够清晰,且变更频率差异显著时,拆分才有意义。
第三个是异构技术的需求。现在的系统越来越复杂,有时候单一的语言无法满足所有场景。比如我们的主业务线是Java写的,但现在需要引入一个AI图像识别的功能,这个功能显然用Python和PyTorch来实现更合适。这时候,将图像识别作为一个独立的微服务拆分出去,通过gRPC或HTTP与Java主服务进行通信,就是一个非常合理的架构选择。
第四个是独立的扩容需求。系统的不同部分对资源的消耗是不同的。比如一个视频网站,视频转码模块是CPU密集型的,而用户评论模块是IO密集型的。在促销活动期间,可能只有订单模块的流量会暴增。如果是单体架构,为了应对订单模块的高并发,你不得不把整个庞大的单体应用水平扩展出几十个实例,造成了巨大的资源浪费。只有当某个模块需要独立的扩缩容策略时,将它独立成微服务才能体现出资源利用的效率。
回顾这几年的代码历程,从最初的盲目崇拜,到中间的痛苦踩坑,再到现在的平静审视,我对架构设计有了一些务实的感悟。
对于大多数从零开始的商业项目或个人项目,我现在的首选建议总是:先单体,后拆分。但这并不是说我们要写一团像意大利面条一样混乱的代码。我们应该从Modular Monolith(模块化单体)开始。在同一个代码库里,通过Maven或Gradle的多模块管理,严格划分API层、核心业务层和基础设施层。在代码级别强制规定模块间的依赖关系,禁止跨模块的数据库直接访问,所有的交互都必须通过预定义的接口进行。
只要你在单体阶段把领域边界划分清楚,把接口设计合理,那么当未来某一天,业务规模真的发展到了需要微服务的时候,你只需要把这些本地的接口调用替换成RPC调用,把本地事务替换成柔性事务,这个拆分过程会非常平滑。
按需拆分,是软件工程里最难把握的火候。只在遇到实际的痛点,比如部署实在太慢了、冲突实在太多了、性能瓶颈实在无法通过加机器解决的时候,才去把那个最痛的模块单独拆出来。
过早优化是万恶之源。作为一名GPA4.02的学生,我也曾追求过代码的完美和架构的极致,但我慢慢懂得,软件工程是一门关于权衡的艺术。在业务还没有活下来之前,去谈论支撑千万并发的微服务架构,是一种自欺欺人的技术傲慢。
大学四年即将结束,拿了两次国奖,写了几十万行代码,我最大的收获或许并不是掌握了多少种中间件的用法,而是学会了克制。架构从来都不是某种需要去顶礼膜拜的信仰,它只是一件工具。优秀的工程师不会因为锤子看起来很酷,就把所有的螺丝都当成钉子来砸。选择最适合当前团队规模、业务阶段和资源状况的方案,而不是一味追求简历上的“技术先进”,这才是软件工程真正要教给我们的事。