“在今天的数字时代,应用程序和在线服务的高并发已成为常态。当数百万用户同时访问您的网站或应用时,如何应对这一挑战变得至关重要。”
为了确保高可用性和无损性能,您需要能够在不中断服务的情况下扩展或缩减资源。本文将深入探讨高并发下如何实现无损扩缩容,以确保您的业务在压力下保持顶尖表现。
01
—
介绍
1.1 文章介绍
在本文中,我们将探讨高并发环境下的无损扩缩容策略。我们将详细介绍在应用程序上线和下线时可能出现的问题,并提供实际解决办法。这些建议将有助于确保您的系统在应对高并发负载时保持稳定和高性能。
1.2 高并发和无损扩缩容的介绍
高并发是指系统需要同时处理大量用户请求的情况。这可能包括电子商务网站的促销活动、社交媒体平台上的热门话题或应用程序的爆发性流量。在这种情况下,传统的扩容策略可能会导致系统性能下降,用户体验下降,甚至系统崩溃。
无损扩缩容是一种策略,允许您在不中断服务的情况下动态地增加或减少计算、存储和网络资源。这可以通过自动化工具和策略来实现,以满足实际需求。
1.3 一般微服务无损扩缩容的问题
扩容情况:在应用上线发布的过程中,一个常见但具有挑战性的情况是在服务刚刚启动后,系统可能还处于JVM JIT编译阶段或者某些中间件加载的过程中。此时,如果系统面临大规模的请求流量冲击,可能会导致新启动的服务实例不堪重负。
在实际场景中,我们曾遇到这样的情况:当服务提供者(provider)启动后,却遭遇到数据库连接异常,这是因为系统未在启动前做好必要的资源准备工作。尽管服务提供者已在注册中心中注册,但由于数据库异常尚未得到修复,服务提供者无法正常提供服务,这会导致大量请求无法正常响应,最终返回异常结果。
缩容情况:应用缩容的过程中,常见问题之一是服务消费者在感知到服务提供者已下线时存在一定的延迟。这意味着在某段时间内,请求仍然被路由到已下线的服务提供者实例,导致连接被拒绝异常。
在实际应用中,可能存在一种情况,即部署了服务提供者的其中一个实例,并且该服务实例在被消费者调用后,通过kill -9强制终止。尽管服务进程实际上已经被终止,但服务的注册信息可能仍然存留在注册中心或者消费者本地缓存列表中,未能清除。因此,消费者服务仍能够发现该实例,获取其IP和端口信息,进行调用它,出现异常。
另外一个问题是服务实例在接收到SIGKILL信号时会立即关闭,但此时可能仍有请求在队列中等待处理。如果立即关闭服务实例,这些请求将会丢失。
假设百胜的业务中,有一个购物车服务。这个购物车服务负责管理每个用户的购物车内容,并提供添加商品、删除商品、结算等操作。此服务通常以微服务的形式部署在容器中,并由负载均衡器分发请求。在某个瞬间,购物车服务的某个实例接收到了大量的请求,这些请求都需要修改购物车内容,例如添加商品到购物车。服务实例正忙于处理这些请求,将它们添加到购物车,但此时,操作系统或容器编排工具决定(KILL)终止该实例。就会损害用户体验,导致用户数据丢失,丧失信任。
1.4 实现无损扩容的必要
实现无损扩缩容的原因是多方面的:
高可用性:在高并发环境下,用户期望服务始终可用。无损扩缩容确保即使在负载增加时也能保持服务的可用性。
性能优化:无损扩缩容允许分配更多资源以提高系统性能,以应对高并发压力。
成本控制:通过动态分配资源,您可以减少不必要的成本,避免过度配置。
自动化:实施无损扩容通常涉及自动化工具和决策系统,可以自动执行资源分配的操作,减少了手动干预的需要,提高了效率。
灵活性:系统的架构可以更加灵活,适应变化的负载需求。无损扩容通常与容器化、微服务架构等现代技术相结合,提供更大的灵活性。
快速响应:自动化扩容策略可以快速响应负载增加的情况,从而降低了用户等待时间,提高了系统的可用性。
02
扩容方案:
缩容方案:
当容器收到下线信号时,利用Kubernetes提供的PreStop钩子,执行以下操作用于优雅终止应用。
1. 调用shutdown接口,通知服务注册中心立即down掉本应用实例。
2. 等待95秒,确保下线服务在调用方本地缓存的服务实例列表中失效。
3. 执行pkill终止应用进程。
apiVersion: v1
kind: Pod
metadata:
name: lifecycle-demo
spec:
containers:
name: lifecycle-demo-container
image: nginx
lifecycle:
preStop:
exec:
command: ["/bin/sh","-c","curl -X GET http://127.0.0.1:8080/xx/instance/shutdown -H "Content-type:application/json";sleep
java"]
为什么等待95秒?
当执行PreStop时,会立即使应用实例在Eureka下线。因此需要考量调用方本地缓存刷新所需最大时间(依据配置默认时间),包括:
Eureka Server端读写缓存同步间隔,默认30S eureka.responseCacheUpdateIntervalMs=30000 和eureka.shouldUseReadOnlyResponseCache=true
Eureka Client端的服务列表缓存同步间隔,默认30S eureka.client.refresh.interval=30
Ribbon服务列表缓存同步间隔,默认30S ribbon.ServerListRefreshInterval=30000
综合在默认配置情况下,各调用方缓存刷新机制,95秒可以覆盖从Eureka下线到服务调用方缓存完全刷新的最大时间。这样可以确保在关闭应用进程前,调用方不会再通过本地缓存访问到已下线的服务实例。
03
尽管在部署中实施了上述水平扩缩容方案,但在一些项目中仍然出现了各种问题,这可能涉及到多个方面,需要进一步分析和解决,下面描述优化方案和问题原因。
3.1 延时注册
描述:默认情况下应用容器启动后默认直接注册到注册中心,意味着准备好提供服务,然而,虽然应用容器已经注册到服务注册中心,但这并不意味着它已经完全准备好应对来自外部的请求。比如:但某些业务在提供服务前,需要进行预启动检查,通过后才可注册至注册中心。
解决方案:
Kubernetes提供了就绪探针,合理使用可进行延时注册。
apiVersion: v1
kind: Pod
metadata:
name: goproxy
labels:
app: goproxy
spec:
containers:
name: goproxy
image: registry.k8s.io/goproxy:0.1
ports:
containerPort: 8080
readinessProbe:
httpGet:
path: /application/readiness
port: 8080
scheme: HTTP
initialDelaySeconds: 15
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
改造readiness探针接口,以Java代码片段为例:
@GetMapping(value = "/application/readiness")
public void readiness(){
// 1. 进行系统启动后 数据预热
// 2. 进行系统(业务)启动预检
// 3. 自检成功后注册到注册中心,不通过返回异常
}
容器启动后进行readiness探针,通过则注册到注册中心,不通过则不注册。这样可以避免不ready的实例注册上线。通过扩展就绪检查机制,可以更准确地控制服务实例的注册时机,保证注册到注册中心的实例一定是ready状态,从而提高服务可靠性。
3.2 启动预加载
描述:某业务系统中,扩容初期可能会出现HTTP 500 、504错误,表示网关超时,当应用程序负载较大时异常情况更多。经过多次排查和优化,总结出以下优化方案:
ribbon预加载优化
在当前的微服务架构中,使用Ribbon负载均衡器,负责将请求分发到后端的服务实例。然而,Ribbon在初始化时需要从服务注册中心获取服务列表,以决定如何分发请求。这个初始化过程可能会花费一定的时间,特别是高并发情况下,加上服务实例数量庞大或者注册中心偶尔响应较慢的情况下,这个时长更会延长。可以做以下配置将Ribbon初始化工作前置在提供服务之前
ribbon:
:
enabled: true
clients: xxxService, xxxxService # 消费端服务名
缓存预加载优化
业务系统通常会实施缓存预加载策略,以优化系统的性能和响应时间。然而,缓存加载的一个常见挑战是确保加载的数据完整性和一致性。在实际场景中,虽然缓存预加载可以显著提升初期系统的响应性,但仍然可能出现部分数据未加载到缓存的情况。为了解决这一问题,通常采用以下策略:
1. 数据完备性检测
缓存初次加载时机可以放在Spring框架的生命周期钩子中实现,比如:CommandLineRunner、ApplicationRunner、ApplicationListener ... 按照需求选择即可。
2. 重新加载机制
重新加载的时机可以选择在 第一步 中重试,也可选择在readiness接口中实现,主要实现是对于未加载的数据,实施重新加载机制,确保数据在后续的访问中可以被缓存。
3. 错误处理
这可以包括自动重试、错误日志记录和通知等。当然对于严重阻塞业务进程的情况,可以选择不注册至注册中心
静态资源预加载优化
在实际场景中,我们遇到了具体的挑战。在百胜某应用中,采用了ShardingSphere中间件来实现数据库分片和SQL改写。然而,在性能排查过程中,发现ShardingSphere的SQL改写需要频繁使用SQL解析器,而在初次解析SQL时,程序执行的解析过程耗费了大量时间,其占比达到了整体执行时间的一半左右。降低了用户体验质量。
为了解决这一性能瓶颈,可以采取预热SQL解析器的优化措施,在系统启动过程中,预热SQL解析器,提前完成一些常见SQL语句的解析工作,以减少初次解析的成本。
类似地,需排查其他有影响的静态资源是否预加载,也是性能优化措施。如配置文件、模板、SPI扩展等。
健康检测策略优化
主动健康检测:中间件框架扩展或者使用健康检测工具主动监测服务的状态,以及时发现问题。停止提供服务等。
故障转移:提高中间件的高可用性,在发现故障时,将请求重定向到备用中间件服务器,以确保服务的稳定。
3.3 异步消费问题
问题1:
在一个实际的应用场景中,我们遇到了以下情况:百胜某应用采用了Pulsar消息中间件,特别是在进行扩容操作时,出现了一个问题。问题的核心在于Pulsar消费服务在应用实例注册到服务注册中心之前,就已经进行了消息消费连接池的初始化,并开始消费消息。这导致在应用程序启动过程中,工作线程被大量的消息消费任务占用,结果应用注册至注册中心后,无法有效地处理正常请求,最终导致了大量的请求错误。
优化措施:
为了解决这一问题,我们采用了MQ消费延迟初始化。优化Pulsar消费服务,以确保在应用程序完全启动后才初始化消息消费连接池。这样可以避免在应用程序启动初期由于消息消费任务的过早启动而占用了工作线程,确保应用在正常负载下能够提供稳定的性能。
问题2:
在百胜实际场景中,我们面临了一个在缩容过程中出现的复杂问题。具体而言,问题的症结在于应用在进行缩容时的终止过程出现了不同步的情况,导致了一系列异常情况。这些异常表现为应用在销毁阶段产生异常消息,其中包括"Do not request a bean from BeanFactory in a destroy method Implementation."的异常信息。
问题的根本原因如下:在Pod销毁时,服务实例会先从Eureka等服务注册中心下线,然后等待大约95秒的时间,之后才执行应用进程的终止操作。然而,问题出现在等待时间结束后,Pulsar消息处理线程池并未被及时终止,仍在继续消费消息。与此同时,当前的JVM已经接收到销毁指令,导致消息处理过程无法继续获取所需的bean信息,最终引发了异常。
优化措施:
我们重新定义了应用接收到JVM销毁指令时的中间件销毁逻辑。具体而言,我们根据业务场景的需求,按照特定的顺序逐一销毁不同的中间件连接。特别是,我们增强了Pulsar中间件的销毁逻辑,将其优先销毁消费者线程池。
这一优化措施确保了在缩容过程中,不仅服务实例能够安全下线,而且中间件连接也得到了精心管理。通过根据特定顺序销毁中间件连接,特别是Pulsar消费者线程池的优先销毁,我们消除了不同步的情况,确保了销毁过程的可靠性和可维护性,提高了系统的整体性能和稳定性。这一策略对于保障高并发负载下系统的顺畅运行具有重要意义。
3.4 其他优化
减少依赖项:SpringBoot程序的依赖项错综复杂,很容易引入到不必要的依赖。通过检查项目的依赖项,删除不必要的依赖项可以提升启动速度。
优化自动配置:Spring Boot提供了自动配置机制,根据应用程序的依赖项和配置,自动配置各种组件。过多的自动配置导致Spring扫描加载的类过多,影响启动速度,可以使用@EnableAutoConfiguration
的exclude属性,排除不必要的组件。
合理的延迟初始化:对于旁路业务上的Bean,可以选择懒加载的模式,在需要时才进行初始化。
优化后扩缩容方案
4.2 缩容方案