负载均衡策略
负载均衡基本配置
负载均衡(Load Balance), 其实就是将请求分摊到多个操作单元上进行执行,从而共同完成工作任务。
负载均衡策略主要用于客户端存在多个提供者时进行选择某个提供者。
在集群负载均衡时,Dubbo 提供了多种均衡策略(包括随机、轮询、最少活跃调用数、一致性Hash),缺省为random随机调用。
官方文档中对于负载均衡的介绍写得非常详细。
配置负载均衡策略,既可以在服务提供者一方配置,也可以在服务消费者一方配置,如下:
//在服务消费者一方配置负载均衡策略
@Reference(check = false,loadbalance = "random")
//在服务提供者一方配置负载均衡
@Service(loadbalance = "random")
public class HelloServiceImpl implements HelloService {
public String sayHello(String name) {
return "hello " + name;
}
}
自定义负载均衡器
负载均衡器在Dubbo中的SPI接口是org.apache.dubbo.rpc.cluster.LoadBalance , 可以通过实现这个接口来实现自定义的负载均衡规则。
(1) 准备测试工程
参考我的另外一篇文章Dubbo架构和简单应用中的Dubbo开发实战部分,这里将消费者多复制成3份,方便测试负载均衡
(2) 自定义负载均衡器
创建名称为dubbo-spi-loadbalance的Maven模块,并创建负载均衡器OnlyFirstLoadbalancer。这里功能只是简单的选取所有机器中的第一个(按照字母排序 + 端口排序)。
public class OnlyFirstLoadbalancer implements LoadBalance {
@Override
public <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException {
return invokers.stream().sorted((i1, i2)->{
int compare = i1.getUrl().getIp().compareTo(i1.getUrl().getIp());
if (compare == 0) {
return Integer.compare(i1.getUrl().getPort(), i2.getUrl().getPort());
}
return compare;
}).findFirst().get();
}
}
(3) 配置负载均衡器
在dubbo-spi-loadbalance工程的META-INF/dubbo 目录下新建org.apache.dubbo.rpc.cluster.LoadBalance 文件,并将当前类的全名写入
onlyFirst=com.laogu.loadbalance.OnlyFirstLoadbalancer
(4) 在服务提供者工程实现类中编写用于测试负载均衡效果的方法 启动不同端口时 方法返回的信息不同
(5) 启动多个服务 要求他们使用同一个接口注册到同一个注册中心 但是他们的dubbo通信端口不同
(6) 在服务消费引入负载均衡模块,并在方法指定自定义负载均衡器 onlyFirst
@Component
public class ConsumerBean {
@DubboReference(loadbalance = "onlyFirst")
HelloService hello;
public String helloService(String name) {
return hello.helloService(name);
}
}
(7) 测试自定义负载均衡的效果
将第一个服务提供者关闭后再执行
异步调用
Dubbo不只提供了堵塞式的的同步调用,同时提供了异步调用的方式。这种方式主要应用于提供者接口响应耗时明显,消费者端可以利用调用接口的时间去做一些其他的接口调用,利用Future 模式来异步等待和获取结果即可。这种方式可以大大的提升消费者端的利用率。 目前这种方式可以通过XML的方式进行引入。
异步调用实现
再上面的工程基础上进行修改
(1) 为了能够模拟等待,通过 int timeToWait参数,标明需要休眠多少毫秒后才会进行返回。
String helloService(String name, int timeToWait);
(2) 接口实现 为了模拟调用耗时 可以让线程等待一段时间
@Override
public String helloService(String name, int timeToWait) {
try {
Thread.sleep(timeToWait);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "0 hello :" + name;
}
(3) 消费者端使用xml文件进行配置,配置异步调用 注意消费端默认超时时间1000毫秒 如果提供端耗时大于1000毫秒会出现超时
可以通过改变消费端的超时时间 通过timeout属性设置即可单位毫秒
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans" xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">
<dubbo:application name="service-consumer"/>
<dubbo:registry address="zookeeper://192.168.137.144:2181?timeout=20000"/>
<dubbo:reference id="helloService" interface="com.elvis.service.HelloService">
<dubbo:method name="helloService" async="true"></dubbo:method>
</dubbo:reference>
<dubbo:consumer>
<dubbo:parameter key="timeout" value="4000"></dubbo:parameter>
</dubbo:consumer>
<context:component-scan base-package="com.elvis.bean"></context:component-scan>
</beans>
(4) 修改ConsumerBean类
@Component
public class ConsumerBean {
// @DubboReference(loadbalance = "onlyFirst")
// 这里使用XML配置的方式,dubbo会直接将代理对象注入IOC容器
@Autowired
HelloService hello;
public String helloService(String name) {
return hello.helloService(name);
}
}
(5)编写XML方式的主启动类
public class XMLConsumerMain {
public static void main(String[] args) throws IOException, InterruptedException {
ClassPathXmlApplicationContext app = new ClassPathXmlApplicationContext("consumer.xml");
HelloService service = app.getBean(HelloService.class);
while (true) {
System.in.read();
try {
String hello = service.helloService("world", 100);
// 利用Future 模式来获取
Future<Object> future = RpcContext.getContext().getFuture();
System.out.println("result :" + hello);
System.out.println("future result:"+future.get());
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
(6) 测试
这里能看到调用接口后立刻返回的是null等到异步调用返回结果才是正确的
异步调用特殊说明
需要特别说明的是,该方式的使用,请确保dubbo的版本在2.5.4及以后的版本使用。 原因在于在2.5.3及之前的版本使用的时候,会出现异步状态传递问题。比如我们的服务调用关系是A -> B -> C , 这时候如果A向B发起了异步请求,在错误的版本时,B向C发起的请求也会连带的产生异步请求。这是因为在底层实现层面,他是通过RPCContext 中的attachment 实现的。在A向B发起异步请求时,会在attachment 中增加一个异步标示字段来表明异步等待结果。B在接受到A中的请求时,会通过该字段来判断是否是异步处理。但是由于值传递问题,B向C发起时同样会将该值进行传递,导致C误以为需要异步结果,导致返回空。这个问题在2.5.4及以后的版本进行了修正。
线程池
Dubbo已有线程池
dubbo在使用时,都是通过创建真实的业务线程池进行操作的。目前已知的线程池模型有两个和java中的相互对应:
- fix: 表示创建固定大小的线程池。也是Dubbo默认的使用方式,默认创建的执行线程数为200,并且是没有任何等待队列的。所以再极端的情况下可能会存在问题,比如某个操作大量执行时,可能存在堵塞的情况。后面也会讲相关的处理办法。
- cache: 创建非固定大小的线程池,当线程不足时,会自动创建新的线程。但是使用这种的时候需要注意,如果突然有高TPS的请求过来,方法没有及时完成,则会造成大量的线程创建,对系统的CPU和负载都是压力,执行越多反而会拖慢整个系统。
自定义线程池
在真实的使用过程中可能会因为使用fix模式的线程池,导致具体某些业务场景因为线程池中的线程数量不足而产生错误,而很多业务研发是对这些无感知的,只有当出现错误的时候才会去查看告警或者通过客户反馈出现严重的问题才去查看,结果发现是线程池满了。所以可以在创建线程池的时,通过某些手段对这个线程池进行监控,这样就可以进行及时的扩缩容机器或者告警。下面的这个程序就是这样子的,会在创建线程池后进行对其监控,并且及时作出相应处理。
这里继续使用上面的工程完成案例
(1) 线程池实现, 这里主要是基于对FixedThreadPool 中的实现做扩展出线程监控的部分
public class WatchingThreadPool extends FixedThreadPool implements Runnable{
private static final Logger LOGGER = LoggerFactory.getLogger(WatchingThreadPool.class);
private static final double ALARM_PERCENT = 0.90;
private final Map<URL, ThreadPoolExecutor> THREAD_POOLS = new ConcurrentHashMap<>();
public WatchingThreadPool() {
// 每隔3秒打印线程使用情况
Executors.newSingleThreadScheduledExecutor().scheduleWithFixedDelay(this, 1,3, TimeUnit.SECONDS);
}
@Override
public Executor getExecutor(URL url) {
// 从父类中创建线程池
final Executor executor = super.getExecutor(url);
if (executor instanceof ThreadPoolExecutor) {
THREAD_POOLS.put(url, ((ThreadPoolExecutor) executor));
}
return executor;
}
@Override
public void run() {
// 遍历线程池
for (Map.Entry<URL,ThreadPoolExecutor> entry: THREAD_POOLS.entrySet()){
final URL url = entry.getKey();
final ThreadPoolExecutor executor = entry.getValue();
// 计算相关指标
final int activeCount = executor.getActiveCount();
final int poolSize = executor.getCorePoolSize();
double usedPercent = activeCount / (poolSize*1.0);
LOGGER.info("线程池执行状态:[{}/{}:{}%]",activeCount,poolSize,usedPercent*100);
if (usedPercent > ALARM_PERCENT){
LOGGER.error("超出警戒线! host:{} 当前使用率是:{},URL:{}",url.getIp(),usedPercent*100,url);
}
}
}
}
(2)SPI声明,创建文件META-INF/dubbo/org.apache.dubbo.common.threadpool.ThreadPool
watching=com.elvis.threadpool.WatchingThreadPool
(3)在服务提供方项目引入该依赖
<dependency>
<groupId>com.elvis</groupId>
<artifactId>service-spi-threadpool</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
(4)在服务提供方项目中设置使用该线程池生成器
dubbo.provider.threadpool=watching
(5)接下来需要做的就是模拟整个流程,消费者则需要启动多个线程来并行执行,来模拟整个并发情况,使用提供者会休眠等待的接口测试。
public static void main(String[] args) throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ConsumerConfiguration.class);
context.start();
ConsumerBean bean = context.getBean(ConsumerBean.class);
while (true) {
for (int i=0;i<1000;i++){
Thread.sleep(1);
int finalI = i;
new Thread(new Runnable() {
@Override
public void run() {
String msg = bean.helloService("world", finalI);
System.out.println(msg);
}
}).start();
}
}
}
(6)在调用方则尝试简单通过for循环启动多个线程来执行 查看服务提供方的监控情况
路由规则
路由是决定一次请求中需要发往目标机器的重要判断,通过对其控制可以决定请求的目标机器。我们可
以通过创建这样的规则来决定一个请求会交给哪些服务器去处理。
路由规则快速入门
- 提供两个提供者(一台本机作为提供者,一台为其他的服务器),每个提供者会在调用时可以返回不同的信息 以区分提供者。
- 针对于消费者,我们这里通过一个死循环,每次等待用户输入,再进行调用,来模拟真实的请求情况。通过调用的返回值 确认具体的提供者。
- 我们通过ipconfig来查询到我们的IP地址,并且单独启动一个客户端,来进行如下配置(这里假设我们希望隔离掉本机的请求,都发送到另外一台机器上)。
public class DubboRouterMain {
public static void main(String[] args) {
RegistryFactory registryFactory =
ExtensionLoader.getExtensionLoader(RegistryFactory.class).getAdaptiveExtension()
;
Registry registry =
registryFactory.getRegistry(URL.valueOf("zookeeper://127.0.0.1:2181"));
registry.register(URL.valueOf("condition://0.0.0.0/com.lagou.service.HelloServi
ce?category=routers&force=true&dynamic=true&rule=" + URL.encode("=> host != 你的
机器ip不能是127.0.0.1")));
}
}
- 通过这个程序执行后,我们就通过消费端不停的发起请求,看到真实的请求都发到了除去本机以外的另外一台机器上。
旧路由规则
在 Dubbo 2.6.x 版本以及更早的版本中配置路由规则
路由本质上就是通过在zookeeper中保存一个节点数据,来记录路由规则。消费者会通过监听这个服务的路径,来感知整个服务的路由规则配置,然后进行适配。这里主要介绍路由配置的参数。具体请参考文档, 这里只对关键的参数做说明。
- route:// 表示路由规则的类型,支持条件路由规则和脚本路由规则,可扩展,必填。
- 0.0.0.0 表示对所有 IP 地址生效,如果只想对某个 IP 的生效,请填入具体 IP,必填。
- com.lagou.service.HelloService 表示只对指定服务生效,必填。
- category=routers 表示该数据为动态配置类型,必填。
- dynamic : 是否为持久数据,当指定服务重启时是否继续生效。必填。
- runtime : 是否在设置规则时自动缓存规则,如果设置为true则会影响部分性能。
- rule : 是整个路由最关键的配置,用于配置路由规则。
- … => … 在这里=> 前面的就是表示消费者方的匹配规则,可以不填(代表全部)。=> 后方则必须填写,表示当请求过来时,如果选择提供者的配置。官方这块儿也给出了详细的示例,可以按照那里来讲。
其中使用最多的便是host 参数。 必填。
条件路由规则
基于条件表达式的路由规则,如:host = 10.20.153.10 => host = 10.20.153.11
规则:
- => 之前的为消费者匹配条件,所有参数和消费者的 URL 进行对比,当消费者满足匹配条件时,对该消费者执行后面的过滤规则。
- => 之后为提供者地址列表的过滤条件,所有参数和提供者的 URL 进行对比,消费者最终只拿到过滤后的地址列表。
- 如果匹配条件为空,表示对所有消费方应用,如:=> host != 10.20.153.11
- 如果过滤条件为空,表示禁止访问,如:host = 10.20.153.10 =>
示例:
白名单
host != 10.20.153.10,10.20.153.11 =>
黑名单
host = 10.20.153.10,10.20.153.11 =>
新路由规则
按照官方文档,新的路由规则是通过dubbo-admin进行配置,具体配置项参考官方说明。
路由与上线系统结合
当公司到了一定的规模之后,一般都会有自己的上线系统,专门用于服务上线。方便后期进行维护和记录的追查。我们去想象这样的一个场景,一个dubbo的提供者要准备进行上线,一般都提供多台提供者来同时在线上提供服务。这时候一个请求刚到达一个提供者,提供者却进行了关闭操作。那么此次请求就应该认定为失败了。所以基于这样的场景,我们可以通过路由的规则,把预发布(灰度)的机器进行从机器列表中移除。并且等待一定的时间,让其把现有的请求处理完成之后再进行关闭服务。同时,在启动时,同样需要等待一定的时间,以免因为尚未重启结束,就已经注册上去。等启动到达一定时间之后,再进行开启流量操作。
实现主体思路
1.利用zookeeper的路径感知能力,在服务准备进行重启之前将当前机器的IP地址和应用名写入zookeeper。
2.服务消费者监听该目录,读取其中需要进行关闭的应用名和机器IP列表并且保存到内存中。
3.当前请求过来时,判断是否是请求该应用,如果是请求重启应用,则将该提供者从服务列表中移除。
- 引入Curator 框架,用于方便操作Zookeeper
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.1.0</version>
</dependency>
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.6.3</version>
</dependency>
- 编写Zookeeper的操作类,用于方便进行zookeeper处理
public class ZookeeperClients {
private final CuratorFramework client;
private static ZookeeperClients INSTANCE;
private ZookeeperClients(CuratorFramework client) {
this.client = client;
}
static {
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework newClient = CuratorFrameworkFactory.builder().
connectString("192.168.137.144:2181").
sessionTimeoutMs(5000).
connectionTimeoutMs(15000).
retryPolicy(retryPolicy).
build();
INSTANCE = new ZookeeperClients(newClient);
newClient.start();
}
public static CuratorFramework client() {
return INSTANCE.client;
}
}
- 编写需要进行预发布的路径管理器,用于缓存和监听所有的待灰度机器信息列表。
public class ReadyRestartInstances implements PathChildrenCacheListener {
private static final Logger LOGGER = LoggerFactory.getLogger( ReadyRestartInstances.class);
private static final String LISTEN_PATHS = "/router/dubbo/restart/instances";
private final CuratorFramework zkClient;
// 当节点变化时 给这个集合赋值 重启机器的信息列表
private volatile Set<String> restartInstances = new HashSet<>();
private ReadyRestartInstances(CuratorFramework zkClient) {
this.zkClient = zkClient;
}
public static ReadyRestartInstances create(){
final CuratorFramework zookeeperClient = ZookeeperClients.client();
try {
// 检查监听路径是否存在
final Stat stat = zookeeperClient.checkExists().forPath(LISTEN_PATHS);
// 如果监听路径不存在 则创建
if (stat == null){
zookeeperClient.create().creatingParentsIfNeeded().forPath(LISTEN_PATHS);
}
} catch (Exception e) {
e.printStackTrace();
LOGGER.error("确保基础路径存在");
}
final ReadyRestartInstances instances = new ReadyRestartInstances(zookeeperClient);
// 创建CuratorCache
CuratorCache curatorCache = CuratorCache.builder(zookeeperClient, LISTEN_PATHS).build();
// 创建一个监听器
CuratorCacheListener listener = CuratorCacheListener.builder().forPathChildrenCache(LISTEN_PATHS, zookeeperClient, instances).build();
// 给节点缓存对象 加入监听
curatorCache.listenable().addListener(listener);
try {
curatorCache.start();
} catch (Exception e) {
e.printStackTrace();
LOGGER.error("启动路径监听失败");
}
return instances;
}
/** 返回应用名 和 主机拼接后的字符串 */
private String buildApplicationAndInstanceString(String applicationName,String host){
return applicationName + "_" + host;
}
/** 增加重启实例的配置信息方法 */
public void addRestartingInstance(String applicationName,String host) throws Exception{
zkClient.create().creatingParentsIfNeeded().forPath(LISTEN_PATHS + "/" + buildApplicationAndInstanceString(applicationName,host));
}
/** 删除重启实例的配置信息方法 */
public void removeRestartingInstance(String applicationName,String host) throws Exception{
zkClient.delete().forPath(LISTEN_PATHS + "/" + buildApplicationAndInstanceString(applicationName,host));
}
/** 判断节点信息 是否存在于 restartInstances */
public boolean hasRestartingInstance(String applicationName,String host){
return restartInstances.contains(buildApplicationAndInstanceString(applicationName,host));
}
@Override
public void childEvent(CuratorFramework curatorFramework, PathChildrenCacheEvent pathChildrenCacheEvent) throws Exception {
// 查询出监听路径下 所有的目录配置信息
final List<String> restartingInstances = zkClient.getChildren().forPath(LISTEN_PATHS);
// 给 restartInstances
if(CollectionUtils.isEmpty(restartingInstances)){
this.restartInstances = Collections.emptySet();
}else{
this.restartInstances = new HashSet<>(restartingInstances);
}
}
}
- 编写路由类(实现
org.apache.dubbo.rpc.cluster.Router
),主要目的在于对ReadyRestartInstances 中的数据进行处理,并且移除路由调用列表中正在重启中的服务。
public class RestartingInstanceRouter implements Router {
private final ReadyRestartInstances instances;
private final URL url;
public RestartingInstanceRouter(URL url) {
this.url = url;
this.instances = ReadyRestartInstances.create();
}
@Override
public URL getUrl() {
return url;
}
@Override
public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException {
// 如果没有在重启列表中 才会加入到后续调用列表
return invokers.stream().filter(i->!instances.hasRestartingInstance(i.getUrl().getParameter("remote.application"),i.getUrl().getIp()))
.collect(Collectors.toList());
}
@Override
public boolean isRuntime() {
return false;
}
@Override
public boolean isForce() {
return true;
}
@Override
public int getPriority() {
return 0;
}
}
- 由于Router 机制比较特殊,所以需要利用一个专门的RouterFactory 来生成,原因在于并不是所有的都需要添加路由,所以需要利用@Activate 来锁定具体哪些服务才需要生成使用。
@Activate
public class RestartingInstanceRouterFactory implements RouterFactory {
@Override
public Router getRouter(URL url) {
return new RestartingInstanceRouter(url);
}
}
- 对RouterFactory 进行注册,同样放入到META-INF/dubbo/org.apache.dubbo.rpc.cluster.RouterFactory 文件中。
restartInstances=com.elvis.router.RestartingInstanceRouterFactory
- 将dubbo-spi-router项目引入至consumer 项目的依赖中。
<dependency>
<groupId>com.elvis</groupId>
<artifactId>service-api-router</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
这里需要将consumer连接的超时时间设置的久一点
dubbo.registry.address=zookeeper://192.168.137.144:2181?timeout=40000
-
这时直接启动程序,还是利用上面中所写好的consumer 程序进行执行,确认各个provider 可以正常执行。
-
单独写一个main 函数来进行将某台实例设置为启动中的状态,比如这里我们认定为当前这台机器中的service-provider 这个提供者需要进行重启操作。
public class ServerRestartMain {
public static void main(String[] args) throws Exception {
ReadyRestartInstances.create().addRestartingInstance("dubbo-demo-annotation-provider-2","192.168.137.1");
// ReadyRestartInstances.create().removeRestartingInstance("dubbo-demo-annotation-provider-2","192.168.137.1");
}
}
-
执行完成后,再次进行尝试通过consumer 进行调用,即可看到当前这台机器没有再发送任何请求
-
一般情况下,当机器重启到一定时间后,我们可以再通过removeRestartingInstance 方法对这个机器设定为既可以继续执行。
public class ServerRestartMain {
public static void main(String[] args) throws Exception {
//ReadyRestartInstances.create().addRestartingInstance("dubbo-demo-annotation-provider-2","192.168.137.1");
ReadyRestartInstances.create().removeRestartingInstance("dubbo-demo-annotation-provider-2","192.168.137.1");
}
}
- 调用完成后,我们再次通过consumer 去调用,即可看到已经再次恢当前机器的请求参数。
服务动态降级
什么是服务降级
服务降级,当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务有策略的降低服务级别,以释放服务器资源,保证核心任务的正常运行。
为什么要服务降级
而为什么要使用服务降级,这是防止分布式服务发生雪崩效应,什么是雪崩?就是蝴蝶效应,当一个请求发生超时,一直等待着服务响应,那么在高并发情况下,很多请求都是因为这样一直等着响应,直到服务资源耗尽产生宕机,而宕机之后会导致分布式其他服务调用该宕机的服务也会出现资源耗尽宕机,这样下去将导致整个分布式服务都瘫痪,这就是雪崩。
dubbo 服务降级实现方式
第一种 在 dubbo 管理控制台配置服务降级
屏蔽和容错
- mock=force:return+null
表示消费方对该服务的方法调用都直接返回 null 值,不发起远程调用。用来屏蔽不重要服务不可用时对调用方的影响。 - mock=fail:return+null
表示消费方对该服务的方法调用在失败后,再返回 null 值,不抛异常。用来容忍不重要服务不稳定时对调用方的影响。
第二种 指定返回简单值或者null
<dubbo:reference id="xxService" check="false" interface="com.xx.XxService"timeout="3000" mock="return null" />
<dubbo:reference id="xxService2" check="false" interface="com.xx.XxService2"timeout="3000" mock="return 1234" />
如果是注解 则使用@Reference(mock=“return null”) @Reference(mock=“return 简单值”)也支持 @Reference(mock=“force:return null”)
第三种 使用java代码 动态写入配置中心
RegistryFactory registryFactory = ExtensionLoader.getExtensionLoader(RegistryFactory.class).getAdaptiveExtension();
Registry registry = registryFactory.getRegistry(URL.valueOf("zookeeper://10.20.153.10:2181"));
registry.register(URL.valueOf("override://0.0.0.0/com.foo.BarService?category=configurators&dynamic=false&application=foo&mock=force:return+null"));