美丽好车的微服务实践是基于 Spring Cloud 体系来做的,在具体的开发过程中遇到了不少问题,踩了不少坑,对于微服务也有了实际的切身体会和理解,而不再是泛泛而谈。在整个 Spring Cloud 技术栈中,基于不同职责需要,我们选择了相应组件来支持我们的服务化,同时配合 Swagger 和 Feign 实现接口的文档化和声明式调用,在实际开发过程中极大地降低了沟通成本,提高了研发联调和测试的效率。
从应用架构来看,正是由于基于 Spring Cloud 来实现,整个系统完全秉承了微服务的原则,无论是 Spring Cloud 组件还是业务系统,都体现了服务即组件、独立部署、去中心化的特性,由此提供了快速交付和弹性伸缩的能力。
接下来我们基于各个组件具体介绍一下美利好车的微服务实践,首先最基本的就是 Eureka,其承载着微服务中的服务注册和服务发现的职责,是最基础的组件,必然有高可用的要求。
美利好车在生产实践中部署了一个三节点的 Eureka Server 的集群,每个节点自身也同时基于 Eureka Client 向其它 Server 注册,节点之间两两复制,实现了高可用。在配置时指定所有节点机器的 hostname 既可,即做到了配置部署的统一,又简单实现了 IP 解耦,不需要像官方示例那样用 profile 机制区分节点配置。这主要是由于 Eureka 节点在复制时会剔除自身节点,向其它节点复制实例信息,保证了单边同步原则:只要有一条边将节点连接,就可以进行信息传播和同步。在生产环境中并不要过多调整其它配置,遵循默认的配置既可。
作为服务提供者的 Eureka Client 必须配置 register-with-eureka 为 true,即向 Eureka Server 注册服务,而作为服务消费者的 Eureka Client 必须配置 fetch-registry=true,意即从 Eureka Server 上获取服务信息。如果一个应用服务可能既对外提供服务,也使用其它领域提供的服务,则两者都配置为 true,同时支持服务注册和服务发现。由于 Ribbon 支持了负载均衡,所以作为服务提供者的应用一般都是采用基于 IP 的方式注册,这样更灵活。
在开发测试环境中,常常都是 standlone 方式部署,但由于 Eureka 自我保护模式以及心跳周期长的原因,经常会遇到 Eureka Server 不剔除已关停的节点的问题,而应用在开发测试环境的启停又比较频繁,给联调测试造成了不小的困扰。为此我们调整了部分配置让 Eureka Server 能够迅速有效地踢出已关停的节点,主要包括在 Server 端配置关闭自我保护 (eureka.server.enableSelfPreservation=false),同时可以缩小 Eureka Server 清理无效节点的时间间隔(eureka.server.evictionIntervalTimerInMs=1000)等方式。
另外在 Client 端开启健康检查,并同步缩小配置续约更新时间和到期时间 (eureka.instance.leaseRenewalIntervalInSeconds=10 和 eureka.instance.leaseExpirationDurationInSeconds=20)。
健康检查机制也有助于帮助 Eureka 判断 Client 端应用的可用性。没有健康检查机制的 Client 端,其应用状态会一直是 UP,只能依赖于 Server 端的定期续约和清理机制判断节点可用性。配置了健康检查的 Client 端会定时向 Server 端发送状态心跳,同时内置支持了包括 JDBC、Redis 等第三方组件的健康检查,任何一个不可用,则应用会被标为 DOWN 状态,将不再提供服务。在生产环境下也是开启了客户端健康检查的机制,但没有调节配置参数。
在 CAP 原则中,Eureka 在设计时优先保证 AP。Eureka 各个节点都是平等的,几个节点挂掉不会影响正常节点的工作,剩余的节点依然可以提供注册和查询服务。而 Eureka 的客户端在向某个 Eureka 注册时如果发现连接失败,则会自动切换至其它节点,只要有一台 Eureka 还在,就能保证注册服务可用 (保证可用性),只不过查到的信息可能不是最新的 (不保证强一致性)。除此之外,Eureka 还有一种自我保护机制:如果在 15 分钟内超过 85% 的节点都没有正常的心跳,那么 Eureka 就认为客户端与注册中心出现了网络故障,开启自我保护,支持可读不可写。
Eureka 为了保证高可用,在应用存活、服务实例信息、节点复制等都采用了缓存机制及定期同步的控制策略,比如客户端的定期获取(eureka.client.registryFetchIntervalSeconds),实例信息的定期复制(eureka.client.instanceInfoReplicationIntervalSeconds),Server 的定期心跳检查 (eureka.instance.leaseExpirationDurationInSeconds),客户端定期存活心跳(eureka.instance.leaseRenewalIntervalInSeconds)等等,加强了注册信息的不一致性。服务消费者应用可以选择重试或者快速失败的方式,但作为服务提供者在基于 Spirng Cloud 的微服务机制下应当保证服务的幂等性,支持重试。因此如果对一致性的要求较高,可以适当调整相应参数,但明显这样也增加了通信的频率,这种平衡性的考虑更多地需要根据生产环境实际情况来调整,并没有最优的设置。
Config 的高可用方案比较简单,只需将 Config Server 作为一个服务发布到注册中心上,客户端基于 Eureka Client 完成服务发现,既可实现配置中心的高可用。这种方式要求客户端应用必须在 bootstrap 阶段完成发现配置服务并获取配置,因此关于 Eureka Client 的配置也必须在 bootstrap 的配置文件中存在。同时我们引入了 Spring Retry 支持重试,可多次从 Server 端拉取配置,提高了容错能力。另外,在启动阶段,我们配置了 failFast=true 来实现快速失败的方式检查应用启动状态,避免对于失败的无感知带来应用不可用的情况。
在实际的生产中,我们同时基于 Spring Cloud Bus 机制和 Kafka 实现了实时更新,当通过 git 提交了更新的配置文件后,基于 webhook 或者手动向 Config Server 应用发送一个 /bus/refresh 请求,Config Server 则通过 Kafka 向应用节点发送了一个配置更新的事件,应用接收到配置更新的事件后,会判断该文件的 version 和 state,如果任一个发生变化,则从 Config Server 新拉取配置,内部基于 RefreshRemoteApplicationEvent 广播更新 RefreshScope 标注的配置。默认的 Kafka 的 Topic 为 springCloudbus,同时需要注意的是应用集群的节点不能采用 consumer group 的方式消费,应采用广播模式保证每个节点都消费配置更新消息。Spring CloudBus 又是基于 Spring Cloud Stream 机制实现的,因此配置需要按照 Steam 的方式设置。具体为:
spring.cloud.stream.kafka.binder.brokers=ip:port spring.cloud.stream.kafka.binder.zk-nodes=ip:port spring.cloud.stream.bindings.springCloudBusInput.destination=springCloudbus.dev
如果需要重定义 Topic 名称,则需要如上所示进行调整,由于多套开发环境的存在,而 Kafka 只有一套,我们对 Topic 进行了不同环境的重定义。
但需要注意的一点是,这种实时刷新会导致拒绝任务的异常 (RejectedExecutionException),必现(当前 Edgware.RELEASE 版本)但不影响实际刷新配置,已被证实是个 Bug,具体参见 https://github.com/spring-cloud/spring-cloud-netflix/issues/2228,可简单理解为在刷新时会关闭 context 及关联的线程池重新加载,但刷新事件又同时提交了一个新的任务,导致拒绝执行异常。
针对外网请求,必然需要一个网关系统来进行统一的安全校验及路由请求,Zuul 很好地支持了这一点,在实际生产中,我们尽量让 gateway 系统不集成任何业务逻辑,基于 EnableZuulProxy 开启了服务发现模式实现了服务路由。且只做安全和路由,降低了网关系统的依赖和耦合,也因此使 gateway 系统可以线性扩展,无压力和无限制地应对流量和吞吐的扩张。
需要注意的是,重定向的问题需要配置 add-host-header=true 支持;为了安全保障,默认忽略所有服务(ignored-services='*'),基于白名单进行路由,同时开启 endpoints 的安全校验,以避免泄露信息,还要通过 ignored-patterns 关闭后端服务的 endpoints 访问请求。
Zuul 支持自定义 Http Header,我们借助于该机制,实现了 Session 从网关层向后端服务的透传。主要是基于 pre 类型的 ZuulFilter,通过 RequestContex.addZuulRequestHeader 方法可实现请求转发时增加自定义 Header,后端基于 SpringMVC 的拦截器拦截处理即可。
ZuulFilter 不像 SpringMVC 的拦截器那么强大,它是不支持请求路径的过滤的。Zuul 也没有集成 SpringMVC 的拦截器,这就需要我们自己开发实现类似的功能。如果需要支持 SpringMVC 拦截器,只需要继承 InstantiationAwareBeanPostProcessorAdapter 重写初始化方法 postProcessAfterInstantiation,向 ZuulHandlerMapping 添加拦截器既可。为了支持请求的过滤,还可以将拦截器包装为 MappedInterceptor,这就可以像 SpringMVC 的拦截器一样支持 include 和 exclude。具体代码示例如下:
1. public static class ZuulHandlerBeanPostProcessor extends InstantiationAwareBeanPostProcessorAdapter { 2. 3. @Value("${login.patterns.include}") 4. private String includePattern; 5. @Value("${login.patterns.exclude}") 6. private String excludePattern; 7. 8. @Autowired 9. private AuthenticateInterceptor authenticateInterceptor; 10. 11. 12. public MappedInterceptor pattern(String[] includePatterns, String[] excludePatterns, HandlerInterceptor interceptor) { 13. return new MappedInterceptor(includePatterns, excludePatterns, interceptor); 14. } 15. 16. @Override 17. public boolean postProcessAfterInstantiation(final Object bean, final String beanName) throws BeansException { 18. if (bean instanceof ZuulHandlerMapping) { 19. ZuulHandlerMapping zuulHandlerMapping = (ZuulHandlerMapping) bean; 20. String[] includePatterns = Iterables.toArray(Splitter.on(",").trimResults().omitEmptyStrings().split(includePattern), String.class); 21. String[] excludePatterns = Iterables.toArray(Splitter.on(",").trimResults().omitEmptyStrings().split(excludePattern), String.class); 22. zuulHandlerMapping.setInterceptors(pattern(includePatterns, excludePatterns, authenticateInterceptor)); 23. } 24. return super.postProcessAfterInstantiation(bean, beanName); 25. } 26. 27. }
Zuul 底层是基于 Ribbon 和 Hystrix 实现的,因此超时配置需要注意,如果基于服务发现的方式,则超时主要受 Ribbon 控制。另外由于 Spring Config 引入了 Spring Retry 导致 Zuul 会至少进行一次失败请求的重试,各种开关配置都不生效,最后通过将 Ribbon 的 MaxAutoRetries 和 MaxAutoRetriesNextServer 同时设置为 0,避免了重试。在整个微服务调用中,由于不能严格保证服务的幂等性,我们是关闭了所有的重试机制的,包括 Feign 的重试,只能手动进行服务重试调用,以确保不会产生脏数据。
Zipkin 是大规模分布式跟踪系统的开源实现,基于 2010 年 Google 发表的 Dapper 论文开发的,Spring Cloud Sleuth 提供了兼容 Zipkin 的实现,可以很方便地集成,提供了较为直观的可视化视图,便于人工排查问题。美利好车系统在实际的生产实践中,将日志同步改为适用于 Zipkin 格式的 pattern,这样后端 ELK 组件日志的收集查询也兼容,基于 traceId 实现了服务追踪和日志查询的联动。
在日志的上报和收集上我们仍然基于 spring-cloud-starter-bus-kafka 来实现。
在前后端分离成为主流和现状的情况下,前后端就接口的定义和理解的不一致性成为开发过程中效率的制约因素,解决这个问题可以帮助前后端团队很好地协作,高质量地完成开发工作。我们在实际开发过程中使用了 Swagger 来生成在线 API 文档,让 API 管理和使用变得极其简单,同时提供了接口的简单测试能力。虽然这带来了一定的侵入性,但从实际生产效率来说远超出了预期,因此也特别予以强调和推荐。
实际开发过程中,我们仍然提供了 API 的 SDK 让调用方接入,虽然这个方式是微服务架构下不被推崇的,但现阶段我们认为 SDK 可以让调用 API 更简单、更友好。版本问题基于 Maven 的 snapshot 机制实现实时更新,唯一需要注意的是要保证向后兼容。
以上就是美利好车系统微服务实施的一些实践,有些地方可能不是特别恰当和正确,但在当前阶段也算基本满足了开发需要,而且我们秉承拥抱变化的态度,对整个体系结构也在持续进行改善和优化,积极推动美利好车架构的演进,希望能更好地支持美利好车的业务需求。
王文尧,曾在京东等多家知名互联网电商领域公司任职,也创业做过垂直电商平台,现任美利金融好车技术部架构师。对电商领域的业务比较了解,同时对敏捷、领域建模、高并发高可用的分布式系统设计及服务化等方面有较为深入的研究和实践。
感谢雨多田光对本文的审校。