微服务是现在非常流行的架构风格很多团队都在把单体应用拆分成微服务希望通过微服务提升系统的可扩展性可维护性和开发效率微服务确实有很多好处,比如服务独立部署独立扩展技术栈灵活团队自治等等,但是微服务拆分不是一件简单的事情拆不好反而会带来很多问题比单体应用更难维护更难开发甚至导致系统不稳定故障频发。

最近我们团队在做微服务拆分把一个庞大的单体应用拆分成多个微服务踩了不少坑有些问题让我熬夜排查很久才解决今天想记录一下这些踩坑经历包括遇到的问题原因分析以及解决方案希望能帮大家在做微服务拆分的时候,少走弯路不熬夜。

一、坑1:服务拆分粒度不合理太细或太粗

第一个坑是服务拆分粒度不合理要么太细要么太粗这是微服务拆分最常见的问题也是最难把握的问题。

我们一开始拆分的时候,走了两个极端一开始怕拆得太细复杂度高,所以拆得比较粗一个服务包含了很多功能,比如把用户权限消息通知都放在一个服务里结果这个服务还是很庞大和单体应用差不多没有享受到微服务的好处团队之间,还是互相影响部署还是要协调没有达到拆分的目的。

后来我们又走向了另一个极端觉得要拆得够细才叫微服务,所以把一个功能拆成了好几个服务,比如把用户服务拆成了用户基本信息服务用户地址服务用户偏好服务等等结果服务数量暴增系统复杂度大大提升一个简单的用户查询需要调用好几个服务网络开销大延迟高,而且服务之间,的依赖关系复杂出了问题很难排查维护成本非常高团队也被拆分搞得疲惫不堪。

原因分析

服务拆分粒度是微服务设计的核心也是难点拆得太粗没有微服务的好处和单体差不多拆得太细系统复杂度太高维护成本高性能也差很多团队在拆分的时候,容易走极端要么不敢拆要么过度拆都是不对的。

服务拆分的依据应该是业务的边界和内聚性也就是领域驱动设计(DDD)里的限界上下文(Bounded Context)把业务上紧密相关的功能放在一个服务里把业务上不同的功能分开一个服务应该对应一个业务能力,或者一个限界上下文而不是按技术层拆分(比如把数据访问单独拆成一个服务)也不是按表拆分(一张表一个服务)。

解决方案

我们后来重新梳理了业务领域用DDD的方法划分限界上下文,然后按限界上下文来拆分服务一个限界上下文对应一个服务,比如用户上下文对应用户服务订单上下文对应订单服务商品上下文对应商品服务等等这样拆分出来的服务粒度比较合适既不会太粗也不会太细每个服务都有明确的业务边界和职责服务之间,的依赖也比较清晰。

另外我们还遵循了一些拆分原则:

  • 服务高内聚低耦合:一个服务内部的功能紧密相关服务之间,的依赖尽量少尽量松。
  • 服务独立部署独立扩展:每个服务都能独立部署独立扩展不需要依赖其他服务一起部署。
  • 服务团队自治:每个服务由一个小团队负责团队能独立开发测试部署运维自己的服务不需要依赖其他团队。
  • 先粗后细逐步拆分:不要一开始就拆得很细先按大的业务边界拆成几个大服务,然后根据实际情况再逐步细化拆分不要一步到位过度设计。

经过重新拆分之后,服务数量从原来的几十个减少到了十几个,但是每个服务的职责更清晰边界更明确系统复杂度降低了维护成本也降低了性能反而提升了,因为服务之间,的调用少了网络开销小了。

二、坑2:分布式事务处理不当数据不一致

第二个坑是分布式事务处理不当导致数据不一致这是微服务拆分后最常见也最棘手的问题之一。

在单体应用里所有操作都在一个数据库事务里要么都成功要么都失败数据一致性有保障,但是拆分成微服务之后,一个业务操作可能涉及多个服务多个数据库,比如下单操作需要调用订单服务创建订单调用库存服务扣减库存调用用户服务扣减余额这三个操作在三个不同的服务和数据库里没法用本地事务来保证一致性就会出现分布式事务的问题。

我们一开始拆分的时候,没有好好处理分布式事务的问题还是按照单体的思维来写代码结果出现了数据不一致的问题,比如订单创建成功了,但是库存扣减失败了导致超卖,或者库存扣减成功了,但是订单创建失败了导致库存少了,但是没有订单这些问题都很严重影响业务我们熬夜排查了很久才发现是分布式事务的问题。

原因分析

微服务架构下分布式事务是不可避免的,因为一个业务操作往往涉及多个服务多个数据库而分布式事务的处理比本地事务复杂很多,因为网络可能延迟,或者失败服务可能宕机,所以没法像本地事务那样简单地保证ACID特性。

很多团队在拆分微服务的时候,忽略了分布式事务的问题还是按照单体的思维来写代码觉得调用多个服务就像调用多个方法一样要么都成功要么都失败结果就出现了数据不一致的问题这是微服务拆分的常见坑。

解决方案

我们后来学习了分布式事务的处理方案根据不同的业务场景选择不同的方案主要有以下几种:

1. 最终一致性(异步消息)

对于大部分业务场景最终一致性就够了不需要强一致性我们用消息队列(比如RocketMQKafka)来实现最终一致性,比如下单操作订单服务创建订单成功后发送一条订单已创建的消息库存服务订阅这个消息扣减库存用户服务订阅这个消息扣减余额,如果某个服务处理失败了就重试,或者人工介入保证最终一致。

最终一致性的好处是性能好系统吞吐高服务之间,解耦缺点是数据不是实时一致的有短暂的不一致窗口需要业务能容忍这种短暂的不一致大部分互联网业务都能容忍最终一致性,所以这是我们用得最多的方案。

2. TCC(Try-Confirm-Cancel)

对于需要强一致性的业务场景,比如资金交易我们用TCC模式TCC是一种补偿型的分布式事务方案分为三个阶段Try(尝试预留资源)Confirm(确认提交)Cancel(取消回滚)业务操作先Try预留资源都成功了就Confirm提交有一个失败了就Cancel回滚释放资源。

TCC的好处是能保证较强的一致性缺点是实现复杂每个服务都要实现TryConfirmCancel三个接口开发成本高,而且可能出现空回滚悬挂等问题需要处理,所以我们只在关键的资金交易场景用TCC其他场景还是用最终一致性。

3. 本地消息表(事务消息)

还有一种方案是本地消息表也叫事务消息就是在本地事务里,同时写入业务数据和消息数据到本地数据库,然后异步把消息发送到消息队列保证业务操作和消息发送的原子性不会出现业务成功了,但是消息没发出去的情况RocketMQ的事务消息就是这种方案的实现用起来很方便。

我们在一些场景用了RocketMQ的事务消息效果很好能保证业务操作和,消息发送的一致性实现起来也不复杂推荐大家使用。

4. 避免分布式事务

最好的分布式事务方案是避免分布式事务也就是在拆分服务的时候,尽量让一个业务操作只涉及一个服务一个数据库这样就没有分布式事务的问题了,比如把订单和库存放在一个服务里下单的时候,在一个本地事务里创建订单和扣减库存就没有分布式事务的问题了。

当然不是所有业务都能避免分布式事务,但是在拆分服务的时候,要尽量考虑这个问题把需要强一致性的业务放在一个服务里减少分布式事务的场景这是最有效的方案。

经过这些方案的综合运用我们解决了数据不一致的问题系统稳定了很多再也没有出现超卖,或者数据对不上的情况了。

三、坑3:服务间调用超时和重试设置不当引发雪崩

第三个坑是服务间调用超时和重试设置不当引发雪崩效应这是,微服务架构下非常危险的问题可能导致整个系统崩溃。

我们一开始拆分微服务的时候,服务间调用的超时时间设置得比较长(比如30秒)而且重试次数设置得比较多(比如3次)觉得这样更可靠不会,因为偶尔的网络抖动而失败结果有一次一个底层服务(用户服务)因为数据库慢查询响应变慢了调用它的上层服务(订单服务)请求都超时了,然后重试又发了3次请求导致用户服务的请求量瞬间变成了原来的4倍用户服务本来就慢现在请求量又暴增直接被打挂了用户服务挂了之后,订单服务的请求也都失败了,然后更上层的服务也跟着失败最后整个系统都不可用了引发了雪崩效应我们熬夜排查了很久才恢复系统。

原因分析

微服务架构下服务之间,相互调用形成了调用链,如果某个底层服务变慢,或者不可用而上层服务的超时时间设置得太长重试次数太多就会导致请求堆积线程耗尽,然后请求量,因为重试而暴增把本来就慢的底层服务彻底打挂,然后故障沿着调用链向上蔓延导致整个系统都不可用这就是雪崩效应。

超时时间设置得太长会导致请求长时间占用线程不释放线程很快就被占满新的请求没法处理重试次数太多会导致请求量放大本来一个请求失败了重试3次就变成了4个请求底层服务本来就压力大现在请求量又翻了几倍直接被打挂,所以超时和重试的设置非常重要设置不当会引发严重的问题。

解决方案

我们后来重新调整了服务间调用的超时和重试设置,并且加上了熔断降级机制防止雪崩效应。

1. 合理设置超时时间

超时时间不要设置得太长要根据服务的实际响应时间来设置一般设置为服务正常响应时间的2-3倍就够了,比如服务正常响应时间是100ms超时时间设置为300ms就够了不要设置成30秒那样太长了会导致请求长时间占用线程引发问题。

另外超时时间要在整个调用链上合理分配,比如一个调用链有3层服务总超时时间是1秒,那么每层的超时时间要小于总超时时间,比如第一层500ms第二层400ms第三层300ms不要每层都设置1秒那样总超时时间会变成3秒不符合预期。

2. 合理设置重试次数

重试次数不要设置得太多一般1-2次就够了不要设置3次以上那样请求量放大太严重,而且要只对幂等的接口重试非幂等的接口(比如创建订单支付)不要重试,否则会导致重复创建订单重复支付等问题。

另外重试要加退避机制(Backoff)不要失败了立即重试要等一段时间再重试,而且每次重试的间隔逐渐增加(指数退避)避免集中重试把底层服务打挂,比如第一次失败等100ms重试第二次失败等200ms重试第三次失败等400ms重试这样能减少对底层服务的冲击。

3. 加上熔断降级机制

光调整超时和重试还不够还要加上熔断降级机制当某个服务的错误率,或者响应时间超过阈值时自动熔断对该服务的调用后续请求直接返回降级结果不再调用该服务避免故障蔓延引发雪崩等服务恢复后再自动恢复调用。

我们用Hystrix(后来也用Resilience4j和Sentinel)来做熔断降级给每个服务调用都加上了熔断降级配置当某个服务出问题的时候,会自动熔断返回降级结果不会把故障蔓延到其他服务也不会,因为重试而把底层服务打挂系统的稳定性大大提升了。

4. 加上限流机制

另外还要加上限流机制限制每个服务的请求量防止突发流量把服务打挂我们用Sentinel来做限流给每个服务的接口都设置了合理的QPS阈值超过阈值的请求直接拒绝,或者排队保证服务不会被突发流量打挂限流也是保护系统稳定性的重要手段。

经过这些调整和机制的加入我们再也没有出现过雪崩效应系统稳定了很多,即使某个服务出问题了也不会影响整个系统其他服务还能正常运行只是相关功能降级而已。

四、坑4:服务拆分后数据库联表查询困难

第四个坑是服务拆分后数据库联表查询困难这是微服务拆分后很常见的问题特别是对于查询需求复杂的系统。

在单体应用里所有数据都在一个数据库里可以随便写SQL联表查询很方便,但是拆分成微服务之后,每个服务有自己的数据库服务之间,不能直接联表查询了,比如要查询订单列表带用户名称和商品名称订单数据在订单服务的数据库用户数据在用户服务的数据库商品数据在商品服务的数据库没法直接写SQL联表查询了怎么办?

我们一开始拆分的时候,没有好好考虑这个问题还是按照单体的思维来做查询结果在订单服务里调用用户服务查询用户信息调用商品服务查询商品信息,然后在内存里拼接数据结果一个订单列表查询需要调用N次用户服务和N次商品服务(N是订单数量)性能非常差延迟很高,而且给用户服务和商品服务带来了很大的压力我们熬夜优化了很久才解决这个问题。

原因分析

微服务拆分后每个服务有自己的数据库数据分散在不同的数据库里没法直接联表查询这是微服务架构的必然结果很多团队在拆分的时候,只考虑了写操作的拆分没有考虑读操作的查询需求结果拆分后查询变得非常困难性能很差这是常见的坑。

解决方案

我们后来学习了CQRS(命令查询职责分离)模式来解决查询的问题,并且用了一些其他的方案综合解决。

1. CQRS模式

CQRS(Command Query Responsibility Segregation)命令查询职责分离核心思想是把写操作(Command)和读操作(Query)分开用不同的模型和,存储写操作用领域模型走微服务的业务逻辑保证数据一致性读操作用专门的查询模型走专门的查询存储(比如宽表搜索引擎缓存等等)保证查询性能。

我们用CQRS模式把复杂的查询都放到专门的查询服务里查询服务订阅各个服务的领域事件把需要的数据同步到专门的查询数据库(宽表)或者搜索引擎(Elasticsearch)里查询的时候,直接从查询存储里查不需要调用多个服务拼接数据性能非常好也很灵活能支持复杂的查询和筛选。

比如订单列表查询我们把订单数据用户数据商品数据都同步到一个订单宽表里查询的时候,直接从宽表查一次SQL就搞定了不需要调用多个服务性能提升非常明显。

2. 冗余数据

对于一些简单的查询需求我们用冗余数据的方式来解决,比如订单服务里需要显示用户名称我们就在订单表里冗余一个用户名称字段创建订单的时候,把用户名称也存进去这样查询订单的时候,直接从订单表里就能拿到用户名称不需要调用用户服务了性能很好。

冗余数据的好处是简单性能好缺点是数据有冗余用户名称改了的话,订单表里的用户名称也要更新,否则会不一致,所以要处理数据更新的问题一般通过消息异步更新保证最终一致对于不经常变化的数据(比如用户名称商品名称)冗余是很好的方案简单有效。

3. 缓存

对于一些热点数据的查询我们用缓存来提升性能,比如用户信息商品信息这些不经常变化的热点数据缓存到Redis里订单服务查询的时候,先从缓存查缓存有就直接用没有再调用用户服务,或者商品服务查,然后写入缓存这样能大大减少服务调用次数提升性能。

缓存,虽然能提升性能,但是也要注意缓存一致性的问题数据更新的时候,要及时更新缓存,或者删除缓存避免数据不一致,另外也要注意缓存穿透击穿雪崩等问题做好防护。

4. 避免过度拆分

最好的方案还是避免过度拆分在拆分服务的时候,要考虑查询的需求把经常需要联表查询的数据放在一个服务里这样就没有跨服务查询的问题了,比如订单和订单项肯定要放在一个服务里,因为经常需要联表查询不要拆成两个服务,否则查询会很麻烦。

当然不是所有数据都能放在一个服务里,但是在拆分的时候,要尽量考虑查询的需求减少跨服务查询的场景这是最有效的方案。

经过这些方案的综合运用我们解决了查询困难的问题查询性能提升了很多用户体验也好了很多。

五、坑5:服务拆分后测试和部署复杂

第五个坑是服务拆分后测试和部署变得复杂这也是微服务拆分后常见的问题。

在单体应用里测试和部署都比较简单测试只需要启动一个应用就能测所有功能部署也只需要部署一个应用,但是拆分成微服务之后,有十几个甚至几十个服务测试的时候,需要启动多个服务才能测一个完整的功能部署的时候,需要部署多个服务还要考虑服务之间,的依赖顺序版本兼容性等等非常复杂我们一开始拆分的时候,没有做好测试和部署的自动化结果测试和部署效率非常低经常出问题熬夜排查部署问题是常事。

原因分析

微服务拆分后服务数量多服务之间,有依赖,所以测试和部署的复杂度大大提升这是微服务架构的必然结果,如果没有做好自动化和DevOps就会导致测试和部署效率低容易出问题很多团队在拆分微服务的时候,只关注业务拆分和代码编写忽略了测试和部署的自动化结果拆分后测试和部署成为瓶颈影响开发效率和系统稳定性。

解决方案

我们后来大力投入做了测试和部署的自动化建立了CI/CD流水线解决了这个问题。

1. 自动化测试

我们建立了完善的自动化测试体系包括单元测试集成测试接口测试端到端测试等等每个服务都有完善的单元测试和接口测试保证服务本身的质量对于服务间的集成测试我们用契约测试(Contract Testing)来保证服务之间,的接口兼容性不需要启动所有服务就能测服务间的接口是否兼容大大提升了测试效率。

另外我们还搭建了自动化测试环境每次代码提交都会自动运行单元测试和接口测试测试通过才能合并代码部署到测试环境后自动运行集成测试和,端到端测试保证整个系统的功能正常通过自动化测试我们能快速发现问题保证代码质量测试效率大大提升。

2. CI/CD流水线

我们用Jenkins(后来也用GitLab CI)搭建了完善的CI/CD流水线代码提交后自动构建测试打包构建Docker镜像推送到镜像仓库,然后自动部署到测试环境运行自动化测试测试通过后手动确认部署到生产环境整个过程大部分都是自动化的只需要手动确认生产部署大大提升了部署效率也减少了人为错误。

另外我们还用了Kubernetes(K8s)来做容器编排和服务管理所有服务都容器化部署在K8s上K8s能自动管理服务的部署扩缩容故障恢复等等大大简化了服务的运维和部署提升了系统的稳定性。

3. 服务版本兼容和灰度发布

微服务架构下服务之间,有依赖,所以服务版本的兼容性很重要我们制定了服务接口的版本管理规范接口变更要向下兼容不能随便改接口导致调用方报错,另外我们还用了灰度发布(也叫金丝雀发布)新版本先部署到一小部分机器,或者一小部分用户观察没有问题再全量发布这样,即使新版本有问题也只影响一小部分用户不会导致整个系统故障大大提升了部署的安全性。

经过这些建设我们的测试和部署效率大大提升了从代码提交到生产部署原来需要几天现在只需要几个小时甚至更短,而且部署的质量也提升了很少出部署问题开发效率大大提升。

六、写在最后

以上就是我们团队微服务拆分踩过的一些坑包括服务拆分粒度不合理分布式事务处理不当服务间调用超时和重试设置不当引发雪崩服务拆分后数据库联表查询困难服务拆分后测试和部署复杂等等这些坑有些让我们熬夜排查很久才解决,但是也让我们学到了很多积累了经验。

微服务不是银弹不是拆了微服务就万事大吉了微服务在带来好处的,同时也带来了很多复杂度和挑战,如果没有做好准备没有相应的技术能力和基础设施盲目拆分微服务反而会比单体应用更差,所以在决定拆分微服务之前,一定要想清楚为什么要拆能不能驾驭微服务的复杂度不要为了微服务而微服务。

如果决定要拆分微服务也要循序渐进不要一步到位过度设计先从简单的业务开始拆分积累经验建设基础设施(CI/CD监控日志等等)然后再逐步拆分更多的服务,另外要重视上面提到的这些坑提前做好准备避免踩坑,或者踩坑了能快速解决。

希望我们的踩坑经历能帮大家在做微服务拆分的时候,少走弯路不熬夜顺利完成微服务拆分享受微服务带来的好处。

最后用一句话结束这篇文章:"微服务是一把双刃剑用得好能提升系统的可扩展性和开发效率用不好反而会带来很多问题比单体更差,所以一定要谨慎对待做好准备循序渐进才能驾驭微服务的复杂度享受微服务的好处。"

愿大家的微服务拆分都能顺利成功系统稳定高效支撑业务蓬勃发展。