【从0-1 千万级直播项目实战】微服务滚动发布方案 | 优雅且平滑

1,751 阅读5分钟

业务背景

众所周知,在我们迭代业务需求和Fix Bug时往往需要重启我们的服务,在高并发场景下,如果还像N年前一样,通过上机操作敲Linux命令执行Kill操作,在分布式环境下显然会让你敲断手指,并且这种方式重启还会影响用户的操作,导致一些线上事故,所以,在微服务、分布式系统中,平滑滚动发布无疑是非常重要的(主打的就是一个丝滑)。

SpringCloud微服务架构导致发布的一些弊端

1.Nacos组件导致的一些弊端

众所周知,Nacos利用心跳机制每隔一段时间(可配置,defalut:30s)服务端与客户端都会互相发送心跳包,当服务端接收到的客户端心跳包有异常情况时,Nacos服务端会剔除客户端在服务端中的注册信息,意味着这个服务是不可用状态,这种方式其实是有弊端的,这其实也是心跳机制的弊端,比如我服务A重启了,Nacos服务端并没有马上感知服务A的异常情况,这个时候还认为其是可用状态,这个时候如果有流量打进来,通过负载均衡很明显会有流量进入到服务A,无法即时感知客户端异常,就是它的最大弊端。

2.SpringCloud Gateway组件导致的一些弊端

SpringCloud Gateway组件对比以前的Zuul网关,在性能和吞吐提升了很多,主要是因为它是基于Spring5.0+SpringBoot2.0+Spring Reactor,没错看到Reactor你就会想到性能强悍的Netty,SpringCloud Gateway正是用了这一高性能通信框架。

提到网关,第一我们要想到的就是负载均衡机制,之前面试过不少后端的兄弟,你们的系统怎么做的负载均衡,他们中多数人的回答是使用了Nginx的负载均衡机制,我...,咱不废话,接着说SpringCloud Gateway的负载均衡机制,当使用Ribbon作为负载中间件时会使用定时线程从Nacos注册中心拉取服务列表然后放到Gateway服务的本地缓存中,拉取时间默认也是30s,同样的也是无法及时感知Nacos注册中心的服务异常情况,所以其实很多人在使用SpringCloud Gateway中会经常出现一些500,503等问题,下面我将针对这些组件的弊端做一个完善解决方案。

针对Nacos与GateWay弊端的解决方案

Nacos与Gateway之间即时感知弊端的优化切入思路

  1. 在程序停止前通过Nacos上下线事件监听回调操作中主动调用Nacos服务下线操作,让Nacos服务端能即时剔除重启的客户端,避免流量流入
  2. SpringCloud GateWay组件中可以重写其负载均衡策略,也就是在服务上下线回调监听事件中刷新在网关服务本地缓存中的Nacos服务信息,就好比我们业务中更新了数据库要刷一遍缓存一般,操作起来没啥难度。

线上优化实战

  • 问题简介

根据线上日志发现,gateway网关在服务重启时会有调用失败的现象,调用失败可能会导致一些数据的丢失甚至引发一些金钱、充值相关的数据有误,尤其在并发越高的情况下,这种现象表现得越发严重,鄙人曾经的项目中有因为此等原因导致了服务雪崩的情况,所以针对此网关进行一个优化。

  • 优化思路可行性分析
  1. 第一种方式 重写gateway的负载均衡器,从可行性来看问题不大,但是我们的主要问题是针对服务上下线无法及时感知而优化,并不是针对其负载均衡器进行深度优化,所以此方式虽可实现,但开发、结果成本可能稍大,不优先采取

  2. 第二种方式 分析Nacos与Gateway之间的关联关系可知,我们可以通过Nacos上下线的事件监听回调来操作Gateway 令其刷新。

  • 代码

    @Slf4j
    @Component
    public class ApplicationEventListener implements ApplicationListener {
    
        @Value("${spring.application.name}")
        private String applicationName;
    
        @Value("${server.port}")
        private int port;
    
        @Autowired
        private DiscoveryClient discoveryClient;
        @Autowired
        private NacosAutoServiceRegistration nacosAutoServiceRegistration;
    
    
        @Override
        public void onApplicationEvent(ApplicationEvent applicationEvent) {
            if (applicationEvent instanceof ApplicationStartedEvent) {
                log.info("【{}】【{}】应用启动", IpUtil.getIntranetIp(), applicationName);
    
                Executors.newSingleThreadExecutor().execute(() -> checkDiscoveryClient());
    
            } else if (applicationEvent instanceof ContextClosedEvent) {
                log.info("【{}】【{}】程序已停止...", IpUtil.getIntranetIp(), applicationName);
                ApplicationCheckUtil.setSystemIsNormal(false);
                //优雅停机
                nacosAutoServiceRegistration.stop();
                SpringApplication.exit(SpringContextUtil.getApplicationContext());
                ((ConfigurableApplicationContext) SpringContextUtil.getApplicationContext()).close();
            }
        }
    
        private void checkDiscoveryClient() {
            try {
                List<ServiceInstance> serviceInstanceList;
                AtomicBoolean currentInstanceHasRegister = new AtomicBoolean(false);
                while (true) {
                    serviceInstanceList = discoveryClient.getInstances(applicationName);
                    serviceInstanceList.forEach(serviceInstance -> {
                        log.info("host:{} | port:{} | serviceId:{}", serviceInstance.getHost(), serviceInstance.getPort(), serviceInstance.getServiceId());
                        if (IpUtil.getIntranetIp().equals(serviceInstance.getHost()) && serviceInstance.getPort() == port) {
                            currentInstanceHasRegister.set(true);
                        }
                    });
    
                    if (currentInstanceHasRegister.get()) {
                        log.info("当前服务实例已成功注册到Nacos中...");
                        break;
                    }
    
                    TimeUnit.SECONDS.sleep(1);
                }
            } catch (Exception e) {
                log.error("检测服务注册异常...");
            } finally {
                DingTalkUtil.send(DingTalkType.PUBLISH_NOTICE, String.format("【%s】【%s】应用启动成功", IpUtil.getIntranetIp(), applicationName));
                ApplicationCheckUtil.setSystemIsNormal(true);
            }
        }
    
    
    }
    

主要核心代码就一行:nacosAutoServiceRegistration.stop();

线上优化结果指标

通过此优化 频繁重启了几次服务,并且用测试工具一直并发调用重启的服务,并未发现有调用异常的情况,至此 服务端真正意义上的平滑重启稍微提升了一个层次,但还不够,请接着往下看。

K8S滚动发布更新方案

用户请求服务过程

服务重启预想过程

滚动发布流程

k8s参数配置

服务在滚动更新时,deployment控制器的目的是:给旧版本(old_rs)副本数减少至0、给新版本(new_rs)副本数量增至期望值(replicas)。大家在使用时,通常容易忽视控制速率的特性,以下是kubernetes提供的两个参数:

1. maxUnavailable:和期望ready的副本数比,不可用副本数最大比例(或最大值),这个值越小,越能保证服务稳定,更新越平滑;

2. maxSurge:和期望ready的副本数比,超过期望副本数最大比例(或最大值),这个值调的越大,副本更新速度越快。

spec:
  ---副本数量
  replicas: 5
  selector:
    matchLabels:
      app: user-service
  minReadySeconds: 120
  strategy:
    ---滚动更新方式
    type: RollingUpdate
    rollingUpdate:
      ---超过期望副本数最大比例(或最大值)
      maxSurge: 1
      ---不可用副本数最大比例(或最大值)
      maxUnavailable: 3

至此,一套完整高可用、稳定的微服务滚动更新方案已完成,基本已经能够满足目前的使用情况。

后续优化展望

  1. 增加灰度发布方案
  2. 增加长连接服务的平滑发布方案(涉及到的知识点很干,大家敬请期待)