
RocketMQ早期布道师、RocketMQ社区视频直播讲师。《RocketMQ架构设计与实战原理》作者。
温馨提示:源码分析 Alibaba Sentinel 专栏开始连载,本文展示如何学习一个全新的技术的方法。该专栏基于 1.7.0 版本。 在学习一个新技术或新框架时,建议先查看其官方文档, Sentinel 官方文档链接如下:官方文档,以获得对其形成一个整体的认识。 1、Sentinel 是什么 ?主要能解决什么问题? 按照官方的定义,Sentinel 意为分布式系统的流量防卫兵,主要提供限流、熔断等服务治理相关的功能。 服务的动态注册、服务发现是 SOA、微服务架构体系中首先需要解决的基本问题,服务治理是 SOA 领域又一重要课题,而 dubbo 框架只提供了一些基本的服务治理能力,例如限制服务并发调用数、配置合适的业务线程数量等,但熔断相关的功能就涉及的较少。 Sentinel 将作为 Dubbo 生态的重要一员,将集中解决服务治理相关的课题,服务限流与熔断又是服务治理首先要解决的课题。 那什么是限流与熔断呢? 限流:我们通常使用TPS对流量来进行描述,限流就是现在服务被调用的并发TPS,从而对系统进行自我保护。 熔断:就是当系统中某一个服务出现性能瓶颈是,对这个服务的调用进行快速失败,避免造成连锁反应,从而影响整个链路的调用。 2、限流与熔断的使用场景 限流还是比较好理解,例如一个项目在上线之前经过性能测试评估,例如服务在 TPS 达到 1w/s 时系统资源利用率飙升,与此同时响应时间急剧增大,那我们就要控制该服务的调用TPS,超过该 TPS 的流量就需要进行干预,可以采取拒绝、排队等策略,实现流量的削峰填谷。 还有一个场景,例如一下开放平台,对接口进行收费,免费用户要控制调用TPS,账户的等级不同,允许调用的TPS也不同,这种情况就非常适合限流。 那熔断的使用场景呢?我们首先来看一下如下的分布式架构。例如应用A 部署了3台机器,如果由于某种原因,例如线程池 hold 住,导致发送到它上面的请求会出现超时而报错,由于该进程并未宕机,请求还是会通过负载算法请求出现故障的机器,出现整个1/3的请求出现超时报错,影响整个系统的可用性?也就是其中一台故障会对整个服务质量产生严重的影响,虽然是集群部署,但无法达到高可用性。那如何解决该问题?如果在调用方(API-Center) 对异常进行统计,发现发往某一台机器的错误数或错误率达到设定的值,就在一定的世界间隔内不继续发往该机器,转而发送给集群内正常的节点,这样就实现了高可用,这就是所谓的熔断机制。 有了上面的基本认识,接下来会进行一些阅读源码的准备,为后面的源码分析 Sentinel 打下坚实的基础。 3、Sentinel 源码结构 Sentinel 的核心模块说明如下: sentinel-coreSentinel 核心模块,实现限流、熔断等基本能力。 sentinel-dashboardSentinel 可视化控制台,提供基本的管理界面,配置限流、熔断规则等,展示监控数据等。 sentinel-adapterSentinel 适配,Sentinel-core 模块提供的是限流等基本API,主要是提供给应用自己去显示调用,对代码有侵入性,故该模块对主流框架进行了适配,目前已适配的模块如下: sentinel-apache-dubbo-adapter对 Apache Dubbo 版本进行适配,这样应用只需引入 sentinel-apache-dubbo-adapter 包即可对 dubbo 服务进行流控与熔断,大家可以思考会利用 Dubbo 的哪个功能特性。 sentinel-dubbo-adapter对 Alibaba Dubbo 版本进行适配。 sentinel-grpc-adapter对 GRPC 进行适配。 sentinel-spring-webflux-adapter对响应式编程框架 webflux 进行适配。 sentinel-web-servlet对 servlet 进行适配,例如 Spring MVC。 sentinel-zuul-adapter对 zuul 网关进行适配。 sentinel-cluster提供集群模式的限流与熔断支持,因为通常一个应用会部署在多台机器上组成应用集群。 sentinel-transport网络通讯模块,提供 Sentinel 节点与 sentinel-dashboard 的通讯支持,主要有如下两种实现。 sentinel-transport-netty-http基于 Netty 实现的 http 通讯模式。 sentinel-transport-simple-http简单的 http 实现方式。 sentinel-extensionSentinel 扩展模式。主要提供了如下扩展(高级)功能: sentinel-annotation-aspectj提供基于注解的方式来定义资源等。 sentinel-parameter-flow-control提供基于参数的限流(热点限流)。 sentinel-datasource-extension限流规则、熔断规则的存储实现,默认是存储在内存中。 sentinel-datasource-apollo基于 apollo 配置中心实现限流规则、熔断规则的存储,动态推送生效机制。 sentinel-datasource-consul基于 consul 实现限流规则、熔断规则的存储,动态推送生效机制。 sentinel-datasource-etcd基于 etcd 实现限流规则、熔断规则的存储,动态推送生效机制。 sentinel-datasource-nacos基于 nacos 实现限流规则、熔断规则的存储,动态推送生效机制。 sentinel-datasource-redis基于 redis 实现限流规则、熔断规则的存储,动态推送生效机制。 sentinel-datasource-spring-cloud-config基于 spring-cloud-config 实现限流规则、熔断规则的存储,动态推送生效机制。 sentinel-datasource-zookeeper基于 zookeeper 实现限流规则、熔断规则的存储,动态推送生效机制。 4、在 IntelliJ IDEA 中运行 Sentine Demo 在 sentinel-demo 模块下提供了很多示例,Seninel 一开始是为 Dubbo 而生的,故我们选取一下 sentinel-demo-apache-dubbo 为本次演示的示例。 注意:该版本需要引入的 apache dubbo 版本需要修改为 2.7.2。 <dependency> <groupId>org.apache.dubbo</groupId> <artifactId>dubbo</artifactId> <version>2.7.2</version> </dependency> Step1:先启动 sentinel-dashboard,启动参数配置如下:sentinel-demo-apache-dubbo 模块如下所示:先启动服务提供者,其配置参数如下:然后启动服务消费者,其配置参数如下:启动后,我们能看到消费者会出现报错,因为触发了限流,我们可以通过控制台查看接入应用的信息,例如输入:http://localhost:8080 部分截图如下:可以在控制台动态添加限流、熔断等规则配置,然后接入的客户端将能在不启动应用的情况下生效。 默认情况下,sentinel-dashboard 中的规则是存储在内存中,重启后就会丢失,因此 Sentinel 提供了很多种数据源的实现,例如 sentinel-datasource-zookeeper,这部分内容随着该专栏的陆续更新,将会对该机制进行介绍。 作者信息:丁威,《RocketMQ技术内幕》作者,目前担任中通科技技术平台部资深架构师,维护 中间件兴趣圈公众号,目前主要发表了源码阅读java集合、JUC(java并发包)、Netty、ElasticJob、Mycat、Dubbo、RocketMQ、mybaits等系列源码。点击链接:加入笔者的知识星球,一起探讨高并发、分布式服务架构,分享阅读源码心得。
RegistryDirectory,基于注册中心的服务发现,本文将重点探讨Dubbo是如何实现服务的自动注册与发现。从上篇文章,得知在消息消费者在创建服务调用器(Invoker)【消费者在初始时】时需要根据不同的协议,例如dubbo、registry(从注册中心获取服务提供者)来构建,其调用的方法为Protocol#refer,基于注册中心发现服务提供者的实现协议为RegistryProtocol。 RegistryProtocol#refer ----> doRefer方法。 RegistryProtocol#doRefer private <T> Invoker<T> doRefer(Cluster cluster, Registry registry, Class<T> type, URL url) { // @1 RegistryDirectory<T> directory = new RegistryDirectory<T>(type, url); // @2 directory.setRegistry(registry); directory.setProtocol(protocol); // @3 // all attributes of REFER_KEY Map<String, String> parameters = new HashMap<String, String>(directory.getUrl().getParameters()); // @4 URL subscribeUrl = new URL(Constants.CONSUMER_PROTOCOL, parameters.remove(Constants.REGISTER_IP_KEY), 0, type.getName(), parameters); // @5 if (!Constants.ANY_VALUE.equals(url.getServiceInterface()) && url.getParameter(Constants.REGISTER_KEY, true)) { registry.register(subscribeUrl.addParameters(Constants.CATEGORY_KEY, Constants.CONSUMERS_CATEGORY, Constants.CHECK_KEY, String.valueOf(false))); } // @6 directory.subscribe(subscribeUrl.addParameter(Constants.CATEGORY_KEY, Constants.PROVIDERS_CATEGORY + "," + Constants.CONFIGURATORS_CATEGORY + "," + Constants.ROUTERS_CATEGORY)); // @7 Invoker invoker = cluster.join(directory); // @8 ProviderConsumerRegTable.registerConsumer(invoker, url, subscribeUrl, directory); // @9 return invoker; } 代码@1:参数详解 Cluster cluster:集群策略。 Registry registry:注册中心实现类。 Class type:引用服务名,dubbo:reference interface。 URL url:注册中心URL。 代码@2:构建RegistryDirectory对象,基于注册中心动态发现服务提供者(服务提供者新增或减少),本节重点会剖析该类的实现细节。 代码@3:为RegistryDirectory设置注册中心、协议。 代码@4:获取服务消费者的配置属性。 代码@5:构建消费者URL,例如: consumer://192.168.56.1/com.alibaba.dubbo.demo.DemoService?application=demo-consumer&check=false&dubbo=2.0.0&interface=com.alibaba.dubbo.demo.DemoService&methods=sayHello&pid=9892&qos.port=33333&side=consumer&timestamp=1528380277185 代码@6:向注册中心消息消费者: consumer://192.168.56.1/com.alibaba.dubbo.demo.DemoService?application=demo-consumer&category=consumers&check=false&dubbo=2.0.0&interface=com.alibaba.dubbo.demo.DemoService&methods=sayHello&pid=9892&qos.port=33333&side=consumer&timestamp=1528380277185 相比第5步的URL,增加了category=consumers、check=false,其中category表示在注册中心的命名空间,这里代表消费端。该步骤的作用就是向注册中心为服务增加一个消息消费者,其生成的效果如下:【以zookeeper为例】。 代码@7:为消息消费者添加category=providers,configurators,routers属性后,然后向注册中心订阅该URL,关注该服务下的providers,configurators,routers发生变化时通知RegistryDirectory,以便及时发现服务提供者、配置、路由规则的变化。 consumer://192.168.56.1/com.alibaba.dubbo.demo.DemoService?application=demo-consumer&category=providers,configurators,routers&check=false&dubbo=2.0.0&interface=com.alibaba.dubbo.demo.DemoService&methods=sayHello&pid=9892&qos.port=33333&side=consumer&timestamp=1528380277185 其订阅关系调用的入口为:RegistryDirectory#subscribe方法,是接下来需要重点分析的重点。 代码@8:根据Directory,利用集群策略返回集群Invoker。 代码@9:缓存服务消费者、服务提供者对应关系。 从这里发现,服务的注册与发现与RegistryDirectory联系非常紧密,接下来让我们来详细分析RegistryDirectory的实现细节。 1、RegistryDirectory类图 private static final Cluster cluster = ExtensionLoader.getExtensionLoader(Cluster.class).getAdaptiveExtension(); 集群策略,默认为failover。 private static final RouterFactory routerFactory = ExtensionLoader.getExtensionLoader (RouterFactory.class).getAdaptiveExtension()路由工厂,可以通过监控中心或治理中心配置。 private static final ConfiguratorFactory configuratorFactory = ExtensionLoader.getExtensionLoader(ConfiguratorFactory.class).getAdaptiveExtension();配置实现工厂类。 private final String serviceKey; 服务key,默认为服务接口名。com.alibaba.dubbo.registry.RegistryService,注册中心在Dubbo中也是使用服务暴露。 private final Class< T > serviceType;服务提供者接口类,例如interface com.alibaba.dubbo.demo.DemoService private final Map< String, String> queryMap:服务消费者URL中的所有属性。 private final URL directoryUrl;注册中心URL,只保留消息消费者URL查询属性,也就是queryMap。 private final String[] serviceMethods:引用服务提供者方法数组。 private final boolean multiGroup:是否引用多个服务组。 private Protocol protocol:协议。 private Registry registry:注册中心实现者。 private volatile List< Configurator> configurators;配置信息。 private volatile Map< String, Invoker< T>> urlInvokerMap; 服务URL对应的Invoker(服务提供者调用器)。 private volatile Map< String, List< Invoker< T>>> methodInvokerMap; methodName : List< Invoker< T >>, dubbo:method 对应的Invoker缓存表。 private volatile Set< URL > cachedInvokerUrls; 当前缓存的所有URL提供者URL。 2、RegistryDirectory 构造方法详解 public RegistryDirectory(Class<T> serviceType, URL url) { // @1 super(url); if (serviceType == null) throw new IllegalArgumentException("service type is null."); if (url.getServiceKey() == null || url.getServiceKey().length() == 0) throw new IllegalArgumentException("registry serviceKey is null."); this.serviceType = serviceType; this.serviceKey = url.getServiceKey(); // @2 this.queryMap = StringUtils.parseQueryString(url.getParameterAndDecoded(Constants.REFER_KEY)); // @3 this.overrideDirectoryUrl = this.directoryUrl = url.setPath(url.getServiceInterface()).clearParameters().addParameters(queryMap).removeParameter(Constants.MONITOR_KEY); //@4 String group = directoryUrl.getParameter(Constants.GROUP_KEY, ""); this.multiGroup = group != null && ("*".equals(group) || group.contains(",")); String methods = queryMap.get(Constants.METHODS_KEY); this.serviceMethods = methods == null ? null : Constants.COMMA_SPLIT_PATTERN.split(methods); // @5 } 代码@1:参数描述,serviceType:消费者引用的服务< dubbo:reference interface="" .../>;URL url:注册中心的URL,例如: zookeeper://127.0.0.1:2181/com.alibaba.dubbo.registry.RegistryService?application=demo-consumer&dubbo=2.0.0&pid=5552&qos.port=33333&refer=application%3Ddemo-consumer%26check%3Dfalse%26dubbo%3D2.0.0%26interface%3Dcom.alibaba.dubbo.demo.DemoService%26methods%3DsayHello%26pid%3D5552%26qos.port%3D33333%26register.ip%3D192.168.56.1%26side%3Dconsumer%26timestamp%3D1528379076123&timestamp=1528379076179 代码@2:获取注册中心URL的serviceKey:com.alibaba.dubbo.registry.RegistryService。 代码@3:获取注册中心URL消费提供者的所有配置参数:从url属性的refer。 代码@4:初始化haulovverrideDirecotryUrl、directoryUrl:注册中心的URL,移除监控中心以及其他属性值,只保留消息消费者的配置属性。 代码@5:获取服务消费者单独配置的方法名dubbo:method。 3、RegistryDirectory#subscribe public void subscribe(URL url) { setConsumerUrl(url); // @1 registry.subscribe(url, this); // @2 } 代码@1:设置RegistryDirectory的consumerUrl为消费者URL。 代码@2:调用注册中心订阅消息消息消费者URL,首先看一下接口Registry#subscribe的接口声明:RegistryService:void subscribe(URL url, NotifyListener listener); 这里传入的NotifyListener为RegistryDirectory,其注册中心的subscribe方法暂时不深入去跟踪,不过根据上面URL上面的特点,应该能猜出如下实现关键点: consumer://192.168.56.1/com.alibaba.dubbo.demo.DemoService?application=demo-consumer&category=providers,configurators,routers&check=false&dubbo=2.0.0&interface=com.alibaba.dubbo.demo.DemoService&methods=sayHello&pid=9892&qos.port=33333&side=consumer&timestamp=1528380277185 根据消息消费者URL,获取服务名。 根据category=providers、configurators、routers,分别在该服务名下的providers目录、configurators目录、routers目录建立事件监听,监听该目录下节点的创建、更新、删除事件,然后一旦事件触发,将回调RegistryDirectory#void notify(List< URL> urls)。 4、RegistryDirectory#notify 首先该方法是在注册中心providers、configurators、routers目录下的节点发生变化后,通知RegistryDirectory,已便更新最新信息,实现”动态“发现机制。 RegistryDirectory#notify List<URL> invokerUrls = new ArrayList<URL>(); List<URL> routerUrls = new ArrayList<URL>(); List<URL> configuratorUrls = new ArrayList<URL>(); for (URL url : urls) { String protocol = url.getProtocol(); // @1 String category = url.getParameter(Constants.CATEGORY_KEY, Constants.DEFAULT_CATEGORY); // @2 if (Constants.ROUTERS_CATEGORY.equals(category) || Constants.ROUTE_PROTOCOL.equals(protocol)) { // @3 routerUrls.add(url); } else if (Constants.CONFIGURATORS_CATEGORY.equals(category) || Constants.OVERRIDE_PROTOCOL.equals(protocol)) { // @4 configuratorUrls.add(url); } else if (Constants.PROVIDERS_CATEGORY.equals(category)) { // @5 invokerUrls.add(url); } else { logger.warn("Unsupported category " + category + " in notified url: " + url + " from registry " + getUrl().getAddress() + " to consumer " + NetUtils.getLocalHost()); } } Step1:根据通知的URL的前缀,分别添加到:invokerUrls(提供者url)、routerUrls(路由信息)、configuratorUrls (配置url)。 代码@1:从url中获取协议字段,例如condition://、route://、script://、override://等。 代码@2:获取url的category,在注册中心的命令空间,例如:providers、configurators、routers。 代码@3:如果category等于routers或协议等于route,则添加到routerUrls中。 代码@4:如果category等于configurators或协议等于override,则添加到configuratorUrls中。 代码@5:如果category等于providers,则表示服务提供者url,加入到invokerUrls中。 RegistryDirectory#notify // configurators if (configuratorUrls != null && !configuratorUrls.isEmpty()) { this.configurators = toConfigurators(configuratorUrls); } Step2:将configuratorUrls转换为配置对象List< Configurator> configurators,该方法将在《源码分析Dubbo配置规则实现细节》一文中详细讲解。 RegistryDirectory#notify // routers if (routerUrls != null && !routerUrls.isEmpty()) { List<Router> routers = toRouters(routerUrls); if (routers != null) { // null - do nothing setRouters(routers); } } Step3:将routerUrls路由URL转换为Router对象,该部分内容将在《源码分析Dubbo路由机制实现细节》一文中详细分析。 RegistryDirectory#notify // providers refreshInvoker(invokerUrls); Step4:根据回调通知刷新服务提供者集合。 5、RegistryDirectory#refreshInvoker RegistryDirectory#refreshInvoker if (invokerUrls != null && invokerUrls.size() == 1 && invokerUrls.get(0) != null && Constants.EMPTY_PROTOCOL.equals(invokerUrls.get(0).getProtocol())) { this.forbidden = true; // Forbid to access this.methodInvokerMap = null; // Set the method invoker map to null destroyAllInvokers(); // Close all invokers } Step1:如果invokerUrls不为空并且长度为1,并且协议为empty,表示该服务的所有服务提供者都下线了。需要销毁当前所有的服务提供者Invoker。 RegistryDirectory#refreshInvoker this.forbidden = false; // Allow to access Map<String, Invoker<T>> oldUrlInvokerMap = this.urlInvokerMap; // local reference if (invokerUrls.isEmpty() && this.cachedInvokerUrls != null) { invokerUrls.addAll(this.cachedInvokerUrls); } else { this.cachedInvokerUrls = new HashSet<URL>(); this.cachedInvokerUrls.addAll(invokerUrls);//Cached invoker urls, convenient for comparison } if (invokerUrls.isEmpty()) { return; } Step2: 如果invokerUrls为空,并且已缓存的invokerUrls不为空,将缓存中的invoker url复制到invokerUrls中,这里可以说明如果providers目录未发送变化,invokerUrls则为空,表示使用上次缓存的服务提供者URL对应的invoker;如果invokerUrls不为空,则用iinvokerUrls中的值替换原缓存的invokerUrls,这里说明,如果providers发生变化,invokerUrls中会包含此时注册中心所有的服务提供者。如果invokerUrls为空,则无需处理,结束本次更新服务提供者Invoker操作。 RegistryDirectory#refreshInvoker Map<String, Invoker<T>> newUrlInvokerMap = toInvokers(invokerUrls);// Translate url list to Invoker map Map<String, List<Invoker<T>>> newMethodInvokerMap = toMethodInvokers(newUrlInvokerMap); // Change method name to map Invoker Map Step3:将invokerUrls转换为对应的Invoke,然后根据服务级的url:invoker映射关系创建method:List< Invoker>映射关系,将在下文相信分析。 RegistryDirectory#refreshInvoker this.methodInvokerMap = multiGroup ? toMergeMethodInvokerMap(newMethodInvokerMap) : newMethodInvokerMap; this.urlInvokerMap = newUrlInvokerMap; try { destroyUnusedInvokers(oldUrlInvokerMap, newUrlInvokerMap); // Close the unused Invoker } catch (Exception e) { logger.warn("destroyUnusedInvokers error. ", e); } Step4:如果支持multiGroup机制,则合并methodInvoker,将在下文分析,然后根据toInvokers、toMethodInvokers刷新当前最新的服务提供者信息。 6、RegistryDirectory#toInvokers RegistryDirectory#toInvokers String queryProtocols = this.queryMap.get(Constants.PROTOCOL_KEY); for (URL providerUrl : urls) { // ... } Step1:获取消息消费者URL中的协议类型,< dubbo:reference protocol="" .../>属性值,然后遍历所有的Invoker Url(服务提供者URL)。 RegistryDirectory#toInvokers if (queryProtocols != null && queryProtocols.length() > 0) { boolean accept = false; String[] acceptProtocols = queryProtocols.split(","); for (String acceptProtocol : acceptProtocols) { if (providerUrl.getProtocol().equals(acceptProtocol)) { accept = true; break; } } if (!accept) { continue; } } Step2: 从这一步开始,代码都包裹在for(URL providerUrl : urls)中,一个一个处理提供者URL。如果dubbo:referecnce标签的protocol不为空,则需要对服务提供者URL进行过滤,匹配其协议与protocol属性相同的服务,如果不匹配,则跳过后续处理逻辑,接着处理下一个服务提供者URL。 RegistryDirectory#toInvokers if (Constants.EMPTY_PROTOCOL.equals(providerUrl.getProtocol())) { continue; } Step3:如果协议为empty,跳过,处理下一个服务提供者URL。 RegistryDirectory#toInvokers if (!ExtensionLoader.getExtensionLoader(Protocol.class).hasExtension(providerUrl.getProtocol())) { logger.error(new IllegalStateException("Unsupported protocol " + providerUrl.getProtocol() + " in notified url: " + providerUrl + " from registry " + getUrl().getAddress() + " to consumer " + NetUtils.getLocalHost() + ", supported protocol: " + ExtensionLoader.getExtensionLoader(Protocol.class).getSupportedExtensions())); continue; } Step4:验证服务提供者协议,如果不支持,则跳过。 RegistryDirectory#toInvokers URL url = mergeUrl(providerUrl); Step5:合并URL中的属性,其具体实现细节如下: 消费端属性覆盖生产者端属性(配置属性消费者端优先生产者端属性),其具体实现方法:ClusterUtils.mergeUrl(providerUrl, queryMap),其中queryMap为消费端属性。 a、首先移除只在服务提供者端生效的属性(线程池相关):threadname、default.threadname、threadpool、default.threadpool、corethreads、default.corethreads、threads、default.threads、queues、default.queues、alive、default.alive、transporter、default.transporter,服务提供者URL中的这些属性来源于dubbo:protocol、dubbo:provider。 b、用消费端配置属性覆盖服务端属性。 c、如下属性以服务端优先:dubbo(dubbo信息)、version(版本)、group(服务组)、methods(服务方法)、timestamp(时间戳)。 d、合并服务端,消费端Filter,其配置属性(reference.filter),返回结果为:provider#reference.filter, consumer#reference.filter。 e、合并服务端,消费端Listener,其配置属性(invoker.listener),返回结果为:provider#invoker.listener,consumer#invoker.listener。 合并configuratorUrls 中的属性,我们现在应该知道,dubbo可以在监控中心或管理端(dubbo-admin)覆盖覆盖服务提供者的属性,其使用协议为override,该部分的实现逻辑见:《源码分析Dubbo配置规则机制(override协议)》 为服务提供者URL增加check=false,默认只有在服务调用时才检查服务提供者是否可用。 重新复制overrideDirectoryUrl,providerUrl在进过第一步参数合并后(包含override协议覆盖后的属性)赋值给overrideDirectoryUrl。 String key = url.toFullString(); // The parameter urls are sorted if (keys.contains(key)) { // Repeated url continue; } keys.add(key); Step6:获取url所有属性构成的key,该key也是RegistryDirectory中Map> urlInvokerMap;中的key。 RegistryDirectory#toInvokers Map<String, Invoker<T>> localUrlInvokerMap = this.urlInvokerMap; // local reference Invoker<T> invoker = localUrlInvokerMap == null ? null : localUrlInvokerMap.get(key); if (invoker == null) { // Not in the cache, refer again try { boolean enabled = true; if (url.hasParameter(Constants.DISABLED_KEY)) { enabled = !url.getParameter(Constants.DISABLED_KEY, false); } else { enabled = url.getParameter(Constants.ENABLED_KEY, true); } if (enabled) { invoker = new InvokerDelegate<T>(protocol.refer(serviceType, url), url, providerUrl); } } catch (Throwable t) { logger.error("Failed to refer invoker for interface:" + serviceType + ",url:(" + url + ")" + t.getMessage(), t); } if (invoker != null) { // Put new invoker in cache newUrlInvokerMap.put(key, invoker); } } else { newUrlInvokerMap.put(key, invoker); } Step7:如果localUrlInvokerMap中未包含invoker并且该provider状态为启用,则创建该URL对应的Invoker,并添加到newUrlInvokerMap中。toInvokers运行结束后,回到refreshInvoker方法中继续往下执行,根据 最新的服务提供者映射关系Map< String,Invoker>,构建Map< String,List< Invoker>>,其中键为methodName。然后更新RegistryDirectory的urlInvokerMap、methodInvokerMap属性,并销毁老的Invoker对象,完成一次路由发现过程。 上面整个过程完成了一次动态服务提供者发现流程,下面再分析一下RegistryDirectory的另外一个重要方法,doList,再重复一遍RegistryDirectory的作用,服务提供者目录服务,在集群Invoker的实现中,内部持有一个Direcotry对象,在进行服务调用之前,首先先从众多的Invoker中选择一个来执行,那众多的Invoker从哪来呢?其来源于集群Invoker中会调用Direcotry的public List< Invoker< T>> list(Invocation invocation),首先将调用AbstractDirectory#list方法,然后再内部调用doList方法,doList方法有其子类实现。 7、RegistryDirectory#doList(Invocation invocation) 方法详解 RegistryDirectory#doList if (forbidden) { // 1. No service provider 2. Service providers are disabled throw new RpcException(RpcException.FORBIDDEN_EXCEPTION, "No provider available from registry " + getUrl().getAddress() + " for service " + getConsumerUrl().getServiceKey() + " on consumer " + NetUtils.getLocalHost() + " use dubbo version " + Version.getVersion() + ", please check status of providers(disabled, not registered or in blacklist)."); } Step1:如果禁止访问(如果没有服务提供者,或服务提供者被禁用),则抛出没有提供者异常。 RegistryDirectory#doList Map<String, List<Invoker<T>>> localMethodInvokerMap = this.methodInvokerMap; // local reference if (localMethodInvokerMap != null && localMethodInvokerMap.size() > 0) { String methodName = RpcUtils.getMethodName(invocation); Object[] args = RpcUtils.getArguments(invocation); if (args != null && args.length > 0 && args[0] != null && (args[0] instanceof String || args[0].getClass().isEnum())) { invokers = localMethodInvokerMap.get(methodName + "." + args[0]); // The routing can be enumerated according to the first parameter } if (invokers == null) { invokers = localMethodInvokerMap.get(methodName); } if (invokers == null) { invokers = localMethodInvokerMap.get(Constants.ANY_VALUE); } if (invokers == null) { Iterator<List<Invoker<T>>> iterator = localMethodInvokerMap.values().iterator(); if (iterator.hasNext()) { invokers = iterator.next(); } } } return invokers == null ? new ArrayList<Invoker<T>>(0) : invokers; Step2:根据方法名称,从Map< String,List< Invoker>>这个集合中找到合适的List< Invoker>,如果方法名未命中,则返回所有的Invoker,localMethodInvokerMap中方法名,主要是dubbo:service的子标签dubbo:method,最终返回invokers。 本文详细介绍了服务消费者基于注册中心的服务发现机制,其中对routers(路由)与configurators(override协议)并未详细展开,下节先重点分析configurators与routers(路由)实现细节。 总结一下服务注册与发现机制: 基于注册 中心的事件通知(订阅与发布),一切支持事件订阅与发布的框架都可以作为Dubbo注册中心的选型。 服务提供者在暴露服务时,会向注册中心注册自己,具体就是在${service interface}/providers目录下添加 一个节点(临时),服务提供者需要与注册中心保持长连接,一旦连接断掉(重试连接)会话信息失效后,注册中心会认为该服务提供者不可用(提供者节点会被删除)。 消费者在启动时,首先也会向注册中心注册自己,具体在${interface interface}/consumers目录下创建一个节点。 消费者订阅${service interface}/ [ providers、configurators、routers ]三个目录,这些目录下的节点删除、新增事件都胡通知消费者,根据通知,重构服务调用器(Invoker)。 以上就是Dubbo服务注册与动态发现机制的原理与实现细节。 原文发布时间为:2019-02-28本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
Invoker,负载网络调用组件,底层依懒与网络通信,Invoker主要负责服务调用,自然与路由(比如集群)等功能息息相关,本节先从整体上把控一下Dubbo服务调用体系,服务发现、集群、负载均衡、路由机制等整个知识体系,梳理整理Dubbo Invoker整个类图如下: 主要有如下接口群 Invocation(调用上下文环境) Invocation:1、String getMethodName() 获取调用方法名。 2、Class< ? >[] getParameterTypes() 获取被调用方法的参数列表(参数类型)3、Object[] getArguments() 获取被调用方法的参数值数组。4、Map< String, String> getAttachments() 获取附加属性。5、String getAttachment(String key) 根据key获取附加属性值。6、String getAttachment(String key, String defaultValue) 根据key获取附加属性,如果不存在,取默认值。7、Invoker< ?> getInvoker() 获取当前的invoker。 RpcInvocation rpc服务调用实现类 Invocation执行调用上下文环境,就是用一个Bean存储当前调用方法的参数,其本质就是一个普通的Bean而已。 MockInvocation 用于mock单元测试用。 DecodeableRpcInvocation 带解码功能的rpc调用上下文 该实现主要能从RPC服务调用请求中解析二进制流(二进制包)得到RPC服务调用上下文(方法调用元数据)。 Invoker 服务调用器,Dubbo中调用服务的抽象。Invoer的抽象接口,继承自com.alibaba.dubbo.common.Node接口 Node:1、URL getUrl(); 获取URL,在dubbo中,注册中心、服务提供者、服务消费者、监控中心等都使用URL描述。 2、boolean isAvailable() :判断是否可用。3、void destroy() :资源销毁。 Invoker:1、Class getInterface() :获取服务提供者的接口。 Result invoke(Invocation invocation) throws RpcException :调用服务,返回调用结果。 AbstractInvoker Invoker默认实现(模板类) 该方法主要实现public Result invoke(Invocation inv) throws RpcException,定义执行invoker的基础流程(模板),然后根据不同的实现子类(不同的协议)执行各自个性化的执行任务。其抽象方法:protected abstract Result doInvoke(Invocation invocation) throws Throwable,具体实现将在后文中分析。 DubboInvoker dubbo协议调用器具体实现。 InjvmInvoker injvm协议调用其具体实现(本地协议) AbstractClusterInvoker 集群模式调用模板类 该类为Dubbo集群模式的调用模板类,主题解决一个服务服务有多个服务提供者,此时消息消费端在调用服务时如何选择具体的服务提供者。该类需要组织多个服务提供者,并按照指定算法选择一服务提供者进行调用。 AvailableClusterInvoker 通过< dubbo:service cluster = "available" .../> 或 < dubbo:reference cluster="available" .../> 集群策略:总是选择第一个可用的服务提供者。 BroadcastClusterInvoker 通过< dubbo:service cluster = "broadcast" .../> 或 < dubbo:reference cluster="broadcast" .../> 集群策略:广播模式,向所有服务提供者都发送请求,任何一个调用失败,则认为失败。 FailbackClusterInvoker 通过< dubbo:service cluster = "failback" .../> 或 < dubbo:reference cluster="failback" .../> 集群策略:服务调用失败后,定时重试,重试次数无线次,重试频率:5s。并不会切换服务提供者。 FailfastClusterInvoker 通过< dubbo:service cluster = "failfast" .../> 或 < dubbo:reference cluster="failfast" .../> 集群策略:服务调用后,快速失败,直接抛出异常,并不重试,也不受retries参数的制约,适合新增、修改类操作。 FailoverClusterInvoker 通过< dubbo:service cluster = "failover" .../> 或 < dubbo:reference cluster="failover" .../> 集群策略:服务调用后,如果出现失败,则重试其他服务提供者,默认重试2次,总共执行3次,重试次数由retries配置,dubbo集群默认方式。 FailsafeClusterInvoker 通过< dubbo:service cluster = "failsafe" .../> 或 < dubbo:reference cluster="failsafe" .../> 集群策略:服务调用后,只打印错误日志,然后直接返回。 ForkingClusterInvoker 通过< dubbo:service cluster = "forking" .../> 或 < dubbo:reference cluster="forking" .../> 集群策略:并发调用多个服务提供者,取第一个返回的结果。可以通过forks设置并发调用的服务台提供者个数。 更多的集群策略,可以参考/dubbo-cluster/src/main/resources/META-/com.alibaba.dubbo.rpc.cluster.Cluster文件。 LoadBalance 集群负载算法当一个服务有多个服务提供者时,消费端在进行服务调用时选择服务服务提供者的负载均衡算法。 LoadBalance定义的接口为:< T> Invoker select(List< Invoker> invokers, URL url, Invocation invocation) throws RpcException; ConsistentHashLoadBalance可以通过< dubbo:service loadbalance="consistenthash" .../>或< dubbo:provider loadbalance = "consistenthash" .../> 负载均衡算法:一致性Hash算法,在AbstractClusterInvoker中从多个服务提供者中选择一个服务提供者时被调用。 LeastActiveLoadBalance可以通过< dubbo:service loadbalance="leastactive" .../>或< dubbo:provider loadbalance = "leastactive" .../> 负载均衡算法:最小活跃调用。 RandomLoadBalance可以通过< dubbo:service loadbalance="random" .../>或< dubbo:service loadbalance = "random" .../> 负载均衡算法:随机,如果weight(权重越大,机会越高) RoundRobinLoadBalance可以通过< dubbo:service loadbalance="roundrobin" .../>或< dubbo:provider loadbalance = "roundrobin" .../> 负载均衡算法:加权轮询算法。 Directory(目录服务,Invoker的目录服务)该接口主要的作用是服务提供者的目录服务,管理多个服务提供者。 Directory1、Class< T> getInterface() 获取该服务接口类别。 2、List< Invoker< T>> list(Invocation invocation) throws RpcException 根据调用上下文获取当前所有该服务的服务提供者。4.2 AbstractDirectory 目录服务实现的抽象列(模板类)4.3 StaticDirectory 静态目录服务 所谓静态目录服务就是在创建StaticDirectory时指定一个服务提供者集合,则该目录服务实例在其生命周期中,只会返回这些服务提供者。 RegistryDirectory 动态目录服务(基于注册中心)、从注册中心动态获取发现服务提供,默认消息消费者并不会指定特定的服务提供者URL,所以会向注册中心订阅服务的服务提供者(监听注册中心providers目录),利用RegistryDirectory自动获取注册中心服务器列表。 Router 路由功能根据消息消费者URL,结合路由表达式或JS引擎,从Directory中选择符合路由规则的Invoker,再执行负载均衡算法。 Router1、URL getUrl(); 获取消息消费者URL。 2、< T> List< Invoker< T>> route(List< Invoker< T>> invokers, URL url, Invocation invocation) throws RpcException 根据消息消费者URL,从invokers中筛选合适的Invokers。 ConditionRouter 基于条件表达式的路由实现。 ScriptRouter 基于JS引擎的路由实现。 单个Invoker的实现,例如DubboInvoker、InJVMInvoker底层调用网络通道发送请求命令(oneway、同步、异步调用方式),其网络底层细节将在后续专门讲解网络实现篇章重点分析,接下来的篇章,主要从源码的角度剖析集群、负载均衡、动态路由目录服务(RegistryDirectory )的实现细节。
1、背景 公司一个 RocketMQ 集群由4主4从组成,突然其中3台服务器“竟然”在同一时间下线,其监控显示如下:依次查看三台机器的监控图形,时间戳几乎完美“吻合”,不可思议吧。 2、故障分析 出现问题,先二话不说,马上重启各服务器,尽快恢复集群,降低对业务的影响,接下来开始对日志进行分析。 Java 进程自动退出(rocketmq 本身就是一个java进程),一种最常见的问题是由于内存溢出或由于内存泄漏导致进程发送Crash等。由于我们的启动参数中未配置-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/jvmdump 这两个参数,不能直接根据 是否生成 dump 文件,那退而求其次去查看其GC日志,将GC日志下载到本地,然后可以使用一个在线gc日志分析工具:https://gceasy.io/ ,将 gc 日志上传后会给出图形化的展示,其图如下:发现垃圾回收很正常。 既然 Java 进程不是由于内存溢出等问题导致的退出,那又会是什么原因呢?那我们来看一下那个点的broker的日志,其关键日志截图如下:发现 broker 日志中有打印出 shutdownHook,表示在进程退出之前执行了启动时注册时的退出钩子函数,说明 broker 是正常停止的,并且也不可能是 kill -9 命令,肯定是显示的执行了 shutodown 或 kill 命令,于是立马使用 history 命令 查看历史命令,都未在指定时间执行过该命令,并且切换到 root 命令后,同样使用 history 命令,并未发现端倪。 但我始终相信,肯定是执行了手动执行了 kill 命令导致进程退出的,经过网上查找查,得知可以通过查阅系统日志/var/log/messages 来查看系统命令的调用,于是乎把日志文件下载到本地,开始搜索 kill 关键字,发现如下日志:发现最近一次 kill 命令是在25号的凌晨1点多,停止 rocketmq 集群,并使用 bin/mqbroker -c conf/broker-b.conf & 进行了重新启动。 这个命令是有问题的,没有使用 nohup ,如果会话失效,该进程就会被退出,为了验证,我们再查一下进程退出时的日志:发现在故障发生点确实有 Removed 相关的日志。 故障原因基本分析到位了,运维在启动的时候没有使用 nohup 来启动,故马上排查刚启动的集群的方式,重新重启刚启动的 Broker。 RocketMQ优雅重启小建议: 首先将 broker 的写权限关闭,命令如下: bin/mqadmin updateBrokerConfig -b 192.168.x.x:10911 -n 192.168.x.x:9876 -k brokerPermission -v 4 通过 rocketmq-console 查看该broker的写入TPS,当写入TPS降为0后,再使用 kill pid 关闭 rocketmq 进程。温馨提示:将broker的写权限关闭后,非顺序消息不会立马拒绝,而是需要等客户端路由信息更新后,不会在往该broker上发送消息,故这个过程需要等待。 启动 rocketmq nohup bin/mqbroker -c conf/broker-a.conf /dev/null 2>&1 & 注意:nohup。 恢复该节点的写权限 bin/mqadmin updateBrokerConfig -b 192.168.x.x:10911 -n 192.168.x.x:9876 -k brokerPermission -v 6 本文的故障分析与处理就介绍到这里,本文重点讲解了故障的分析过程以及 RocketMQ Broker 优雅停机的方案。 如果本文对您有所帮助的话,麻烦帮忙点个赞,谢谢。 作者介绍:丁威,《RocketMQ技术内幕》作者,RocketMQ 社区布道师,公众号:中间件兴趣圈 维护者,目前已陆续发表源码分析Java集合、Java 并发包(JUC)、Netty、Mycat、Dubbo、RocketMQ、Mybatis等源码专栏。 原文发布时间为:2019-10-27本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
通过前面文章详解,我们知道Dubbo服务消费者标签dubbo:reference最终会在Spring容器中创建一个对应的ReferenceBean实例,而ReferenceBean实现了Spring生命周期接口:InitializingBean,接下来应该看一下其afterPropertiesSet方法的实现。 1、源码分析ReferenceBean#afterPropertiesSet ReferenceBean#afterPropertiesSet if (getConsumer() == null) { Map<String, ConsumerConfig> consumerConfigMap = applicationContext == null ? null : BeanFactoryUtils.beansOfTypeIncludingAncestors(applicationContext, ConsumerConfig.class, false, false); if (consumerConfigMap != null && consumerConfigMap.size() > 0) { ConsumerConfig consumerConfig = null; for (ConsumerConfig config : consumerConfigMap.values()) { if (config.isDefault() == null || config.isDefault().booleanValue()) { if (consumerConfig != null) { throw new IllegalStateException("Duplicate consumer configs: " + consumerConfig + " and " + config); } consumerConfig = config; } } if (consumerConfig != null) { setConsumer(consumerConfig); } } } Step1:如果consumer为空,说明dubbo:reference标签未设置consumer属性,如果一个dubbo:consumer标签,则取该实例,如果存在多个dubbo:consumer 配置,则consumer必须设置,否则会抛出异常:"Duplicate consumer configs"。 Step2:如果application为空,则尝试从BeanFactory中查询dubbo:application实例,如果存在多个dubbo:application配置,则抛出异常:"Duplicate application configs"。 Step3:如果ServiceBean的module为空,则尝试从BeanFactory中查询dubbo:module实例,如果存在多个dubbo:module,则抛出异常:"Duplicate module configs: "。 Step4:尝试从BeanFactory中加载所有的注册中心,注意ServiceBean的List< RegistryConfig> registries属性,为注册中心集合。 Step5:尝试从BeanFacotry中加载一个监控中心,填充ServiceBean的MonitorConfig monitor属性,如果存在多个dubbo:monitor配置,则抛出"Duplicate monitor configs: "。 ReferenceBean#afterPropertiesSet Boolean b = isInit(); if (b == null && getConsumer() != null) { b = getConsumer().isInit(); } if (b != null && b.booleanValue()) { getObject(); } Step6:判断是否初始化,如果为初始化,则调用getObject()方法,该方法也是FactoryBean定义的方法,ReferenceBean是dubbo:reference所真实引用的类(interface)的实例工程,getObject发返回的是interface的实例,而不是ReferenceBean实例。 1.1 源码分析getObject() public Object getObject() throws Exception { return get(); } ReferenceBean#getObject()方法直接调用其父类的get方法,get方法内部调用init()方法进行初始化 1.2 源码分析ReferenceConfig#init方法 ReferenceConfig#init if (initialized) { return; } initialized = true; if (interfaceName == null || interfaceName.length() == 0) { throw new IllegalStateException("<dubbo:reference interface=\"\" /> interface not allow null!"); } Step1:如果已经初始化,直接返回,如果interfaceName为空,则抛出异常。 ReferenceConfig#init调用ReferenceConfig#checkDefault private void checkDefault() { if (consumer == null) { consumer = new ConsumerConfig(); } appendProperties(consumer); } Step2:如果dubbo:reference标签也就是ReferenceBean的consumer属性为空,调用appendProperties方法,填充默认属性,其具体加载顺序: 从系统属性加载对应参数值,参数键:dubbo.consumer.属性名,从系统属性中获取属性值的方法为:System.getProperty(key)。 加载属性配置文件的值。属性配置文件,可通过系统属性:dubbo.properties.file,如果该值未配置,则默认取dubbo.properties属性配置文件。ReferenceConfig#init appendProperties(this); Step3:调用appendProperties方法,填充ReferenceBean的属性,属性值来源与step2一样,当然只填充ReferenceBean中属性为空的属性。 ReferenceConfig#init if (getGeneric() == null && getConsumer() != null) { setGeneric(getConsumer().getGeneric()); } if (ProtocolUtils.isGeneric(getGeneric())) { interfaceClass = GenericService.class; } else { try { interfaceClass = Class.forName(interfaceName, true, Thread.currentThread().getContextClassLoader()); } catch (ClassNotFoundException e) { throw new IllegalStateException(e.getMessage(), e); } checkInterfaceAndMethods(interfaceClass, methods); } Step4:如果使用返回引用,将interface值替换为GenericService全路径名,如果不是,则加载interfacename,并检验dubbo:reference子标签dubbo:method引用的方法是否在interface指定的接口中存在。 ReferenceConfig#init String resolve = System.getProperty(interfaceName); // @1 String resolveFile = null; if (resolve == null || resolve.length() == 0) { // @2 resolveFile = System.getProperty("dubbo.resolve.file"); // @3 start if (resolveFile == null || resolveFile.length() == 0) { File userResolveFile = new File(new File(System.getProperty("user.home")), "dubbo-resolve.properties"); if (userResolveFile.exists()) { resolveFile = userResolveFile.getAbsolutePath(); } } // @3 end if (resolveFile != null && resolveFile.length() > 0) { // @4 Properties properties = new Properties(); FileInputStream fis = null; try { fis = new FileInputStream(new File(resolveFile)); properties.load(fis); } catch (IOException e) { throw new IllegalStateException("Unload " + resolveFile + ", cause: " + e.getMessage(), e); } finally { try { if (null != fis) fis.close(); } catch (IOException e) { logger.warn(e.getMessage(), e); } } resolve = properties.getProperty(interfaceName); } } if (resolve != null && resolve.length() > 0) { // @5 url = resolve; if (logger.isWarnEnabled()) { if (resolveFile != null && resolveFile.length() > 0) { logger.warn("Using default dubbo resolve file " + resolveFile + " replace " + interfaceName + "" + resolve + " to p2p invoke remote service."); } else { logger.warn("Using -D" + interfaceName + "=" + resolve + " to p2p invoke remote service."); } } } Step5:处理dubbo服务消费端resolve机制,也就是说消息消费者只连服务提供者,绕过注册中心。 代码@1:从系统属性中获取该接口的直连服务提供者,如果存在 -Dinterface=dubbo://127.0.0.1:20880,其中interface为dubbo:reference interface属性的值。 代码@2:如果未指定-D属性,尝试从resolve配置文件中查找,从这里看出-D的优先级更高。 代码@3:首先尝试获取resolve配置文件的路径,其来源可以通过-Ddubbo.resolve.file=文件路径名来指定,如果未配置该系统参数,则默认从${user.home}/dubbo-resolve.properties,如果过文件存在,则设置resolveFile的值,否则resolveFile为null。 代码@4:如果resolveFile不为空,则加载resolveFile文件中内容,然后通过interface获取其配置的直连服务提供者URL。 代码@5:如果resolve不为空,则填充ReferenceBean的url属性为resolve(点对点服务提供者URL),打印日志,点对点URL的来源(系统属性、resolve配置文件)。 ReferenceConfig#init checkApplication(); checkStubAndMock(interfaceClass); Step6:校验ReferenceBean的application是否为空,如果为空,new 一个application,并尝试从系统属性(优先)、资源文件中填充其属性;同时校验stub、mock实现类与interface的兼容性。系统属性、资源文件属性的配置如下:application dubbo.application.属性名,例如 dubbo.application.name ReferenceConfig#init Map<String, String> map = new HashMap<String, String>(); Map<Object, Object> attributes = new HashMap<Object, Object>(); map.put(Constants.SIDE_KEY, Constants.CONSUMER_SIDE); map.put(Constants.DUBBO_VERSION_KEY, Version.getVersion()); map.put(Constants.TIMESTAMP_KEY, String.valueOf(System.currentTimeMillis())); if (ConfigUtils.getPid() > 0) { map.put(Constants.PID_KEY, String.valueOf(ConfigUtils.getPid())); } Step7:构建Map,封装服务消费者引用服务提供者URL的属性,这里主要填充side:consume(消费端)、dubbo:2.0.0(版本)、timestamp、pid:进程ID。 ReferenceConfig#init if (!isGeneric()) { String revision = Version.getVersion(interfaceClass, version); if (revision != null && revision.length() > 0) { map.put("revision", revision); } String[] methods = Wrapper.getWrapper(interfaceClass).getMethodNames(); if (methods.length == 0) { logger.warn("NO method found in service interface " + interfaceClass.getName()); map.put("methods", Constants.ANY_VALUE); } else { map.put("methods", StringUtils.join(new HashSet<String>(Arrays.asList(methods)), ",")); } } Step8:如果不是泛化引用,增加methods:interface的所有方法名,多个用逗号隔开。ReferenceConfig#init map.put(Constants.INTERFACE_KEY, interfaceName); appendParameters(map, application); appendParameters(map, module); appendParameters(map, consumer, Constants.DEFAULT_KEY); appendParameters(map, this); Step9:用Map存储application配置、module配置、默认消费者参数(ConsumerConfig)、服务消费者dubbo:reference的属性。ReferenceConfig#init String prefix = StringUtils.getServiceKey(map); if (methods != null && !methods.isEmpty()) { for (MethodConfig method : methods) { appendParameters(map, method, method.getName()); String retryKey = method.getName() + ".retry"; if (map.containsKey(retryKey)) { String retryValue = map.remove(retryKey); if ("false".equals(retryValue)) { map.put(method.getName() + ".retries", "0"); } } appendAttributes(attributes, method, prefix + "." + method.getName()); checkAndConvertImplicitConfig(method, map, attributes); } } Step10:获取服务键值 /{group}/interface:版本,如果group为空,则为interface:版本,其值存为prifex,然后将dubbo:method的属性名称也填入map中,键前缀为dubbo.method.methodname.属性名。dubbo:method的子标签dubbo:argument标签的属性也追加到attributes map中,键为 prifex + methodname.属性名。 ReferenceConfig#init String hostToRegistry = ConfigUtils.getSystemProperty(Constants.DUBBO_IP_TO_REGISTRY); if (hostToRegistry == null || hostToRegistry.length() == 0) { hostToRegistry = NetUtils.getLocalHost(); } else if (isInvalidLocalHost(hostToRegistry)) { throw new IllegalArgumentException("Specified invalid registry ip from property:" + Constants.DUBBO_IP_TO_REGISTRY + ", value:" + hostToRegistry); } map.put(Constants.REGISTER_IP_KEY, hostToRegistry); Step11:填充register.ip属性,该属性是消息消费者连接注册中心的IP,并不是注册中心自身的IP。ReferenceConfig#init ref = createProxy(map); Step12:调用createProxy方法创建消息消费者代理,下面详细分析其实现细节。ReferenceConfig#init ConsumerModel consumerModel = new ConsumerModel(getUniqueServiceName(), this, ref, interfaceClass.getMethods()); ApplicationModel.initConsumerModel(getUniqueServiceName(), consumerModel); Step13:将消息消费者缓存在ApplicationModel中。 1.2.1 源码分析ReferenceConfig#createProxy方法 ReferenceConfig#createProxy URL tmpUrl = new URL("temp", "localhost", 0, map); final boolean isJvmRefer; if (isInjvm() == null) { if (url != null && url.length() > 0) { // if a url is specified, don't do local reference isJvmRefer = false; } else if (InjvmProtocol.getInjvmProtocol().isInjvmRefer(tmpUrl)) { // by default, reference local service if there is isJvmRefer = true; } else { isJvmRefer = false; } } else { isJvmRefer = isInjvm().booleanValue(); } Step1:判断该消费者是否是引用本(JVM)内提供的服务。 如果dubbo:reference标签的injvm(已过期,被local属性替换)如果不为空,则直接取该值,如果该值未配置,则判断ReferenceConfig的url属性是否为空,如果不为空,则isJvmRefer =false,表明该服务消费者将直连该URL的服务提供者;如果url属性为空,则判断该协议是否是isInjvm,其实现逻辑:获取dubbo:reference的scop属性,根据其值判断: 如果为空,isJvmRefer为false。 如果协议为injvm,就是表示为本地协议,既然提供了本地协议的实现,则无需配置isJvmRefer该标签为true,故,isJvmRerfer=false。 如果scope=local或injvm=true,isJvmRefer=true。 如果scope=remote,isJvmRefer设置为false。 如果是泛化引用,isJvmRefer设置为false。 其他默认情况,isJvmRefer设置为true。 ReferenceConfig#createProxy if (isJvmRefer) { URL url = new URL(Constants.LOCAL_PROTOCOL, NetUtils.LOCALHOST, 0, interfaceClass.getName()).addParameters(map); invoker = refprotocol.refer(interfaceClass, url); if (logger.isInfoEnabled()) { logger.info("Using injvm service " + interfaceClass.getName()); } } Step2:如果消费者引用本地JVM中的服务,则利用InjvmProtocol创建Invoker,dubbo中的invoker主要负责服务调用的功能,是其核心实现,后续会在专门的章节中详细分析,在这里我们需要知道,会创建于协议相关的Invoker即可。 ReferenceConfig#createProxy if (url != null && url.length() > 0) { // user specified URL, could be peer-to-peer address, or register center's address. String[] us = Constants.SEMICOLON_SPLIT_PATTERN.split(url); // @1 if (us != null && us.length > 0) { for (String u : us) { URL url = URL.valueOf(u); if (url.getPath() == null || url.getPath().length() == 0) { url = url.setPath(interfaceName); } if (Constants.REGISTRY_PROTOCOL.equals(url.getProtocol())) { // @2 urls.add(url.addParameterAndEncoded(Constants.REFER_KEY, StringUtils.toQueryString(map))); } else { urls.add(ClusterUtils.mergeUrl(url, map)); // @3 } } } } Step3:处理直连情况,与step2互斥。 代码@1:对直连URL进行分割,多个直连URL用分号隔开,如果URL中不包含path属性,则为URL设置path属性为interfaceName。 代码@2:如果直连提供者的协议为registry,则对url增加refer属性,其值为消息消费者所有的属性。(表示从注册中心发现服务提供者) 代码@3:如果是其他协议提供者,则合并服务提供者与消息消费者的属性,并移除服务提供者默认属性。以default开头的属性。 ReferenceConfig#createProxy List<URL> us = loadRegistries(false); // @1 if (us != null && !us.isEmpty()) { for (URL u : us) { URL monitorUrl = loadMonitor(u); // @2 if (monitorUrl != null) { map.put(Constants.MONITOR_KEY, URL.encode(monitorUrl.toFullString())); // @3 } urls.add(u.addParameterAndEncoded(Constants.REFER_KEY, StringUtils.toQueryString(map))); // @4 } } if (urls == null || urls.isEmpty()) { throw new IllegalStateException("No such any registry to reference " + interfaceName + " on the consumer " + NetUtils.getLocalHost() + " use dubbo version " + Version.getVersion() + ", please config <dubbo:registry address=\"...\" /> to your spring config."); } Step4:普通消息消费者,从注册中心订阅服务。 代码@1:获取所有注册中心URL,其中参数false表示消费端,需要排除dubbo:registry subscribe=false的注册中心,其值为false表示不接受订阅。代码@2:根据注册中心URL,构建监控中心URL。代码@3:如果监控中心不为空,在注册中心URL后增加属性monitor。代码@4:在注册中心URL中,追加属性refer,其值为消费端的所有配置组成的URL。 ReferenceConfig#createProxy if (urls.size() == 1) { invoker = refprotocol.refer(interfaceClass, urls.get(0)); // @1 } else { List<Invoker<?>> invokers = new ArrayList<Invoker<?>>(); // @2,多个服务提供者URL,集群模式 URL registryURL = null; for (URL url : urls) { invokers.add(refprotocol.refer(interfaceClass, url)); // @2 if (Constants.REGISTRY_PROTOCOL.equals(url.getProtocol())) { registryURL = url; // use last registry url } } if (registryURL != null) { // registry url is available // use AvailableCluster only when register's cluster is available URL u = registryURL.addParameter(Constants.CLUSTER_KEY, AvailableCluster.NAME); invoker = cluster.join(new StaticDirectory(u, invokers)); // @3 } else { // not a registry url invoker = cluster.join(new StaticDirectory(invokers)); } } Step5:根据URL获取对应协议的Invoker。 代码@1:如果只有一个服务提供者URL,则直接根据协议构建Invoker,具体有如下协议: 代码@2:如果有多个服务提供者,则众多服务提供者构成一个集群。首先根据协议构建服务Invoker,默认Dubbo基于服务注册于发现,在服务消费端不会指定url属性,从注册中心获取服务提供者列表,此时的URL:registry://开头,url中会包含register属性,其值为注册中心的类型,例如zookeeper,将使用RedisProtocol构建Invoker,该方法将自动发现注册在注册中心的服务提供者,后续文章将会zookeeper注册中心为例,详细分析其实现原理。 代码@3:返回集群模式实现的Invoker,Dubbo中的Invoker类继承体系如下: 集群模式的Invoker和单个协议Invoker一样实现Invoker接口,然后在集群Invoker中利用Directory保证一个一个协议的调用器,十分的巧妙,在后续章节中将重点分析Dubbo Invoker实现原理,包含集群实现机制。 ReferenceConfig#createProxy Boolean c = check; if (c == null && consumer != null) { c = consumer.isCheck(); } if (c == null) { c = true; // default true } if (c && !invoker.isAvailable()) { throw new IllegalStateException("Failed to check the status of the service " + interfaceName + ". No provider available for the service " + (group == null ? "" : group + "/") + interfaceName + (version == null ? "" : ":" + version) + " from the url " + invoker.getUrl() + " to the consumer " + NetUtils.getLocalHost() + " use dubbo version " + Version.getVersion()); } 代码@4:如果dubbo:referecnce的check=true或默认为空,则需要判断服务提供者是否存在。 ReferenceConfig#createProxy return (T) proxyFactory.getProxy(invoker); AbstractProxyFactory#getProxy public <T> T getProxy(Invoker<T> invoker) throws RpcException { Class<?>[] interfaces = null; String config = invoker.getUrl().getParameter("interfaces"); // @1 if (config != null && config.length() > 0) { String[] types = Constants.COMMA_SPLIT_PATTERN.split(config); if (types != null && types.length > 0) { interfaces = new Class<?>[types.length + 2]; interfaces[0] = invoker.getInterface(); interfaces[1] = EchoService.class; // @2 for (int i = 0; i < types.length; i++) { interfaces[i + 1] = ReflectUtils.forName(types[i]); } } } if (interfaces == null) { interfaces = new Class<?>[]{invoker.getInterface(), EchoService.class}; } return getProxy(invoker, interfaces); // @3 } 根据invoker获取代理类,其实现逻辑如下: 代码@1:从消费者URL中获取interfaces的值,用,分隔出单个服务应用接口。 代码@2:增加默认接口EchoService接口。 代码@3:根据需要实现的接口,使用jdk或Javassist创建代理类。 最后给出消息消费者启动时序图: 本节关于Dubbo服务消费者(服务调用者)的启动流程就梳理到这里,下一篇将重点关注Invoker(服务调用相关的实现细节)。 原文发布时间为:2019-02-16本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
RocketMQ DLedger 多副本即主从切换专栏总共包含9篇文章,时间跨度大概为2个月的时间,笔者觉得授人以鱼不如授人以渔,借以这个系列来展示该系列的创作始末,展示笔者阅读源码的技巧。 首先在下决心研读 RocketMQ DLedger 多副本(主从切换)的源码之前,首先还是要通过官方的分享、百度等途径对该功能进行一些基本的了解。 我们了解到 RocketMQ 在 4.5.0 之前提供了主从同步功能,即当主节点宕机后,消费端可以继续从从节点上消费消息,但无法继续向该复制组发送消息。RocketMQ 4.5.0版本引入了多副本机制,即 DLedger,支持主从切换,即当一个复制组内的主节点宕机后,会在该复制组内触发重新选主,选主完成后即可继续提供消息写功能。同时还了解到 rocketmq 主从切换是基于 raft 协议的。 raft 协议是何许人也,我猜想大部分读者对这个名词并不陌生,但像笔者一样只是听过其大体作用但并未详细学习的应该也不在少数,故我觉得看 RocketMQ DLedger 多副本即主从切换之前应该重点了解 raft 协议。 1、RocketMQ 多副本前置篇:初探raft协议 本文主要根据 raft 官方提供的动画来学习了解 raft 协议,从本文基本得知了 raft 协议主要包含两个重要部分:选主 以及 日志复制。在了解了 raft 协议的选主、日志复制的基本实现后,然后就可以步入到 RocketMQ DLedger 多副本即主从切换的源码研究了,以探究大神是如何实现 raft 协议的。同时在了解到了 raft 协议的选主部分内容后,自己也可以简单的思考,如果自己去实现 raft 协议,应该要实现哪些关键点,当时我的思考如下:这样在看源码时更加有针对性,不至于在阅读源码过程中“迷失”。 2、源码分析 RocketMQ DLedger 多副本之 Leader 选主 本文按照上一篇的思路,重点对 DLedgerLeaderElector 的实现进行了详细分析,特别是其内部的状态机流转,最后也给出一张流程图对选主过程进行一个简单的梳理与总结。 温馨提示:如果在阅读源码的过程中一时无法理解,可以允许其提供的单元测试,DEBUG一下,可以起到拨云见雾之效。 3、源码分析 RocketMQ DLedger 多副本存储实现 在学习完 DLedger 选主实现后,接下来将重点突破 raft 协议的另外一个部分:日志复制。因为日志复制将涉及到存储,故在学习日志复制之前,先来看一下 DLedger 与存储相关的设计,例如 DLedger 日志条目的存储协议、日志在服务器的组织等关系,这部分类比 RocketMQ commitlog 等的存储。 4、源码分析 RocketMQ DLedger(多副本) 之日志追加流程 在学习完DLedger 多副本即主从切换 日志存储后,我们将正式进入到日志复制部分,从上图我们可以简单了解,日志复制其实包含两个比较大的阶段,第一阶段是指主节点(Leader)接受客户端请求后,将数据先存储到主服务器中,然后再将数据转发到它的所有从节点。故本篇文章中的关注第一阶段:日志追加。 5、源码分析 RocketMQ DLedger(多副本) 之日志复制(传播)本文继续关注日志复制的第二个阶段,包含主节点日志转发、从节点接收日志、主节点对日志转发进行仲裁,即需要实现只有超过集群半数节点都存储成功才认为该消息已成功提交,才会对客户端承偌消息发送成功。 6、基于 raft 协议的 RocketMQ DLedger 多副本日志复制设计原理 源码解读 raft 协议的日志复制部分毕竟比较枯燥,故本文梳理了3张流程图,并对日志的实现要点做一个总结,以此来介绍 rocketmq Dledger 多副本即主从切换部分的 raft 协议的解读。 7、RocketMQ 整合 DLedger(多副本)即主从切换实现平滑升级的设计技巧 前面6篇文章都聚焦在 raft 协议的选主与日志复制。从本节开始将介绍 rocketmq 主从切换的实现细节,基于 raft 协议已经可以实现主节点的选主与日志复制,主从切换的另外一个核心就是主从切换后元数据的同步,例如topic、消费组订阅信息、消息消费进度等。另外主从切换是rocketmq 4.5.0 版本才引入的,如果从老版本升级到 4.5.0,直接兼容原先的消息是重中之中,故本文将详细剖析其设计要点。 8、源码分析 RocketMQ DLedger 多副本即主从切换实现原理 从设计上理解了平滑升级的技巧,本篇就从源码角度剖析主从切换的实现要点,即重点关注元数据的同步(特别是消息消费进度的同步)。 9、RocketMQ DLedger 多副本即主从切换实战 经过前面8篇文章的铺垫,我相信大家对 DLedger 的实现原理有了一个全新的认识,本篇作为该系列的收官之作,介绍如何从主从同步集群平滑升级到DLedger,即主从切换版本,并对功能进行验证。 整体总结一下就是首先从整体上认识其核心要点,然后逐步展开,逐步分解形成一篇一篇的文章,在遇到看不懂的时候,可以 debug 官方提供的单元测试用例。 温馨提示:本专栏是《RocketMQ技术内幕》作者倾力打造的又一个精彩系列,也是《RocketMQ技术内幕》第二版的原始素材。 原文发布时间为:2019-10-20本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
本文继续上文Dubbo服务提供者启动流程,在上篇文章中详细梳理了基于dubbo spring文件的配置方式,Dubbo是如何加载配置文件,服务提供者dubbo:service标签服务暴露全流程,本节重点关注RegistryProtocol#export中调用doLocalExport方法,其实主要是根据各自协议,服务提供者建立网络服务器,在特定端口建立监听,监听来自消息消费端服务的请求。 RegistryProtocol#doLocalExport: private <T> ExporterChangeableWrapper<T> doLocalExport(final Invoker<T> originInvoker) { String key = getCacheKey(originInvoker); ExporterChangeableWrapper<T> exporter = (ExporterChangeableWrapper<T>) bounds.get(key); if (exporter == null) { synchronized (bounds) { exporter = (ExporterChangeableWrapper<T>) bounds.get(key); if (exporter == null) { final Invoker<?> invokerDelegete = new InvokerDelegete<T>(originInvoker, getProviderUrl(originInvoker)); // @1 exporter = new ExporterChangeableWrapper<T>((Exporter<T>) protocol.export(invokerDelegete), originInvoker); // @2 bounds.put(key, exporter); } } } return exporter; } 代码@1:如果服务提供者以dubbo协议暴露服务,getProviderUrl(originInvoker)返回的URL将以dubbo://开头。代码@2:根据Dubbo内置的SPI机制,将调用DubboProtocol#export方法。 1、源码分析DubboProtocol#export public <T> Exporter<T> export(Invoker<T> invoker) throws RpcException { URL url = invoker.getUrl(); // @1 // export service. String key = serviceKey(url); // @2 DubboExporter<T> exporter = new DubboExporter<T>(invoker, key, exporterMap); exporterMap.put(key, exporter); //export an stub service for dispatching event Boolean isStubSupportEvent = url.getParameter(Constants.STUB_EVENT_KEY, Constants.DEFAULT_STUB_EVENT); //@3 start Boolean isCallbackservice = url.getParameter(Constants.IS_CALLBACK_SERVICE, false); if (isStubSupportEvent && !isCallbackservice) { String stubServiceMethods = url.getParameter(Constants.STUB_EVENT_METHODS_KEY); if (stubServiceMethods == null || stubServiceMethods.length() == 0) { if (logger.isWarnEnabled()) { logger.warn(new IllegalStateException("consumer [" + url.getParameter(Constants.INTERFACE_KEY) + "], has set stubproxy support event ,but no stub methods founded.")); } } else { stubServiceMethodsMap.put(url.getServiceKey(), stubServiceMethods); } } // @3 end openServer(url); // @4 optimizeSerialization(url); // @5 return exporter; } 代码@1:获取服务提供者URL,以协议名称,这里是dubbo://开头。代码@2:从服务提供者URL中获取服务名,key: interface:port,例如:com.alibaba.dubbo.demo.DemoService:20880。代码@3:是否将转发事件导出成stub。代码@4:根据url打开服务,下面将详细分析其实现。代码@5:根据url优化器序列化方式。 2、源码分析DubboProtocol#openServer private void openServer(URL url) { // find server. String key = url.getAddress(); // @1 //client can export a service which's only for server to invoke boolean isServer = url.getParameter(Constants.IS_SERVER_KEY, true); if (isServer) { ExchangeServer server = serverMap.get(key); // @2 if (server == null) { serverMap.put(key, createServer(url)); //@3 } else { // server supports reset, use together with override server.reset(url); //@4 } } } 代码@1:根据url获取网络地址:ip:port,例如:192.168.56.1:20880,服务提供者IP与暴露服务端口号。代码@2:根据key从服务器缓存中获取,如果存在,则执行代码@4,如果不存在,则执行代码@3.代码@3:根据URL创建一服务器,Dubbo服务提供者服务器实现类为ExchangeServer。代码@4:如果服务器已经存在,用当前URL重置服务器,这个不难理解,因为一个Dubbo服务中,会存在多个dubbo:service标签,这些标签都会在服务台提供者的同一个IP地址、端口号上暴露服务。 2.1 源码分析DubboProtocol#createServer private ExchangeServer createServer(URL url) { // send readonly event when server closes, it's enabled by default url = url.addParameterIfAbsent(Constants.CHANNEL_READONLYEVENT_SENT_KEY, Boolean.TRUE.toString()); // @1 // enable heartbeat by default url = url.addParameterIfAbsent(Constants.HEARTBEAT_KEY, String.valueOf(Constants.DEFAULT_HEARTBEAT)); // @2 String str = url.getParameter(Constants.SERVER_KEY, Constants.DEFAULT_REMOTING_SERVER); // @3 if (str != null && str.length() > 0 && !ExtensionLoader.getExtensionLoader(Transporter.class).hasExtension(str)) // @4 throw new RpcException("Unsupported server type: " + str + ", url: " + url); url = url.addParameter(Constants.CODEC_KEY, DubboCodec.NAME); // @5 ExchangeServer server; try { server = Exchangers.bind(url, requestHandler); // @6 } catch (RemotingException e) { throw new RpcException("Fail to start server(url: " + url + ") " + e.getMessage(), e); } str = url.getParameter(Constants.CLIENT_KEY); //@7 if (str != null && str.length() > 0) { Set<String> supportedTypes = ExtensionLoader.getExtensionLoader(Transporter.class).getSupportedExtensions(); if (!supportedTypes.contains(str)) { throw new RpcException("Unsupported client type: " + str); } } return server; } 代码@1:为服务提供者url增加channel.readonly.sent属性,默认为true,表示在发送请求时,是否等待将字节写入socket后再返回,默认为true。代码@2:为服务提供者url增加heartbeat属性,表示心跳间隔时间,默认为60*1000,表示60s。代码@3:为服务提供者url增加server属性,可选值为netty,mina等等,默认为netty。代码@4:根据SPI机制,判断server属性是否支持。代码@5:为服务提供者url增加codec属性,默认值为dubbo,协议编码方式。代码@6:根据服务提供者URI,服务提供者命令请求处理器requestHandler构建ExchangeServer实例。requestHandler的实现具体在以后详细分析Dubbo服务调用时再详细分析。代码@7:验证客户端类型是否可用。 2.1.1 源码分析Exchangers.bind 根据URL、ExchangeHandler构建服务器 public static ExchangeServer bind(URL url, ExchangeHandler handler) throws RemotingException { if (url == null) { throw new IllegalArgumentException("url == null"); } if (handler == null) { throw new IllegalArgumentException("handler == null"); } url = url.addParameterIfAbsent(Constants.CODEC_KEY, "exchange"); return getExchanger(url).bind(url, handler); } 上述代码不难看出,首先根据url获取Exchanger实例,然后调用bind方法构建ExchangeServer,Exchanger接口如下 ExchangeServer bind(URL url, ExchangeHandler handler) : 服务提供者调用。 ExchangeClient connect(URL url, ExchangeHandler handler):服务消费者调用。 dubbo提供的实现类为:HeaderExchanger,其bind方法如下: HeaderExchanger#bind public ExchangeServer bind(URL url, ExchangeHandler handler) throws RemotingException { return new HeaderExchangeServer(Transporters.bind(url, new DecodeHandler(new HeaderExchangeHandler(handler)))); } 从这里可以看出,端口的绑定由Transporters的bind方法实现。 2.1.2 源码分析Transporters.bind方法 public static Server bind(URL url, ChannelHandler... handlers) throws RemotingException { if (url == null) { throw new IllegalArgumentException("url == null"); } if (handlers == null || handlers.length == 0) { throw new IllegalArgumentException("handlers == null"); } ChannelHandler handler; if (handlers.length == 1) { handler = handlers[0]; } else { handler = new ChannelHandlerDispatcher(handlers); } return getTransporter().bind(url, handler); } public static Transporter getTransporter() { return ExtensionLoader.getExtensionLoader(Transporter.class).getAdaptiveExtension(); } 从这里得知,Dubbo网络传输的接口有Transporter接口实现,其继承类图所示: 本文以netty版本来查看一下Transporter实现。 NettyTransporter源码如下: public class NettyTransporter implements Transporter { public static final String NAME = "netty"; @Override public Server bind(URL url, ChannelHandler listener) throws RemotingException { return new NettyServer(url, listener); } @Override public Client connect(URL url, ChannelHandler listener) throws RemotingException { return new NettyClient(url, listener); } } NettyServer建立网络连接的实现方法为: protected void doOpen() throws Throwable { NettyHelper.setNettyLoggerFactory(); ExecutorService boss = Executors.newCachedThreadPool(new NamedThreadFactory("NettyServerBoss", true)); ExecutorService worker = Executors.newCachedThreadPool(new NamedThreadFactory("NettyServerWorker", true)); ChannelFactory channelFactory = new NioServerSocketChannelFactory(boss, worker, getUrl().getPositiveParameter(Constants.IO_THREADS_KEY, Constants.DEFAULT_IO_THREADS)); bootstrap = new ServerBootstrap(channelFactory); final NettyHandler nettyHandler = new NettyHandler(getUrl(), this); // @1 channels = nettyHandler.getChannels(); // https://issues.jboss.org/browse/NETTY-365 // https://issues.jboss.org/browse/NETTY-379 // final Timer timer = new HashedWheelTimer(new NamedThreadFactory("NettyIdleTimer", true)); bootstrap.setPipelineFactory(new ChannelPipelineFactory() { @Override public ChannelPipeline getPipeline() { NettyCodecAdapter adapter = new NettyCodecAdapter(getCodec(), getUrl(), NettyServer.this); ChannelPipeline pipeline = Channels.pipeline(); /*int idleTimeout = getIdleTimeout(); if (idleTimeout > 10000) { pipeline.addLast("timer", new IdleStateHandler(timer, idleTimeout / 1000, 0, 0)); }*/ pipeline.addLast("decoder", adapter.getDecoder()); pipeline.addLast("encoder", adapter.getEncoder()); pipeline.addLast("handler", nettyHandler); // @2 return pipeline; } }); // bind channel = bootstrap.bind(getBindAddress()); } 熟悉本方法需要具备Netty的知识,有关源码:阅读Netty系列文章,这里不对每一行代码进行解读,对于与网络相关的参数,将在后续文章中详细讲解,本方法@1、@2引起了我的注意,首先创建NettyServer必须传入一个服务提供者URL,但从DubboProtocol#createServer中可以看出,Server是基于网络套接字(ip:port)缓存的,一个JVM应用中,必然会存在多个dubbo:server标签,就会有多个URL,这里为什么可以这样做呢?从DubboProtocol#createServer中可以看出,在解析第二个dubbo:service标签时并不会调用createServer,而是会调用Server#reset方法,是不是这个方法有什么魔法,在reset方法时能将URL也注册到Server上,那接下来分析NettyServer#reset方法是如何实现的。 2.2 源码分析DdubboProtocol#reset reset方法最终将用Server的reset方法,同样还是以netty版本的NettyServer为例,查看reset方法的实现原理。NettyServer#reset--->父类(AbstractServer) AbstractServer#reset public void reset(URL url) { if (url == null) { return; } try { // @1 start if (url.hasParameter(Constants.ACCEPTS_KEY)) { int a = url.getParameter(Constants.ACCEPTS_KEY, 0); if (a > 0) { this.accepts = a; } } } catch (Throwable t) { logger.error(t.getMessage(), t); } try { if (url.hasParameter(Constants.IDLE_TIMEOUT_KEY)) { int t = url.getParameter(Constants.IDLE_TIMEOUT_KEY, 0); if (t > 0) { this.idleTimeout = t; } } } catch (Throwable t) { logger.error(t.getMessage(), t); } try { if (url.hasParameter(Constants.THREADS_KEY) && executor instanceof ThreadPoolExecutor && !executor.isShutdown()) { ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor; int threads = url.getParameter(Constants.THREADS_KEY, 0); int max = threadPoolExecutor.getMaximumPoolSize(); int core = threadPoolExecutor.getCorePoolSize(); if (threads > 0 && (threads != max || threads != core)) { if (threads < core) { threadPoolExecutor.setCorePoolSize(threads); if (core == max) { threadPoolExecutor.setMaximumPoolSize(threads); } } else { threadPoolExecutor.setMaximumPoolSize(threads); if (core == max) { threadPoolExecutor.setCorePoolSize(threads); } } } } } catch (Throwable t) { logger.error(t.getMessage(), t); } // @1 end super.setUrl(getUrl().addParameters(url.getParameters())); // @2 } 代码@1:首先是调整线程池的相关线程数量,这个好理解。、代码@2:然后设置调用setUrl覆盖原先NettyServer的private volatile URL url的属性,那为什么不会影响原先注册的dubbo:server呢?原来NettyHandler上加了注解:@Sharable,由该注解去实现线程安全。 Dubbo服务提供者启动流程将分析到这里了,本文并未对网络细节进行详细分析,旨在梳理出启动流程,有关Dubbo服务网络实现原理将在后续章节中详细分析,敬请期待。 原文发布时间为:2019-02-15本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
本节将详细分析Dubbo服务提供者的启动流程,请带着如下几个疑问进行本节的阅读,因为这几个问题将是接下来几篇文章分析的重点内容。 什么时候建立与注册中心的连接。 服务提供者什么时候向注册中心注册服务。 服务提供者与注册中心的心跳机制。 从上文中我们得知,服务提供者启动的核心入口为ServiceBean,本节将从源码级别详细剖析ServcieBean的实现原理,即Dubbo服务提供者的启动流程,ServiceBean的继承层次如图所示,dubbo:service标签的所有属性都被封装在此类图结构中。 1、源码分析ServiceBean#afterPropertiesSet ServiceBean#afterPropertiesSet if (getProvider() == null) { // @1 Map<String, ProviderConfig> provide ConfigMap = applicationContext == null ? null : BeanFactoryUtils.beansOfTypeIncludingAncestors(applicationContext, ProviderConfig.class, false, false); // @2 // ...... 具体解析代码省略。 } } Step1:如果provider为空,说明dubbo:service标签未设置provider属性,如果一个dubbo:provider标签,则取该实例,如果存在多个dubbo:provider配置则provider属性不能为空,否则抛出异常:"Duplicate provider configs"。 ServiceBean#afterPropertiesSet if (getApplication() == null && (getProvider() == null || getProvider().getApplication() == null)) { Map<String, ApplicationConfig> applicationConfigMap = applicationContext == null ? null : BeanFactoryUtils.beansOfTypeIncludingAncestors(applicationContext, ApplicationConfig.class, false, false); // ...省略 } Step2:如果application为空,则尝试从BeanFactory中查询dubbo:application实例,如果存在多个dubbo:application配置,则抛出异常:"Duplicate application configs"。 Step3:如果ServiceBean的module为空,则尝试从BeanFactory中查询dubbo:module实例,如果存在多个dubbo:module,则抛出异常:"Duplicate module configs: "。 Step4:尝试从BeanFactory中加载所有的注册中心,注意ServiceBean的List< RegistryConfig> registries属性,为注册中心集合。 Step5:尝试从BeanFacotry中加载一个监控中心,填充ServiceBean的MonitorConfig monitor属性,如果存在多个dubbo:monitor配置,则抛出"Duplicate monitor configs: "。 Step6:尝试从BeanFactory中加载所有的协议,注意:ServiceBean的List< ProtocolConfig> protocols是一个集合,也即一个服务可以通过多种协议暴露给消费者。 ServiceBean#afterPropertiesSet if (getPath() == null || getPath().length() == 0) { if (beanName != null && beanName.length() > 0 && getInterface() != null && getInterface().length() > 0 && beanName.startsWith(getInterface())) { setPath(beanName); } } Step7:设置ServiceBean的path属性,path属性存放的是dubbo:service的beanName(dubbo:service id)。 ServiceBean#afterPropertiesSet if (!isDelay()) { export(); } Step8:如果为启用延迟暴露机制,则调用export暴露服务。首先看一下isDelay的实现,然后重点分析export的实现原理(服务暴露的整个实现原理)。 ServiceBean#isDelay private boolean isDelay() { Integer delay = getDelay(); ProviderConfig provider = getProvider(); if (delay == null && provider != null) { delay = provider.getDelay(); } return supportedApplicationListener && (delay == null || delay == -1); } 如果有设置dubbo:service或dubbo:provider的属性delay,或配置delay为-1,都表示启用延迟机制,单位为毫秒,设置为-1,表示等到Spring容器初始化后再暴露服务。从这里也可以看出,Dubbo暴露服务的处理入口为ServiceBean#export---》ServiceConfig#export。 1.1 源码分析ServiceConfig#export 暴露服务 调用链:ServiceBean#afterPropertiesSet------>ServiceConfig#export public synchronized void export() { if (provider != null) { if (export == null) { export = provider.getExport(); } if (delay == null) { delay = provider.getDelay(); } } if (export != null && !export) { // @1 return; } if (delay != null && delay > 0) { // @2 delayExportExecutor.schedule(new Runnable() { @Override public void run() { doExport(); } }, delay, TimeUnit.MILLISECONDS); } else { doExport(); //@3 } } 代码@1:判断是否暴露服务,由dubbo:service export="true|false"来指定。 代码@2:如果启用了delay机制,如果delay大于0,表示延迟多少毫秒后暴露服务,使用ScheduledExecutorService延迟调度,最终调用doExport方法。 代码@3:执行具体的暴露逻辑doExport,需要大家留意:delay=-1的处理逻辑(基于Spring事件机制触发)。 1.2 源码分析ServiceConfig#doExport暴露服务 调用链:ServiceBean#afterPropertiesSet---调用------>ServiceConfig#export------>ServiceConfig#doExport ServiceConfig#checkDefault private void checkDefault() { if (provider == null) { provider = new ProviderConfig(); } appendProperties(provider); } Step1:如果dubbo:servce标签也就是ServiceBean的provider属性为空,调用appendProperties方法,填充默认属性,其具体加载顺序: 从系统属性加载对应参数值,参数键:dubbo.provider.属性名,System.getProperty。 加载属性配置文件的值。属性配置文件,可通过系统属性:dubbo.properties.file,如果该值未配置,则默认取dubbo.properties属性配置文件。 ServiceConfig#doExport if (ref instanceof GenericService) { interfaceClass = GenericService.class; if (StringUtils.isEmpty(generic)) { generic = Boolean.TRUE.toString(); } } else { try { interfaceClass = Class.forName(interfaceName, true, Thread.currentThread() .getContextClassLoader()); } catch (ClassNotFoundException e) { throw new IllegalStateException(e.getMessage(), e); } checkInterfaceAndMethods(interfaceClass, methods); checkRef(); generic = Boolean.FALSE.toString(); } Step2:校验ref与interface属性。如果ref是GenericService,则为dubbo的泛化实现,然后验证interface接口与ref引用的类型是否一致。 ServiceConfig#doExport if (local != null) { if ("true".equals(local)) { local = interfaceName + "Local"; } Class<?> localClass; try { localClass = ClassHelper.forNameWithThreadContextClassLoader(local); } catch (ClassNotFoundException e) { throw new IllegalStateException(e.getMessage(), e); } if (!interfaceClass.isAssignableFrom(localClass)) { throw new IllegalStateException("The local implementation class " + localClass.getName() + " not implement interface " + interfaceName); } } Step3:dubbo:service local机制,已经废弃,被stub属性所替换。 Step4:处理本地存根Stub, ServiceConfig#doExport checkApplication(); checkRegistry(); checkProtocol(); appendProperties(this); Step5:校验ServiceBean的application、registry、protocol是否为空,并从系统属性(优先)、资源文件中填充其属性。 系统属性、资源文件属性的配置如下: application dubbo.application.属性名,例如 dubbo.application.name registry dubbo.registry.属性名,例如 dubbo.registry.address protocol dubbo.protocol.属性名,例如 dubbo.protocol.port service dubbo.service.属性名,例如 dubbo.service.stub ServiceConfig#doExport checkStubAndMock(interfaceClass); Step6:校验stub、mock类的合理性,是否是interface的实现类。 ServiceConfig#doExport doExportUrls(); Step7:执行doExportUrls()方法暴露服务,接下来会重点分析该方法。 ServiceConfig#doExport ProviderModel providerModel = new ProviderModel(getUniqueServiceName(), this, ref); ApplicationModel.initProviderModel(getUniqueServiceName(), providerModel); Step8:将服务提供者信息注册到ApplicationModel实例中。 1.3 源码分析ServiceConfig#doExportUrls暴露服务具体实现逻辑 调用链:ServiceBean#afterPropertiesSet------>ServiceConfig#export------>ServiceConfig#doExport private void doExportUrls() { List<URL> registryURLs = loadRegistries(true); // @1 for (ProtocolConfig protocolConfig : protocols) { doExportUrlsFor1Protocol(protocolConfig, registryURLs); // @2 } } 代码@1:首先遍历ServiceBean的List< RegistryConfig> registries(所有注册中心的配置信息),然后将地址封装成URL对象,关于注册中心的所有配置属性,最终转换成url的属性(?属性名=属性值),loadRegistries(true),参数的意思:true,代表服务提供者,false:代表服务消费者,如果是服务提供者,则检测注册中心的配置,如果配置了register="false",则忽略该地址,如果是服务消费者,并配置了subscribe="false"则表示不从该注册中心订阅服务,故也不返回,一个注册中心URL示例:registry://127.0.0.1:2181/com.alibaba.dubbo.registry.RegistryService?application=demo-provider&dubbo=2.0.0&pid=7072&qos.port=22222&registry=zookeeper&timestamp=1527308268041 代码@2:然后遍历配置的所有协议,根据每个协议,向注册中心暴露服务,接下来重点分析doExportUrlsFor1Protocol方法的实现细节。 1.4 源码分析doExportUrlsFor1Protocol 调用链:ServiceBean#afterPropertiesSet------>ServiceConfig#export------>ServiceConfig#doExport------>ServiceConfig#doExportUrlsFor1Protocol ServiceConfig#doExportUrlsFor1Protocol String name = protocolConfig.getName(); if (name == null || name.length() == 0) { name = "dubbo"; } Map<String, String> map = new HashMap<String, String>(); map.put(Constants.SIDE_KEY, Constants.PROVIDER_SIDE); map.put(Constants.DUBBO_VERSION_KEY, Version.getVersion()); map.put(Constants.TIMESTAMP_KEY, String.valueOf(System.currentTimeMillis())); if (ConfigUtils.getPid() > 0) { map.put(Constants.PID_KEY, String.valueOf(ConfigUtils.getPid())); } appendParameters(map, application); appendParameters(map, module); appendParameters(map, provider, Constants.DEFAULT_KEY); appendParameters(map, protocolConfig); appendParameters(map, this); Step1:用Map存储该协议的所有配置参数,包括协议名称、dubbo版本、当前系统时间戳、进程ID、application配置、module配置、默认服务提供者参数(ProviderConfig)、协议配置、服务提供Dubbo:service的属性。 ServiceConfig#doExportUrlsFor1Protocol if (methods != null && !methods.isEmpty()) { for (MethodConfig method : methods) { appendParameters(map, method, method.getName()); String retryKey = method.getName() + ".retry"; if (map.containsKey(retryKey)) { String retryValue = map.remove(retryKey); if ("false".equals(retryValue)) { map.put(method.getName() + ".retries", "0"); } } List<ArgumentConfig> arguments = method.getArguments(); if (arguments != null && !arguments.isEmpty()) { for (ArgumentConfig argument : arguments) { // convert argument type if (argument.getType() != null && argument.getType().length() > 0) { Method[] methods = interfaceClass.getMethods(); // visit all methods if (methods != null && methods.length > 0) { for (int i = 0; i < methods.length; i++) { String methodName = methods[i].getName(); // target the method, and get its signature if (methodName.equals(method.getName())) { Class<?>[] argtypes = methods[i].getParameterTypes(); // one callback in the method if (argument.getIndex() != -1) { if (argtypes[argument.getIndex()].getName().equals(argument.getType())) { appendParameters(map, argument, method.getName() + "." + argument.getIndex()); } else { throw new IllegalArgumentException("argument config error : the index attribute and type attribute not match :index :" + argument.getIndex() + ", type:" + argument.getType()); } } else { // multiple callbacks in the method for (int j = 0; j < argtypes.length; j++) { Class<?> argclazz = argtypes[j]; if (argclazz.getName().equals(argument.getType())) { appendParameters(map, argument, method.getName() + "." + j); if (argument.getIndex() != -1 && argument.getIndex() != j) { throw new IllegalArgumentException("argument config error : the index attribute and type attribute not match :index :" + argument.getIndex() + ", type:" + argument.getType()); } } } } } } } } else if (argument.getIndex() != -1) { appendParameters(map, argument, method.getName() + "." + argument.getIndex()); } else { throw new IllegalArgumentException("argument config must set index or type attribute.eg: <dubbo:argument index='0' .../> or <dubbo:argument type=xxx .../>"); } } } } // end of methods for } Step2:如果dubbo:service有dubbo:method子标签,则dubbo:method以及其子标签的配置属性,都存入到Map中,属性名称加上对应的方法名作为前缀。dubbo:method的子标签dubbo:argument,其键为方法名.参数序号。 ServiceConfig#doExportUrlsFor1Protocol if (ProtocolUtils.isGeneric(generic)) { map.put(Constants.GENERIC_KEY, generic); map.put(Constants.METHODS_KEY, Constants.ANY_VALUE); } else { String revision = Version.getVersion(interfaceClass, version); if (revision != null && revision.length() > 0) { map.put("revision", revision); } String[] methods = Wrapper.getWrapper(interfaceClass).getMethodNames(); if (methods.length == 0) { logger.warn("NO method found in service interface " + interfaceClass.getName()); map.put(Constants.METHODS_KEY, Constants.ANY_VALUE); } else { map.put(Constants.METHODS_KEY, StringUtils.join(new HashSet<String>(Arrays.asList(methods)), ",")); } } Step3:添加methods键值对,存放dubbo:service的所有方法名,多个方法名用,隔开,如果是泛化实现,填充genric=true,methods为"*"; ServiceConfig#doExportUrlsFor1Protocol if (!ConfigUtils.isEmpty(token)) { if (ConfigUtils.isDefault(token)) { map.put(Constants.TOKEN_KEY, UUID.randomUUID().toString()); } else { map.put(Constants.TOKEN_KEY, token); } } Step4:根据是否开启令牌机制,如果开启,设置token键,值为静态值或uuid。 ServiceConfig#doExportUrlsFor1Protocol if (Constants.LOCAL_PROTOCOL.equals(protocolConfig.getName())) { protocolConfig.setRegister(false); map.put("notify", "false"); } Step5:如果协议为本地协议(injvm),则设置protocolConfig#register属性为false,表示不向注册中心注册服务,在map中存储键为notify,值为false,表示当注册中心监听到服务提供者发送变化(服务提供者增加、服务提供者减少等事件时不通知。 ServiceConfig#doExportUrlsFor1Protocol // export service String contextPath = protocolConfig.getContextpath(); if ((contextPath == null || contextPath.length() == 0) && provider != null) { contextPath = provider.getContextpath(); } Step6:设置协议的contextPath,如果未配置,默认为/interfacename ServiceConfig#doExportUrlsFor1Protocol String host = this.findConfigedHosts(protocolConfig, registryURLs, map); Integer port = this.findConfigedPorts(protocolConfig, name, map); Step7:解析服务提供者的IP地址与端口。服务IP地址解析顺序:(序号越小越优先) 系统环境变量,变量名:DUBBO_DUBBO_IP_TO_BIND 系统属性,变量名:DUBBO_DUBBO_IP_TO_BIND 系统环境变量,变量名:DUBBO_IP_TO_BIND 系统属性,变量名:DUBBO_IP_TO_BIND dubbo:protocol 标签的host属性 --》 dubbo:provider 标签的host属性 默认网卡IP地址,通过InetAddress.getLocalHost().getHostAddress()获取,如果IP地址不符合要求,继续下一个匹配。 判断IP地址是否符合要求的标准是: public static boolean isInvalidLocalHost(String host) { return host == null || host.length() == 0 || host.equalsIgnoreCase("localhost") || host.equals("0.0.0.0") || (LOCAL_IP_PATTERN.matcher(host).matches()); } 选择第一个可用网卡,其实现方式是建立socket,连接注册中心,获取socket的IP地址。其代码: Socket socket = new Socket(); try { SocketAddress addr = new InetSocketAddress(registryURL.getHost(), registryURL.getPort()); socket.connect(addr, 1000); hostToBind = socket.getLocalAddress().getHostAddress(); break; } finally { try { socket.close(); } catch (Throwable e) { } } 服务提供者端口解析顺序:(序号越小越优先) 系统环境变量,变量名:DUBBO_DUBBO_PORT_TO_BIND 系统属性,变量名:DUBBO_DUBBO_PORT_TO_BIND 系统环境变量,变量名:DUBBO_PORT_TO_BIND 系统属性,变量名DUBBO_PORT_TO_BIND dubbo:protocol标签port属性、dubbo:provider标签的port属性。 随机选择一个端口。 ServiceConfig#doExportUrlsFor1Protocol URL url = new URL(name, host, port, (contextPath == null || contextPath.length() == 0 ? "" : contextPath + "/") + path, map); Step8:根据协议名称、协议host、协议端口、contextPath、相关配置属性(application、module、provider、protocolConfig、service及其子标签)构建服务提供者URI。 URL运行效果图: 以dubbo协议为例,展示最终服务提供者的URL信息如下:dubbo://192.168.56.1:20880/com.alibaba.dubbo.demo.DemoService?anyhost=true&application=demo-provider&bind.ip=192.168.56.1&bind.port=20880&dubbo=2.0.0&generic=false&interface=com.alibaba.dubbo.demo.DemoService&methods=sayHello&pid=5916&qos.port=22222&side=provider&timestamp=1527168070857 ServiceConfig#doExportUrlsFor1Protocol String scope = url.getParameter(Constants.SCOPE_KEY); // don't export when none is configured if (!Constants.SCOPE_NONE.toString().equalsIgnoreCase(scope)) { // 接口暴露实现逻辑 } Step9:获取dubbo:service标签的scope属性,其可选值为none(不暴露)、local(本地)、remote(远程),如果配置为none,则不暴露。默认为local。 ServiceConfig#doExportUrlsFor1Protocol // export to local if the config is not remote (export to remote only when config is remote) if (!Constants.SCOPE_REMOTE.toString().equalsIgnoreCase(scope)) { // @1 exportLocal(url); } // export to remote if the config is not local (export to local only when config is local) if (!Constants.SCOPE_LOCAL.toString().equalsIgnoreCase(scope)) { // @2 if (logger.isInfoEnabled()) { logger.info("Export dubbo service " + interfaceClass.getName() + " to url " + url); } if (registryURLs != null && !registryURLs.isEmpty()) { // @3 for (URL registryURL : registryURLs) { url = url.addParameterIfAbsent(Constants.DYNAMIC_KEY, registryURL.getParameter(Constants.DYNAMIC_KEY)); // @4 URL monitorUrl = loadMonitor(registryURL); // @5 if (monitorUrl != null) { url = url.addParameterAndEncoded(Constants.MONITOR_KEY, monitorUrl.toFullString()); } if (logger.isInfoEnabled()) { logger.info("Register dubbo service " + interfaceClass.getName() + " url " + url + " to registry " + registryURL); } Invoker<?> invoker = proxyFactory.getInvoker(ref, (Class) interfaceClass, registryURL.addParameterAndEncoded(Constants.EXPORT_KEY, url.toFullString())); // @6 DelegateProviderMetaDataInvoker wrapperInvoker = new DelegateProviderMetaDataInvoker(invoker, this); Exporter<?> exporter = protocol.export(wrapperInvoker); // @7 exporters.add(exporter); } } else { Invoker<?> invoker = proxyFactory.getInvoker(ref, (Class) interfaceClass, url); DelegateProviderMetaDataInvoker wrapperInvoker = new DelegateProviderMetaDataInvoker(invoker, this); Exporter<?> exporter = protocol.export(wrapperInvoker); exporters.add(exporter); } } Step10:根据scope来暴露服务,如果scope不配置,则默认本地与远程都会暴露,如果配置成local或remote,那就只能是二选一。 代码@1:如果scope不为remote,则先在本地暴露(injvm):,具体暴露服务的具体实现,将在remote 模式中详细分析。 代码@2:如果scope不为local,则将服务暴露在远程。 代码@3:remote方式,检测当前配置的所有注册中心,如果注册中心不为空,则遍历注册中心,将服务依次在不同的注册中心进行注册。 代码@4:如果dubbo:service的dynamic属性未配置, 尝试取dubbo:registry的dynamic属性,该属性的作用是否启用动态注册,如果设置为false,服务注册后,其状态显示为disable,需要人工启用,当服务不可用时,也不会自动移除,同样需要人工处理,此属性不要在生产环境上配置。 代码@5:根据注册中心url(注册中心url),构建监控中心的URL,如果监控中心URL不为空,则在服务提供者URL上追加monitor,其值为监控中心url(已编码)。 如果dubbo spring xml配置文件中没有配置监控中心(dubbo:monitor),如果从系统属性-Ddubbo.monitor.address,-Ddubbo.monitor.protocol构建MonitorConfig对象,否则从dubbo的properties配置文件中寻找这个两个参数,如果没有配置,则返回null。 如果有配置,则追加相关参数,dubbo:monitor标签只有两个属性:address、protocol,其次会追加interface(MonitorService)、协议等。 代码@6:通过动态代理机制创建Invoker,dubbo的远程调用实现类。 Dubbo远程调用器如何构建,这里不详细深入,重点关注WrapperInvoker的url为:registry://127.0.0.1:2181/com.alibaba.dubbo.registry.RegistryService?application=demo-provider&dubbo=2.0.0&export=dubbo%3A%2F%2F192.168.56.1%3A20880%2Fcom.alibaba.dubbo.demo.DemoService%3Fanyhost%3Dtrue%26application%3Ddemo-provider%26bind.ip%3D192.168.56.1%26bind.port%3D20880%26dubbo%3D2.0.0%26generic%3Dfalse%26interface%3Dcom.alibaba.dubbo.demo.DemoService%26methods%3DsayHello%26pid%3D6328%26qos.port%3D22222%26side%3Dprovider%26timestamp%3D1527255510215&pid=6328&qos.port=22222&registry=zookeeper&timestamp=1527255510202,这里有两个重点值得关注: path属性:com.alibaba.dubbo.registry.RegistryService,注册中心也类似于服务提供者。 export属性:值为服务提供者的URL,为什么需要关注这个URL呢?请看代码@7,protocol属性为Protocol$Adaptive,Dubbo在加载组件实现类时采用SPI(插件机制,有关于插件机制,在该专题后续文章将重点分析),在这里我们只需要知道,根据URL冒号之前的协议名将会调用相应的方法。 其映射关系(列出与服务启动相关协议实现类):dubbo=com.alibaba.dubbo.rpc.protocol.dubbo.DubboProtocol //文件位于dubbo-rpc-dubbo/src/main/resources/META-INF/dubbo/internal/com.alibaba.dubbo.rpc.Protocolregistry=com.alibaba.dubbo.registry.integration.RegistryProtocol //文件位于dubbo-registry-api/src/main/resources/META-INF/dubbo/internal/com.alibaba.dubbo.rpc.Protocol 代码@7:根据代码@6的分析,将调用RegistryProtocol#export方法。 1.5 源码分析RegistryProtocol#export方法 调用链:ServiceBean#afterPropertiesSet------>ServiceConfig#export------>ServiceConfig#doExport------>ServiceConfig#doExportUrlsFor1Protocol------>RegistryProtocol#export RegistryProtocol#export @Override public <T> Exporter<T> export(final Invoker<T> originInvoker) throws RpcException { //export invoker final ExporterChangeableWrapper<T> exporter = doLocalExport(originInvoker); // @1 URL registryUrl = getRegistryUrl(originInvoker); // @2 //registry provider final Registry registry = getRegistry(originInvoker); // @3 final URL registedProviderUrl = getRegistedProviderUrl(originInvoker); // @4start //to judge to delay publish whether or not boolean register = registedProviderUrl.getParameter("register", true); ProviderConsumerRegTable.registerProvider(originInvoker, registryUrl, registedProviderUrl); if (register) { register(registryUrl, registedProviderUrl); // @4 end ProviderConsumerRegTable.getProviderWrapper(originInvoker).setReg(true); } // Subscribe the override data // FIXME When the provider subscribes, it will affect the scene : a certain JVM exposes the service and call the same service. Because the subscribed is cached key with the name of the service, it causes the subscription information to cover. final URL overrideSubscribeUrl = getSubscribedOverrideUrl(registedProviderUrl); // @5 start final OverrideListener overrideSubscribeListener = new OverrideListener(overrideSubscribeUrl, originInvoker); overrideListeners.put(overrideSubscribeUrl, overrideSubscribeListener); registry.subscribe(overrideSubscribeUrl, overrideSubscribeListener); // @5 end //Ensure that a new exporter instance is returned every time export return new DestroyableExporter<T>(exporter, originInvoker, overrideSubscribeUrl, registedProviderUrl); } 代码@1:启动服务提供者服务,监听指定端口,准备服务消费者的请求,这里其实就是从WrapperInvoker中的url(注册中心url)中提取export属性,描述服务提供者的url,然后启动服务提供者。 从上图中,可以看出,将调用DubboProtocol#export完成dubbo服务的启动,利用netty构建一个微型服务端,监听端口,准备接受服务消费者的网络请求,本节旨在梳理其启动流程,具体实现细节,将在后续章节中详解,这里我们只要知道,< dubbo:protocol name="dubbo" port="20880" />,会再此次监听该端口,然后将dubbo:service的服务handler加入到命令处理器中,当有消息消费者连接该端口时,通过网络解包,将需要调用的服务和参数等信息解析处理后,转交给对应的服务实现类处理即可。 代码@2:获取真实注册中心的URL,例如zookeeper注册中心的URL:zookeeper://127.0.0.1:2181/com.alibaba.dubbo.registry.RegistryService?application=demo-provider&dubbo=2.0.0&export=dubbo%3A%2F%2F192.168.56.1%3A20880%2Fcom.alibaba.dubbo.demo.DemoService%3Fanyhost%3Dtrue%26application%3Ddemo-provider%26bind.ip%3D192.168.56.1%26bind.port%3D20880%26dubbo%3D2.0.0%26generic%3Dfalse%26interface%3Dcom.alibaba.dubbo.demo.DemoService%26methods%3DsayHello%26pid%3D10252%26qos.port%3D22222%26side%3Dprovider%26timestamp%3D1527263060882&pid=10252&qos.port=22222&timestamp=1527263060867 代码@3:根据注册中心URL,从注册中心工厂中获取指定的注册中心实现类:zookeeper注册中心的实现类为:ZookeeperRegistry 代码@4:获取服务提供者URL中的register属性,如果为true,则调用注册中心的ZookeeperRegistry#register方法向注册中心注册服务(实际由其父类FailbackRegistry实现)。 代码@5:服务提供者向注册中心订阅自己,主要是为了服务提供者URL发送变化后重新暴露服务,当然,会将dubbo:reference的check属性设置为false。 到这里就对文章开头提到的问题1,问题2做了一个解答,其与注册中心的心跳机制等将在后续章节中详细分析。 文字看起来可能不是很直观,现整理一下Dubbo服务提供者启动流程图如下: 本文重点梳理了Dubbo服务提供者启动流程,其中Dubbo服务提供者在指定端口监听服务的启动流程将在下一节中详细分析。 原文发布时间为:2019-01-29本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
本节主要阐述如下两个问题: Dubbo自定义标签实现。 dubbo通过Spring加载配置文件后,是如何触发注册中心、服务提供者、服务消费者按照Dubbo的设计执行相关的功能。 所谓的执行相关功能如下: 注册中心启动,监听消息提供者的注册服务、接收消息消费者的服务订阅(服务注册与发现机制)。 服务提供者向注册中心注册服务。 服务消费者向注册中心订阅服务。 接下来从使用dubbo的角度,从配置文件入手: 1、Dubbo服务提供者的一般配置如下: <!-- 提供方应用信息,用于计算依赖关系 --> <dubbo:application name="uop" owner="uce"/> <!-- 使用zookeeper注册中心暴露服务地址 --> <dubbo:registry protocol="zookeeper" address="zookeeper://192.168.xx.xx:2181?backup=192.168.xx.xx:2182,192.168.xx.xx:2183" /> <!--dubbox中引入Kryo和FST这两种高效Java序列化实现,来逐步取代原生dubbo中的hessian2,如果使用kryo记得添加依赖 --> <dubbo:protocol name="dubbo" serialization="kryo" port="20990" /> <!-- 定义服务提供者默认属性值 --> <dubbo:provider timeout="5000" threadpool="fixed" threads="100" accepts="1000" token="true"/> <!-- 暴露服务接口 一个服务可以用多个协议暴露,一个服务也可以注册到多个注册中心--> <!--Provider上尽量多配置Consumer端的属性,让Provider实现者一开始就思考Provider服务特点、服务质量的问题--> <dubbo:service interface="com.yingjun.dubbox.api.UserService" ref="userService" /> 上面通过dubbo提供的dubbo:application、dubbo:registry、dubbo:protocol、dubbo:provider、dubbo:service分别定义dubbo应用程序名、注册中心、协议、服务提供者参数默认值、服务提供者,这些配置后面的实现原理是什么呢?是如何启动并发挥相关作用的呢? 2、Spring自定义标签即命令空间实现 dubbo自定义标签与命名空间其实现代码在模块dubbo-config中,其核心实现如下: 2.1 DubboNamespaceHandler dubbo命名空间实现handler,其全路径:com.alibaba.dubbo.config.spring.schema.DubboNamespaceHandler,其源码实现如下: public class DubboNamespaceHandler extends NamespaceHandlerSupport { static { Version.checkDuplicate(DubboNamespaceHandler.class); } @Override public void init() { registerBeanDefinitionParser("application", new DubboBeanDefinitionParser(ApplicationConfig.class, true)); registerBeanDefinitionParser("module", new DubboBeanDefinitionParser(ModuleConfig.class, true)); registerBeanDefinitionParser("registry", new DubboBeanDefinitionParser(RegistryConfig.class, true)); registerBeanDefinitionParser("monitor", new DubboBeanDefinitionParser(MonitorConfig.class, true)); registerBeanDefinitionParser("provider", new DubboBeanDefinitionParser(ProviderConfig.class, true)); registerBeanDefinitionParser("consumer", new DubboBeanDefinitionParser(ConsumerConfig.class, true)); registerBeanDefinitionParser("protocol", new DubboBeanDefinitionParser(ProtocolConfig.class, true)); registerBeanDefinitionParser("service", new DubboBeanDefinitionParser(ServiceBean.class, true)); registerBeanDefinitionParser("reference", new DubboBeanDefinitionParser(ReferenceBean.class, false)); registerBeanDefinitionParser("annotation", new AnnotationBeanDefinitionParser()); } } 从这里可以看出,dubbo自定义的标签主要包括:application、module、registry、monitor、provider、consumer、protocol、service、reference、annotation,其具体解析实现类主要包括:DubboBeanDefinitionParser(基于xml配置文件)、AnnotationBeanDefinitionParser(基于注解),下文会详细分析上述两个解析类的实现。 2.2 定义dubbo.xsd 文件 在dubbo-config-spring模块下的 src/main/resouce/META-INF中分别定义dubbo.xsd、spring.handlers、spring.schemas。 关于Spring如何新增命名空间与标签,在源码分析ElasticJob时已经详细介绍过,再这里就不做 过多重复,如需了解,请查看:https://blog.csdn.net/prestigeding/article/details/79751023 3、Bean解析机制(Spring基础知识) 我们应该知道,Spirng的配置支持xml配置文件与注解的方式,故Dubbo也支持两种配置方式,xml与注解方式。 3.1 xml配置方式解析 BeanDefinitionParser:Spring定义的bean解析器,要实现自定义标签,则需要实现该接口,然后通过NamespaceHandlerSupport将Bean定义解析器注册到Spring bean解析器中。从接口中可以看出,其终极目标就是将Element element(xml节点)解析成BeanDefinition,有关于Spring BeanDefinition,请参考:https://blog.csdn.net/prestigeding/article/details/80490206 DubboBeanDefinitionParser构造函数如下: public DubboBeanDefinitionParser(Class<?> beanClass, boolean required) { this.beanClass = beanClass; this.required = required; } beanClass:该xml标签节点最终会被Spring实例化的类名。 required:该标签的ID是否必须。 标签名 类名 dubbo:application ApplicationConfig dubbo:module ModuleConfig dubbo:registry RegistryConfig dubbo:monitor MonitorConfig dubbo:provider ProviderConfig dubbo:consumer ConsumerConfig dubbo:protocol ProtocolConfig dubbo:service ServiceBean dubbo:reference ReferenceBean 注:包名:com.alibaba.dubbo.config bean解析器的主要目的就是将上述标签,解析成对应的BeanDifinition,以便Spring构建上述类的实例。 本节不拷贝DubboBeanDefinitionParser根据xml定义的标签与属性转换成BeanDefinitionParser的每一行代码,本节只给出其大体关键点。 Step1:解析id属性,如果DubboBeanDefinitionParser对象的required属性为true,如果id为空,则根据如下规则构建一个id。 如果name属性不为空,则取name的值,如果已存在,则为 name + 序号,例如 name,name1,name2。 如果name属性为空,如果是dubbo:protocol标签,则取protocol属性,其他的则取interface属性,如果不为空,则取该值,但如果已存在,和name处理相同,在后面追加序号。 如果第二步还未空,则取beanClass的名称,如果已存在,则追加序号。 Step2:根据不同的标签解析特殊属性。 dubbo:protocol,添加protocol属性(BeanDefinition)。 dubbo:service,添加ref属性。 dubbo:provider,嵌套解析,dubbo:provider标签有两个可选的子标签,dubbo:service、dubbo:parameter,这里需要嵌套解析dubbo:service标签。 知识点:dubbo:provider是配置服务提供者的默认参数,在dubbo spring配置文件中可以配置多个dubbo:provider,那dubbo:service标签如何选取一个合适的dubbo:provider作为其默认参数呢?有两种办法: 将dubbo:service标签直接声明在dubbo:provider方法 在dubbo:service中通过provider属性指定一个provider配置,如果不填,并且存在多个dubbo:provider配置,则会抛出错误。 dubbo:customer:解析嵌套标签,其原理与dubbo:provider解析一样。 Step3:解析标签,将属性与值填充到BeanDefinition的propertyValues中。最终返回BeanDefinition实例,供Spring实例化Bean。 上述已经解答了Dubbo自定义标签的解析实现,主要完成了ApplicationConfig、RegistryConfig、ServiceBean、ReferenceBean实例的初始化,那什么时候构建与注册中心的连接、服务提供者什么时候会向注册中心注册服务,服务消费者向注册中心订阅服务呢? 通过上述步骤,我们已经知道已经成功解析注册中心、服务提供者、服务消费者的配置元信息,并将其实例化,按照我们的思路,配置对象生成后,下一步应该是实现Dubbo服务的注册与发现机制,但代码中无法找到相关代码。 据我目前所掌握的知识,Spring在对象实例化,一般有两种方式来对Bean做一些定制化处理。 实现BeanPostProcessor Spring后置处理器,在Bean初始化前后执行相关操作。 Bean实现InitializingBean接口(init-method) 浏览表格中所有Bean的声明,发现了两个类非常特殊: ServiceBean(服务提供者)与ReferenceBean(服务消费者)比较特殊,实现了Spring与Bean生命周期相关的接口。 InitializingBean,其声明的接口为afterPropertiesSet方法,顾名思义,就是在bean初始化所有属性之后调用。 DisposableBean:其声明的接口为destroy()方法,在Spring BeanFactory销毁一个单例实例之前调用。 ApplicationContextAware:其声明的接口为void setApplicationContext(ApplicationContext applicationContext),实现了该接口,Spring容器在初始化Bean时会调用该方法,注入ApplicationContext,已方便该实例可以直接调用applicationContext获取其他Bean。 ApplicationListener< ContextRefreshedEvent>:容器重新刷新时执行事件函数。 BeanNameAware:其声明的接口为:void setBeanName(String name),实现该接口的Bean,其实例可以获取该实例在BeanFactory的id或name。 FactoryBean:Spring初始化Bean的另外一种方式,例如dubbo:reference,需要返回的对象并不是ReferenceBean,而是要返回ref指定的代理类来执行业务操作,故这里使用FactoryBean非常合适,FactoryBean定义了如下三个方法: T getObject() throws Exception:获取需要返回的结果对象。 Class<?> getObjectType():获取返回对象的类型。 boolean isSingleton():返回是否是单例。 看到这里,不免有一点小激动,似乎已经摸到Dubbo服务注册与发现机制(Dubbo服务提供者、Dubbo服务消费者、注册中心的启动流程入口点了,下一步就是分析ServiceBean、ReferenceBean的实现原理,试图揭开Dubbo服务注册与发现机制,该部分内容将在下一篇中详细分析。 3.2 注解配置方式解析 注解配置方式的解析入口类:AnnotationBeanDefinitionParser,也是基于Spring注解解析逻辑,这部分在将在未来《Spring系列进阶篇-源码分析注解解析实现原理》中详细分析,目前暂未深究,读者朋友们,如果有兴趣,可以以AnnotationBeanDefinitionParser为入口,进行进一步的分析。 本节就讲解到这里了,下一篇将重点分析ServiceBean(服务提供者启动流程)。 原文发布时间为:2019-01-24本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
本文主要介绍如何将 RocketMQ 集群从原先的主从同步升级到主从切换。 首先介绍与 DLedger 多副本即 RocketMQ 主从切换相关的核心配置属性,然后尝试搭建一个主从同步集群,再从原先的 RocketMQ 集群平滑升级到 DLedger 集群的示例,并简单测试一下主从切换功能。 1、RocketMQ DLedger 多副本即主从切换核心配置参数详解 其主要的配置参数如下所示: enableDLegerCommitLog是否启用 DLedger,即是否启用 RocketMQ 主从切换,默认值为 false。如果需要开启主从切换,则该值需要设置为 true 。 dLegerGroup节点所属的 raft 组,建议与 brokerName 保持一致,例如 broker-a。 dLegerPeers集群节点信息,示例配置如下:n0-127.0.0.1:40911;n1-127.0.0.1:40912;n2-127.0.0.1:40913,多个节点用英文冒号隔开,单个条目遵循 legerSlefId-ip:端口,这里的端口用作 dledger 内部通信。 dLegerSelfId当前节点id。取自 legerPeers 中条目的开头,即上述示例中的 n0,并且特别需要强调,只能第一个字符为英文,其他字符需要配置成数字。 storePathRootDirDLedger 日志文件的存储根目录,为了能够支持平滑升级,该值与 storePathCommitLog 设置为不同的目录。 2、搭建主从同步环境 首先先搭建一个传统意义上的主从同步架构,往集群中灌一定量的数据,然后升级到 DLedger 集群。 在 Linux 服务器上搭建一个 rocketmq 主从同步集群我想不是一件很难的事情,故本文就不会详细介绍按照过程,只贴出相关配置。 实验环境的部署结构采取 一主一次,其部署图如下:下面我就重点贴一下 broker 的配置文件。220 上的 broker 配置文件如下: brokerClusterName = DefaultCluster brokerName = broker-a brokerId = 0 deleteWhen = 04 fileReservedTime = 48 brokerRole = ASYNC_MASTER flushDiskType = ASYNC_FLUSH brokerIP1=192.168.0.220 brokerIP2=192.168.0.220 namesrvAddr=192.168.0.221:9876;192.168.0.220:9876 storePathRootDir=/opt/application/rocketmq-all-4.5.2-bin-release/store storePathCommitLog=/opt/application/rocketmq-all-4.5.2-bin-release/store/commitlog autoCreateTopicEnable=false autoCreateSubscriptionGroup=false 221 上 broker 的配置文件如下: brokerClusterName = DefaultCluster brokerName = broker-a brokerId = 1 deleteWhen = 04 fileReservedTime = 48 brokerRole = SLAVE flushDiskType = ASYNC_FLUSH brokerIP1=192.168.0.221 brokerIP2=192.168.0.221 namesrvAddr=192.168.0.221:9876;192.168.0.220:9876 storePathRootDir=/opt/application/rocketmq-all-4.5.2-bin-release/store storePathCommitLog=/opt/application/rocketmq-all-4.5.2-bin-release/store/commitlog autoCreateTopicEnable=false autoCreateSubscriptionGroup=false 相关的启动命令如下: nohup bin/mqnamesrv /dev/null 2>&1 & nohup bin/mqbroker -c conf/broker.conf /dev/null 2>&1 & 安装后的集群信息如图所示: 3、主从同步集群升级到DLedger 3.1 部署架构 DLedger 集群至少需要3台机器,故搭建 DLedger 还需要再引入一台机器,其部署结构图如下:从主从同步集群升级到 DLedger 集群,用户最关心的还是升级后的集群是否能够兼容原先的数据,即原先存储在消息能否能被消息消费者消费端,甚至于能否查询到。为了方便后续验证,首先我使用下述程序向 mq 集群中添加了一篇方便查询的消息(设置消息的key)。 public class Producer { public static void main(String[] args) throws MQClientException, InterruptedException { DefaultMQProducer producer = new DefaultMQProducer("producer_dw_test"); producer.setNamesrvAddr("192.168.0.220:9876;192.168.0.221:9876"); producer.start(); for(int i =600000; i < 600100; i ++) { try { Message msg = new Message("topic_dw_test_by_order_01",null , "m" + i,("Hello RocketMQ" + i ).getBytes(RemotingHelper.DEFAULT_CHARSET)); SendResult sendResult = producer.send(msg); //System.out.printf("%s%n", sendResult); } catch (Exception e) { e.printStackTrace(); Thread.sleep(1000); } } producer.shutdown(); System.out.println("end"); } } 消息的查询结果示例如下: 3.2 升级步骤 Step1:将 192.168.0.220 的 rocketmq 拷贝到 192.168.0.222,可以使用如下命令进行操作。在 192.168.0.220 上敲如下命令: scp -r rocketmq-all-4.5.2-bin-release/ root@192.168.0.222:/opt/application/rocketmq-all-4.5.2-bin-release 温馨提示:示例中由于版本是一样,实际过程中,版本需要升级,故需先下载最新的版本,然后将老集群中的 store 目录完整的拷贝到新集群的 store 目录。 Step2:依次在三台服务器的 broker.conf 配置文件中添加与 dledger 相关的配置属性。 192.168.0.220 broker配置文件如下: brokerClusterName = DefaultCluster brokerId = 0 deleteWhen = 04 fileReservedTime = 48 brokerRole = ASYNC_MASTER flushDiskType = ASYNC_FLUSH brokerIP1=192.168.0.220 brokerIP2=192.168.0.220 namesrvAddr=192.168.0.221:9876;192.168.0.220:9876 storePathRootDir=/opt/application/rocketmq-all-4.5.2-bin-release/store storePathCommitLog=/opt/application/rocketmq-all-4.5.2-bin-release/store/commitlog autoCreateTopicEnable=false autoCreateSubscriptionGroup=false # 与 dledger 相关的属性 enableDLegerCommitLog=true storePathRootDir=/opt/application/rocketmq-all-4.5.2-bin-release/store/dledger_store dLegerGroup=broker-a dLegerPeers=n0-192.168.0.220:40911;n1-192.168.0.221:40911;n2-192.168.0.222:40911 dLegerSelfId=n0 192.168.0.221 broker配置文件如下: brokerClusterName = DefaultCluster brokerName = broker-a brokerId = 1 deleteWhen = 04 fileReservedTime = 48 brokerRole = SLAVE flushDiskType = ASYNC_FLUSH brokerIP1=192.168.0.221 brokerIP2=192.168.0.221 namesrvAddr=192.168.0.221:9876;192.168.0.220:9876 storePathRootDir=/opt/application/rocketmq-all-4.5.2-bin-release/store storePathCommitLog=/opt/application/rocketmq-all-4.5.2-bin-release/store/commitlog autoCreateTopicEnable=false autoCreateSubscriptionGroup=false # 与dledger 相关的配置属性 enableDLegerCommitLog=true storePathRootDir=/opt/application/rocketmq-all-4.5.2-bin-release/store/dledger_store dLegerGroup=broker-a dLegerPeers=n0-192.168.0.220:40911;n1-192.168.0.221:40911;n2-192.168.0.222:40911 dLegerSelfId=n1 192.168.0.222 broker配置文件如下: brokerClusterName = DefaultCluster brokerName = broker-a brokerId = 0 deleteWhen = 04 fileReservedTime = 48 brokerRole = ASYNC_MASTER flushDiskType = ASYNC_FLUSH brokerIP1=192.168.0.222 brokerIP2=192.168.0.222 namesrvAddr=192.168.0.221:9876;192.168.0.220:9876 storePathRootDir=/opt/application/rocketmq-all-4.5.2-bin-release/store storePathCommitLog=/opt/application/rocketmq-all-4.5.2-bin-release/store/commitlog autoCreateTopicEnable=false autoCreateSubscriptionGroup=false # 与 dledger 相关的配置 enableDLegerCommitLog=true storePathRootDir=/opt/application/rocketmq-all-4.5.2-bin-release/store/dledger_store dLegerGroup=broker-a dLegerPeers=n0-192.168.0.220:40911;n1-192.168.0.221:40911;n2-192.168.0.222:40911 dLegerSelfId=n2 温馨提示:legerSelfId 分别为 n0、n1、n2。在真实的生产环境中,broker配置文件中的 storePathRootDir、storePathCommitLog 尽量使用单独的根目录,这样判断其磁盘使用率时才不会相互影响。 Step3:将 store/config 下的 所有文件拷贝到 dledger store 的 congfig 目录下。 cd /opt/application/rocketmq-all-4.5.2-bin-release/store/ cp config/* dledger_store/config/ 温馨提示:该步骤按照各自按照时配置的目录进行复制即可。 Step4:依次启动三台 broker。 nohup bin/mqbroker -c conf/broker.conf /dev/null 2>&1 & 如果启动成功,则在 rocketmq-console 中看到的集群信息如下: 3.3 验证消息发送与消息查找 首先我们先验证升级之前的消息是否能查询到,那我们还是查找key 为 m600000 的消息,查找结果如图所示: 然后我们来测试一下消息发送。测试代码如下: public class Producer { public static void main(String[] args) throws MQClientException, InterruptedException { DefaultMQProducer producer = new DefaultMQProducer("producer_dw_test"); producer.setNamesrvAddr("192.168.0.220:9876;192.168.0.221:9876"); producer.start(); for(int i =600200; i < 600300; i ++) { try { Message msg = new Message("topic_dw_test_by_order_01",null , "m" + i,("Hello RocketMQ" + i ).getBytes(RemotingHelper.DEFAULT_CHARSET)); SendResult sendResult = producer.send(msg); System.out.printf("%s%n", sendResult); } catch (Exception e) { e.printStackTrace(); Thread.sleep(1000); } } producer.shutdown(); System.out.println("end"); } } 执行结果如下:再去控制台查询一下消息,其结果也表明新的消息也能查询到。最后我们再来验证一下主节点宕机,消息发送是否会受影响。 在消息发送的过程中,去关闭主节点,其截图如下:再来看一下集群的状态: 等待该复制组重新完成主服务器选举后,即可继续处理消息发送。 温馨提示:由于本示例是一主一从,故在选举期间,消息不可用,但在真实的生产环境上,其部署架构是多主主从,即一个复制组在 leader 选举期间,其他复制组可以接替该复制组完成消息的发送,实现消息服务的高可用。 与 DLedger 相关的日志,默认存储在 broker_default.log 文件中。 4、源码分析 RocketMQ 系列文章 1、RocketMQ 多副本前置篇:初探raft协议2、源码分析 RocketMQ DLedger 多副本之 Leader 选主3、源码分析 RocketMQ DLedger 多副本存储实现4、源码分析 RocketMQ DLedger(多副本) 之日志追加流程5、源码分析 RocketMQ DLedger(多副本) 之日志复制(传播)6、基于 raft 协议的 RocketMQ DLedger 多副本日志复制设计原理7、RocketMQ 整合 DLedger(多副本)即主从切换实现平滑升级的设计技巧8、源码分析 RocketMQ DLedger 多副本即主从切换实现原理 原文发布时间为:2019-10-13本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
DLedger 基于 raft 协议,故天然支持主从切换,即主节点(Leader)发生故障,会重新触发选主,在集群内再选举出新的主节点。 RocketMQ 中主从同步,从节点不仅会从主节点同步数据,也会同步元数据,包含 topic 路由信息、消费进度、延迟队列处理队列、消费组订阅配置等信息。那主从切换后元数据如何同步呢?特别是主从切换过程中,对消息消费有多大的影响,会丢失消息吗? 温馨提示:本文假设大家已经对 RocketMQ4.5 版本之前的主从同步实现有一定的了解,这部分内容在《RocketMQ技术内幕》一书中有详细的介绍,大家也可以参考如下两篇文章:1、 RocketMQ HA机制(主从同步) 。2、RocketMQ 整合 DLedger(多副本)即主从切换实现平滑升级的设计技巧 1、BrokerController 中与主从相关的方法详解 本节先对 BrokerController 中与主从切换相关的方法。 1.1 startProcessorByHa BrokerController#startProcessorByHa private void startProcessorByHa(BrokerRole role) { if (BrokerRole.SLAVE != role) { if (this.transactionalMessageCheckService != null) { this.transactionalMessageCheckService.start(); } } } 感觉该方法的取名较为随意,该方法的作用是开启事务状态回查处理器,即当节点为主节点时,开启对应的事务状态回查处理器,对PREPARE状态的消息发起事务状态回查请求。 1.2 shutdownProcessorByHa BrokerController#shutdownProcessorByHa private void shutdownProcessorByHa() { if (this.transactionalMessageCheckService != null) { this.transactionalMessageCheckService.shutdown(true); } } 关闭事务状态回查处理器,当节点从主节点变更为从节点后,该方法被调用。 1.3 handleSlaveSynchronize BrokerController#handleSlaveSynchronize private void handleSlaveSynchronize(BrokerRole role) { if (role == BrokerRole.SLAVE) { // @1 if (null != slaveSyncFuture) { slaveSyncFuture.cancel(false); } this.slaveSynchronize.setMasterAddr(null); // slaveSyncFuture = this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() { @Override public void run() { try { BrokerController.this.slaveSynchronize.syncAll(); } catch (Throwable e) { log.error("ScheduledTask SlaveSynchronize syncAll error.", e); } } }, 1000 * 3, 1000 * 10, TimeUnit.MILLISECONDS); } else { // @2 //handle the slave synchronise if (null != slaveSyncFuture) { slaveSyncFuture.cancel(false); } this.slaveSynchronize.setMasterAddr(null); } } 该方法的主要作用是处理从节点的元数据同步,即从节点向主节点主动同步 topic 的路由信息、消费进度、延迟队列处理队列、消费组订阅配置等信息。 代码@1:如果当前节点的角色为从节点: 如果上次同步的 future 不为空,则首先先取消。 然后设置 slaveSynchronize 的 master 地址为空。不知大家是否与笔者一样,有一个疑问,从节点的时候,如果将 master 地址设置为空,那如何同步元数据,那这个值会在什么时候设置呢? 开启定时同步任务,每 10s 从主节点同步一次元数据。 代码@2:如果当前节点的角色为主节点,则取消定时同步任务并设置 master 的地址为空。 1.4 changeToSlave BrokerController#changeToSlave public void changeToSlave(int brokerId) { log.info("Begin to change to slave brokerName={} brokerId={}", brokerConfig.getBrokerName(), brokerId); //change the role brokerConfig.setBrokerId(brokerId == 0 ? 1 : brokerId); //TO DO check // @1 messageStoreConfig.setBrokerRole(BrokerRole.SLAVE); // @2 //handle the scheduled service try { this.messageStore.handleScheduleMessageService(BrokerRole.SLAVE); // @3 } catch (Throwable t) { log.error("[MONITOR] handleScheduleMessageService failed when changing to slave", t); } //handle the transactional service try { this.shutdownProcessorByHa(); // @4 } catch (Throwable t) { log.error("[MONITOR] shutdownProcessorByHa failed when changing to slave", t); } //handle the slave synchronise handleSlaveSynchronize(BrokerRole.SLAVE); // @5 try { this.registerBrokerAll(true, true, brokerConfig.isForceRegister()); // @6 } catch (Throwable ignored) { } log.info("Finish to change to slave brokerName={} brokerId={}", brokerConfig.getBrokerName(), brokerId); } Broker 状态变更为从节点。其关键实现如下: 设置 brokerId,如果broker的id为0,则设置为1,这里在使用的时候,注意规划好集群内节点的 brokerId。 设置 broker 的状态为 BrokerRole.SLAVE。 如果是从节点,则关闭定时调度线程(处理 RocketMQ 延迟队列),如果是主节点,则启动该线程。 关闭事务状态回查处理器。 从节点需要启动元数据同步处理器,即启动 SlaveSynchronize 定时从主服务器同步元数据。 立即向集群内所有的 nameserver 告知 broker 信息状态的变更。 1.5 changeToMaster BrokerController#changeToMaster public void changeToMaster(BrokerRole role) { if (role == BrokerRole.SLAVE) { return; } log.info("Begin to change to master brokerName={}", brokerConfig.getBrokerName()); //handle the slave synchronise handleSlaveSynchronize(role); // @1 //handle the scheduled service try { this.messageStore.handleScheduleMessageService(role); // @2 } catch (Throwable t) { log.error("[MONITOR] handleScheduleMessageService failed when changing to master", t); } //handle the transactional service try { this.startProcessorByHa(BrokerRole.SYNC_MASTER); // @3 } catch (Throwable t) { log.error("[MONITOR] startProcessorByHa failed when changing to master", t); } //if the operations above are totally successful, we change to master brokerConfig.setBrokerId(0); //TO DO check // @4 messageStoreConfig.setBrokerRole(role); try { this.registerBrokerAll(true, true, brokerConfig.isForceRegister()); // @5 } catch (Throwable ignored) { } log.info("Finish to change to master brokerName={}", brokerConfig.getBrokerName()); } 该方法是 Broker 角色从从节点变更为主节点的处理逻辑,其实现要点如下: 关闭元数据同步器,因为主节点无需同步。 开启定时任务处理线程。 开启事务状态回查处理线程。 设置 brokerId 为 0。 向 nameserver 立即发送心跳包以便告知 broker 服务器当前最新的状态。 主从节点状态变更的核心方法就介绍到这里了,接下来看看如何触发主从切换。 2、如何触发主从切换 从前面的文章我们可以得知,RocketMQ DLedger 是基于 raft 协议实现的,在该协议中就实现了主节点的选举与主节点失效后集群会自动进行重新选举,经过协商投票产生新的主节点,从而实现高可用。 BrokerController#initialize if (messageStoreConfig.isEnableDLegerCommitLog()) { DLedgerRoleChangeHandler roleChangeHandler = new DLedgerRoleChangeHandler(this, (DefaultMessageStore) messageStore); ((DLedgerCommitLog)((DefaultMessageStore) messageStore).getCommitLog()).getdLedgerServer().getdLedgerLeaderElector().addRoleChangeHandler(roleChangeHandler); } 上述代码片段截取自 BrokerController 的 initialize 方法,我们可以得知在 Broker 启动时,如果开启了 多副本机制,即 enableDLedgerCommitLog 参数设置为 true,会为 集群节点选主器添加 roleChangeHandler 事件处理器,即节点发送变更后的事件处理器。 接下来我们将重点探讨 DLedgerRoleChangeHandler 。 2.1 类图 DLedgerRoleChangeHandler 继承自 RoleChangeHandler,即节点状态发生变更后的事件处理器。上述的属性都很简单,在这里就重点介绍一下 ExecutorService executorService,事件处理线程池,但只会开启一个线程,故事件将一个一个按顺序执行。 接下来我们来重点看一下 handle 方法的执行。 2.2 handle 主从状态切换处理逻辑 DLedgerRoleChangeHandler#handle public void handle(long term, MemberState.Role role) { Runnable runnable = new Runnable() { public void run() { long start = System.currentTimeMillis(); try { boolean succ = true; log.info("Begin handling broker role change term={} role={} currStoreRole={}", term, role, messageStore.getMessageStoreConfig().getBrokerRole()); switch (role) { case CANDIDATE: // @1 if (messageStore.getMessageStoreConfig().getBrokerRole() != BrokerRole.SLAVE) { brokerController.changeToSlave(dLedgerCommitLog.getId()); } break; case FOLLOWER: // @2 brokerController.changeToSlave(dLedgerCommitLog.getId()); break; case LEADER: // @3 while (true) { if (!dLegerServer.getMemberState().isLeader()) { succ = false; break; } if (dLegerServer.getdLedgerStore().getLedgerEndIndex() == -1) { break; } if (dLegerServer.getdLedgerStore().getLedgerEndIndex() == dLegerServer.getdLedgerStore().getCommittedIndex() && messageStore.dispatchBehindBytes() == 0) { break; } Thread.sleep(100); } if (succ) { messageStore.recoverTopicQueueTable(); brokerController.changeToMaster(BrokerRole.SYNC_MASTER); } break; default: break; } log.info("Finish handling broker role change succ={} term={} role={} currStoreRole={} cost={}", succ, term, role, messageStore.getMessageStoreConfig().getBrokerRole(), DLedgerUtils.elapsed(start)); } catch (Throwable t) { log.info("[MONITOR]Failed handling broker role change term={} role={} currStoreRole={} cost={}", term, role, messageStore.getMessageStoreConfig().getBrokerRole(), DLedgerUtils.elapsed(start), t); } } }; executorService.submit(runnable); } 代码@1:如果当前节点状态机状态为 CANDIDATE,表示正在发起 Leader 节点,如果该服务器的角色不是 SLAVE 的话,需要将状态切换为 SLAVE。 代码@2:如果当前节点状态机状态为 FOLLOWER,broker 节点将转换为 从节点。 代码@3:如果当前节点状态机状态为 Leader,说明该节点被选举为 Leader,在切换到 Master 节点之前,首先需要等待当前节点追加的数据都已经被提交后才可以将状态变更为 Master,其关键实现如下: 如果 ledgerEndIndex 为 -1,表示当前节点还未又数据转发,直接跳出循环,无需等待。 如果 ledgerEndIndex 不为 -1 ,则必须等待数据都已提交,即 ledgerEndIndex 与 committedIndex 相等。 并且需要等待 commitlog 日志全部已转发到 consumequeue中,即 ReputMessageService 中的 reputFromOffset 与 commitlog 的 maxOffset 相等。 等待上述条件满足后,即可以进行状态的变更,需要恢复 ConsumeQueue,维护每一个 queue 对应的 maxOffset,然后将 broker 角色转变为 master。 经过上面的步骤,就能实时完成 broker 主节点的自动切换。由于单从代码的角度来看主从切换不够直观,下面我将给出主从切换的流程图。 2.3 主从切换流程图 由于从源码的角度或许不够直观,故本节给出其流程图。 温馨提示:该流程图的前半部分在 源码分析 RocketMQ 整合 DLedger(多副本)实现平滑升级的设计技巧 该文中有所阐述。 3、主从切换若干问题思考 我相信经过上面的讲解,大家应该对主从切换的实现原理有了一个比较清晰的理解,我更相信读者朋友们会抛出一个疑问,主从切换会不会丢失消息,消息消费进度是否会丢失而导致重复消费呢? 3.1 消息消费进度是否存在丢失风险 首先,由于 RocketMQ 元数据,当然也包含消息消费进度的同步是采用的从服务器定时向主服务器拉取进行更新,存在时延,引入 DLedger 机制,也并不保证其一致性,DLedger 只保证 commitlog 文件的一致性。 当主节点宕机后,各个从节点并不会完成同步了消息消费进度,于此同时,消息消费继续,此时消费者会继续从从节点拉取消息进行消费,但汇报的从节点并不一定会成为新的主节点,故消费进度在 broker 端存在丢失的可能性。当然并不是一定会丢失,因为消息消费端只要不重启,消息消费进度会存储在内存中。 综合所述,消息消费进度在 broker 端会有丢失的可能性,存在重复消费的可能性,不过问题不大,因为 RocketMQ 本身也不承若不会重复消费。 3.2 消息是否存在丢失风险 消息会不会丢失的关键在于,日志复制进度较慢的从节点是否可以被选举为主节点,如果在一个集群中,从节点的复制进度落后与从主节点,但当主节点宕机后,如果该从节点被选举成为新的主节点,那这将是一个灾难,将会丢失数据。关于一个节点是否给另外一个节点投赞成票的逻辑在 源码分析 RocketMQ DLedger 多副本之 Leader 选主 的 2.4.2 handleVote 方法中已详细介绍,在这里我以截图的方式再展示其核心点:从上面可以得知,如果发起投票节点的复制进度比自己小的话,会投拒绝票。其必须得到集群内超过半数节点认可,即最终选举出来的主节点的当前复制进度一定是比绝大多数的从节点要大,并且也会等于承偌给客户端的已提交偏移量。故得出的结论是不会丢消息。 本文的介绍就到此为止了,最后抛出一个思考题与大家相互交流学习,也算是对 DLedger 多副本即主从切换一个总结回顾。答案我会以留言的方式或在下一篇文章中给出。 4、思考题 例如一个集群内有5个节点的 DLedgr 集群。Leader Node: n0-broker-afolloer Node: n1-broker-a,n2-broker-a,n3-broker-a,n4-broker-a 从节点的复制进度可能不一致,例如:n1-broker-a复制进度为 100n2-broker-a复制进度为 120n3-broker-a复制进度为 90n4-broker-a负载进度为 90 如果此时 n0-broker-a 节点宕机,触发选主,如果 n1率先发起投票,由于 n1,的复制进度大于 n3,n4,再加上自己一票,是有可能成为leader的,此时消息会丢失吗?为什么? 原文发布时间为:2019-10-04本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
源码分析 RocketMQ DLedger 多副本系列已经进行到第 8 篇了,前面的章节主要是介绍了基于 raft 协议的选主与日志复制,从本篇开始将开始关注如何将 DLedger 应用到 RocketMQ中。 摘要:详细分析了RocketMQ DLedger 多副本(主从切换) 是如何整合到 RocketMQ中,本文的行文思路首先结合已掌握的DLedger 多副本相关的知识初步思考其实现思路,然后从 Broker启动流程、DLedgerCommitlog 核心类的讲解,再从消息发送(追加)与消息查找来进一步探讨 DLedger 是如何支持平滑升级的。 1、阅读源码之前的思考 RocketMQ 的消息存储文件主要包括 commitlog 文件、consumequeue 文件与 Index 文件。commitlog 文件存储全量的消息,consumequeue、index 文件都是基于 commitlog 文件构建的。要使用 DLedger 来实现消息存储的一致性,应该关键是要实现 commitlog 文件的一致性,即 DLedger 要整合的对象应该是 commitlog 文件,即只需保证 raft 协议的复制组内各个节点的 commitlog 文件一致即可。 我们知道使用文件存储消息都会基于一定的存储格式,rocketmq 的 commitlog 一个条目就包含魔数、消息长度,消息属性、消息体等,而我们再来回顾一下 DLedger 日志的存储格式:DLedger 要整合 commitlog 文件,是不是可以把 rocketmq 消息,即一个个 commitlog 条目整体当成 DLedger 的 body 字段即可。 还等什么,跟我一起来看源码吧!!!别急,再抛一个问题,DLedger 整合 RocketMQ commitlog,能不能做到平滑升级? 带着这些思考和问题,一起来探究 DLedger 是如何整合 RocketMQ 的。 2、从 Broker 启动流程看 DLedger 温馨提示:本文不会详细介绍 Broker 端的启动流程,只会点出在启动过程中与 DLedger 相关的代码,如想详细了解 Broker 的启动流程,建议关注笔者的《RocketMQ技术内幕》一书。 Broker 涉及到 DLedger 相关关键点如下: 2.1 构建 DefaultMessageStore DefaultMessageStore 构造方法 if(messageStoreConfig.isEnableDLegerCommitLog()) { // @1 this.commitLog = new DLedgerCommitLog(this); else { this.commitLog = new CommitLog(this); // @2 } 代码@1:如果开启 DLedger ,commitlog 的实现类为 DLedgerCommitLog,也是本文需要关注的关键所在。 代码@2:如果未开启 DLedger,则使用旧版的 Commitlog实现类。 2.2 增加节点状态变更事件监听器 BrokerController#initialize if (messageStoreConfig.isEnableDLegerCommitLog()) { DLedgerRoleChangeHandler roleChangeHandler = new DLedgerRoleChangeHandler(this, (DefaultMessageStore) messageStore); ((DLedgerCommitLog)((DefaultMessageStore) messageStore).getCommitLog()).getdLedgerServer().getdLedgerLeaderElector().addRoleChangeHandler(roleChangeHandler); } 主要调用 LedgerLeaderElector 的 addRoleChanneHandler 方法增加 节点角色变更事件监听器,DLedgerRoleChangeHandler 是实现主从切换的另外一个关键点。 2.3 调用 DefaultMessageStore 的 load 方法 DefaultMessageStore#load // load Commit Log result = result && this.commitLog.load(); // @1 // load Consume Queue result = result && this.loadConsumeQueue(); if (result) { this.storeCheckpoint = new StoreCheckpoint(StorePathConfigHelper.getStoreCheckpoint(this.messageStoreConfig.getStorePathRootDir())); this.indexService.load(lastExitOK); this.recover(lastExitOK); // @2 log.info("load over, and the max phy offset = {}", this.getMaxPhyOffset()); } 代码@1、@2 最终都是委托 commitlog 对象来执行,这里的关键又是如果开启了 DLedger,则最终调用的是 DLedgerCommitLog。 经过上面的铺垫,主角 DLedgerCommitLog “闪亮登场“了。 3、DLedgerCommitLog 详解 温馨提示:由于 Commitlog 的绝大部分方法都已经在《RocketMQ技术内幕》一书中详细介绍了,并且 DLedgerCommitLog 的实现原理与 Commitlog 文件的实现原理类同,本文会一笔带过关于存储部分的实现细节。 3.1 核心类图 DLedgerCommitlog 继承自 Commitlog。让我们一一来看一下它的核心属性。 DLedgerServer dLedgerServer基于 raft 协议实现的集群内的一个节点,用 DLedgerServer 实例表示。 DLedgerConfig dLedgerConfigDLedger 的配置信息。 DLedgerMmapFileStore dLedgerFileStoreDLedger 基于文件映射的存储实现。 MmapFileList dLedgerFileListDLedger 所管理的存储文件集合,对比 RocketMQ 中的 MappedFileQueue。 int id节点ID,0 表示主节点,非0表示从节点 MessageSerializer messageSerializer消息序列器。 long beginTimeInDledgerLock = 0用于记录 消息追加的时耗(日志追加所持有锁时间)。 long dividedCommitlogOffset = -1记录的旧 commitlog 文件中的最大偏移量,如果访问的偏移量大于它,则访问 dledger 管理的文件。 boolean isInrecoveringOldCommitlog = false是否正在恢复旧的 commitlog 文件。 接下来我们将详细介绍 DLedgerCommitlog 各个核心方法及其实现要点。 3.2 构造方法 public DLedgerCommitLog(final DefaultMessageStore defaultMessageStore) { super(defaultMessageStore); // @1 dLedgerConfig = new DLedgerConfig(); dLedgerConfig.setEnableDiskForceClean(defaultMessageStore.getMessageStoreConfig().isCleanFileForciblyEnable()); dLedgerConfig.setStoreType(DLedgerConfig.FILE); dLedgerConfig.setSelfId(defaultMessageStore.getMessageStoreConfig().getdLegerSelfId()); dLedgerConfig.setGroup(defaultMessageStore.getMessageStoreConfig().getdLegerGroup()); dLedgerConfig.setPeers(defaultMessageStore.getMessageStoreConfig().getdLegerPeers()); dLedgerConfig.setStoreBaseDir(defaultMessageStore.getMessageStoreConfig().getStorePathRootDir()); dLedgerConfig.setMappedFileSizeForEntryData(defaultMessageStore.getMessageStoreConfig().getMapedFileSizeCommitLog()); dLedgerConfig.setDeleteWhen(defaultMessageStore.getMessageStoreConfig().getDeleteWhen()); dLedgerConfig.setFileReservedHours(defaultMessageStore.getMessageStoreConfig().getFileReservedTime() + 1); id = Integer.valueOf(dLedgerConfig.getSelfId().substring(1)) + 1; // @2 dLedgerServer = new DLedgerServer(dLedgerConfig); // @3 dLedgerFileStore = (DLedgerMmapFileStore) dLedgerServer.getdLedgerStore(); DLedgerMmapFileStore.AppendHook appendHook = (entry, buffer, bodyOffset) -> { assert bodyOffset == DLedgerEntry.BODY_OFFSET; buffer.position(buffer.position() + bodyOffset + MessageDecoder.PHY_POS_POSITION); buffer.putLong(entry.getPos() + bodyOffset); }; dLedgerFileStore.addAppendHook(appendHook); // @4 dLedgerFileList = dLedgerFileStore.getDataFileList(); this.messageSerializer = new MessageSerializer(defaultMessageStore.getMessageStoreConfig().getMaxMessageSize()); // @5 } 代码@1:调用父类 即 CommitLog 的构造函数,加载 ${ROCKETMQ_HOME}/store/ comitlog 下的 commitlog 文件,以便兼容升级 DLedger 的消息。我们稍微看一下 CommitLog 的构造函数:代码@2:构建 DLedgerConfig 相关配置属性,其主要属性如下: enableDiskForceClean是否强制删除文件,取自 broker 配置属性 cleanFileForciblyEnable,默认为 true 。 storeTypeDLedger 存储类型,固定为 基于文件的存储模式。 dLegerSelfId 节点的 id 名称,示例配置:n0,其配置要求第二个字符后必须是数字。 dLegerGroupDLeger group 的名称,建议与 broker 配置属性 brokerName 保持一致。 dLegerPeersDLeger Group 中所有的节点信息,其配置示例 n0-127.0.0.1:40911;n1-127.0.0.1:40912;n2-127.0.0.1:40913。多个节点使用分号隔开。 storeBaseDir设置 DLedger 的日志文件的根目录,取自 borker 配件文件中的 storePathRootDir ,即 RocketMQ 的数据存储根路径。 mappedFileSizeForEntryData设置 DLedger 的单个日志文件的大小,取自 broker 配置文件中的 - mapedFileSizeCommitLog,即与 commitlog 文件的单个文件大小一致。 deleteWhenDLedger 日志文件的删除时间,取自 broker 配置文件中的 deleteWhen,默认为凌晨 4点。 fileReservedHoursDLedger 日志文件保留时长,取自 broker 配置文件中的 fileReservedHours,默认为 72h。 代码@3:根据 DLedger 配置信息创建 DLedgerServer,即创建 DLedger 集群节点,集群内各个节点启动后,就会触发选主。 代码@4:构建 appendHook 追加钩子函数,这是兼容 Commitlog 文件很关键的一步,后面会详细介绍其作用。 代码@5:构建消息序列化。 根据上述的流程图,构建好 DefaultMessageStore 实现后,就是调用其 load 方法,在启用 DLedger 机制后,会依次调用 DLedgerCommitlog 的 load、recover 方法。 3.3 load public boolean load() { boolean result = super.load(); if (!result) { return false; } return true; } DLedgerCommitLog 的 laod 方法实现比较简单,就是调用 其父类 Commitlog 的 load 方法,即这里也是为了启用 DLedger 时能够兼容以前的消息。 3.4 recover 在 Broker 启动时会加载 commitlog、consumequeue等文件,需要恢复其相关是数据结构,特别是与写入、刷盘、提交等指针,其具体调用 recover 方法。DLedgerCommitLog#recover public void recoverNormally(long maxPhyOffsetOfConsumeQueue) { // @1 recover(maxPhyOffsetOfConsumeQueue); } 首先会先恢复 consumequeue,得出 consumequeue 中记录的最大有效物理偏移量,然后根据该物理偏移量进行恢复。接下来看一下该方法的处理流程与关键点。 DLedgerCommitLog#recover dLedgerFileStore.load(); Step1:加载 DLedger 相关的存储文件,并一一构建对应的 MmapFile,其初始化三个重要的指针 wrotePosition、flushedPosition、committedPosition 三个指针为文件的大小。 DLedgerCommitLog#recover if (dLedgerFileList.getMappedFiles().size() > 0) { dLedgerFileStore.recover(); // @1 dividedCommitlogOffset = dLedgerFileList.getFirstMappedFile().getFileFromOffset(); // @2 MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile(); if (mappedFile != null) { // @3 disableDeleteDledger(); } long maxPhyOffset = dLedgerFileList.getMaxWrotePosition(); // Clear ConsumeQueue redundant data if (maxPhyOffsetOfConsumeQueue >= maxPhyOffset) { // @4 log.warn("[TruncateCQ]maxPhyOffsetOfConsumeQueue({}) >= processOffset({}), truncate dirty logic files", maxPhyOffsetOfConsumeQueue, maxPhyOffset); this.defaultMessageStore.truncateDirtyLogicFiles(maxPhyOffset); } return; } Step2:如果已存在 DLedger 的数据文件,则只需要恢复 DLedger 相关数据文建,因为在加载旧的 commitlog 文件时已经将其重要的数据指针设置为最大值。其关键实现点如下: 首先调用 DLedger 文件存储实现类 DLedgerFileStore 的 recover 方法,恢复管辖的 MMapFile 对象(一个文件对应一个MMapFile实例)的相关指针,其实现方法与 RocketMQ 的 DefaultMessageStore 的恢复过程类似。 设置 dividedCommitlogOffset 的值为 DLedger 中所有物理文件的最小偏移量。操作消息的物理偏移量小于该值,则从 commitlog 文件中查找;物理偏移量大于等于该值的话则从 DLedger 相关的文件中查找消息。 如果存在旧的 commitlog 文件,则禁止删除 DLedger 文件,其具体做法就是禁止强制删除文件,并将文件的有效存储时间设置为 10 年。 如果 consumequeue 中存储的最大物理偏移量大于 DLedger 中最大的物理偏移量,则删除多余的 consumequeue 文件。 温馨提示:为什么当存在 commitlog 文件的情况下,不能删除 DLedger 相关的日志文件呢? 因为在此种情况下,如果 DLedger 中的物理文件有删除,则物理偏移量会断层。正常情况下, maxCommitlogPhyOffset 与 dividedCommitlogOffset 是连续的,这样非常方便是访问 commitlog 还是 访问 DLedger ,但如果DLedger 部分文件删除后,这两个值就变的不连续,就会造成中间的文件空洞,无法被连续访问。 DLedgerCommitLog#recover isInrecoveringOldCommitlog = true; super.recoverNormally(maxPhyOffsetOfConsumeQueue); isInrecoveringOldCommitlog = false; Step3:如果启用了 DLedger 并且是初次启动(还未生成 DLedger 相关的日志文件),则需要恢复 旧的 commitlog 文件。 DLedgerCommitLog#recover MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile(); if (mappedFile == null) { // @1 return; } ByteBuffer byteBuffer = mappedFile.sliceByteBuffer(); byteBuffer.position(mappedFile.getWrotePosition()); boolean needWriteMagicCode = true; // 1 TOTAL SIZE byteBuffer.getInt(); //size int magicCode = byteBuffer.getInt(); if (magicCode == CommitLog.BLANK_MAGIC_CODE) { // @2 needWriteMagicCode = false; } else { log.info("Recover old commitlog found a illegal magic code={}", magicCode); } dLedgerConfig.setEnableDiskForceClean(false); dividedCommitlogOffset = mappedFile.getFileFromOffset() + mappedFile.getFileSize(); // @3 log.info("Recover old commitlog needWriteMagicCode={} pos={} file={} dividedCommitlogOffset={}", needWriteMagicCode, mappedFile.getFileFromOffset() + mappedFile.getWrotePosition(), mappedFile.getFileName(), dividedCommitlogOffset); if (needWriteMagicCode) { // @4 byteBuffer.position(mappedFile.getWrotePosition()); byteBuffer.putInt(mappedFile.getFileSize() - mappedFile.getWrotePosition()); byteBuffer.putInt(BLANK_MAGIC_CODE); mappedFile.flush(0); } mappedFile.setWrotePosition(mappedFile.getFileSize()); // @5 mappedFile.setCommittedPosition(mappedFile.getFileSize()); mappedFile.setFlushedPosition(mappedFile.getFileSize()); dLedgerFileList.getLastMappedFile(dividedCommitlogOffset); log.info("Will set the initial commitlog offset={} for dledger", dividedCommitlogOffset); } Step4:如果存在旧的 commitlog 文件,需要将最后的文件剩余部分全部填充,即不再接受新的数据写入,新的数据全部写入到 DLedger 的数据文件中。其关键实现点如下: 尝试查找最后一个 commitlog 文件,如果未找到,则结束。 从最后一个文件的最后写入点(原 commitlog 文件的 待写入位点)尝试去查找写入的魔数,如果存在魔数并等于 CommitLog.BLANK_MAGIC_CODE,则无需再写入魔数,在升级 DLedger 第一次启动时,魔数为空,故需要写入魔数。 初始化 dividedCommitlogOffset ,等于最后一个文件的起始偏移量加上文件的大小,即该指针指向最后一个文件的结束位置。 将最后一个 commitlog 未写满的数据全部写入,其方法为 设置消息体的 size 与 魔数即可。 设置最后一个文件的 wrotePosition、flushedPosition、committedPosition 为文件的大小,同样有意味者最后一个文件已经写满,下一条消息将写入 DLedger 中。 在启用 DLedger 机制时 Broker 的启动流程就介绍到这里了,相信大家已经了解 DLedger 在整合 RocketMQ 上做的努力,接下来我们从消息追加、消息读取两个方面再来探讨 DLedger 是如何无缝整合 RocketMQ 的,实现平滑升级的。 4、从消息追加看 DLedger 整合 RocketMQ 如何实现无缝兼容 温馨提示:本节同样也不会详细介绍整个消息追加(存储流程),只是要点出与 DLedger(多副本、主从切换)相关的核心关键点。如果想详细了解消息追加的流程,可以阅读笔者所著的《RocketMQ技术内幕》一书。 DLedgerCommitLog#putMessage AppendEntryRequest request = new AppendEntryRequest(); request.setGroup(dLedgerConfig.getGroup()); request.setRemoteId(dLedgerServer.getMemberState().getSelfId()); request.setBody(encodeResult.data); dledgerFuture = (AppendFuture<AppendEntryResponse>) dLedgerServer.handleAppend(request); if (dledgerFuture.getPos() == -1) { return new PutMessageResult(PutMessageStatus.OS_PAGECACHE_BUSY, new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR)); } 关键点一:消息追加时,则不再写入到原先的 commitlog 文件中,而是调用 DLedgerServer 的 handleAppend 进行消息追加,该方法会有集群内的 Leader 节点负责消息追加以及在消息复制,只有超过集群内的半数节点成功写入消息后,才会返回写入成功。如果追加成功,将会返回本次追加成功后的起始偏移量,即 pos 属性,即类似于 rocketmq 中 commitlog 的偏移量,即物理偏移量。 DLedgerCommitLog#putMessage long wroteOffset = dledgerFuture.getPos() + DLedgerEntry.BODY_OFFSET; ByteBuffer buffer = ByteBuffer.allocate(MessageDecoder.MSG_ID_LENGTH); String msgId = MessageDecoder.createMessageId(buffer, msg.getStoreHostBytes(), wroteOffset); eclipseTimeInLock = this.defaultMessageStore.getSystemClock().now() - beginTimeInDledgerLock; appendResult = new AppendMessageResult(AppendMessageStatus.PUT_OK, wroteOffset, encodeResult.data.length, msgId, System.currentTimeMillis(), queueOffset, eclipseTimeInLock); 关键点二:根据 DLedger 的起始偏移量计算真正的消息的物理偏移量,从开头部分得知,DLedger 自身有其存储协议,其 body 字段存储真实的消息,即 commitlog 条目的存储结构,返回给客户端的消息偏移量为 body 字段的开始偏移量,即通过 putMessage 返回的物理偏移量与不使用Dledger 方式返回的物理偏移量的含义是一样的,即从开偏移量开始,可以正确读取消息,这样 DLedger 完美的兼容了 RocketMQ Commitlog。关于 pos 以及 wroteOffset 的图解如下: 5、从消息读取看 DLedger 整合 RocketMQ 如何实现无缝兼容 DLedgerCommitLog#getMessage public SelectMappedBufferResult getMessage(final long offset, final int size) { if (offset < dividedCommitlogOffset) { // @1 return super.getMessage(offset, size); } int mappedFileSize = this.dLedgerServer.getdLedgerConfig().getMappedFileSizeForEntryData(); MmapFile mappedFile = this.dLedgerFileList.findMappedFileByOffset(offset, offset == 0); // @2 if (mappedFile != null) { int pos = (int) (offset % mappedFileSize); return convertSbr(mappedFile.selectMappedBuffer(pos, size)); // @3 } return null; } 消息查找比较简单,因为返回给客户端消息,转发给 consumequeue 的消息物理偏移量并不是 DLedger 条目的偏移量,而是真实消息的起始偏移量。其实现关键点如下: 如果查找的物理偏移量小于 dividedCommitlogOffset,则从原先的 commitlog 文件中查找。 然后根据物理偏移量按照二分方找到具体的物理文件。 对物理偏移量取模,得出在该物理文件中中的绝对偏移量,进行消息查找即可,因为只有知道其物理偏移量,从该处先将消息的长度读取出来,然后即可读出一条完整的消息。 5、总结 根据上面详细的介绍,我想读者朋友们应该不难得出如下结论: DLedger 在整合时,使用 DLedger 条目包裹 RocketMQ 中的 commitlog 条目,即在 DLedger 条目的 body 字段来存储整条 commitlog 条目。 引入 dividedCommitlogOffset 变量,表示物理偏移量小于该值的消息存在于旧的 commitlog 文件中,实现 升级 DLedger 集群后能访问到旧的数据。 新 DLedger 集群启动后,会将最后一个 commitlog 填充,即新的数据不会再写入到 原先的 commitlog 文件。 消息追加到 DLedger 数据日志文件中,返回的偏移量不是 DLedger 条目的起始偏移量,而是DLedger 条目中 body 字段的起始偏移量,即真实消息的起始偏移量,保证消息物理偏移量的语义与 RocketMQ Commitlog一样。 RocketMQ 整合 DLedger(多副本)实现平滑升级的设计技巧就介绍到这里了。 推荐阅读:1、RocketMQ 多副本前置篇:初探raft协议2、源码分析RocketMQ多副本之Leader选主3、源码分析 RocketMQ DLedger 多副本存储实现4、源码分析 RocketMQ DLedger(多副本) 之日志追加流程5、源码分析 RocketMQ DLedger(多副本) 之日志复制-上篇6、源码分析 RocketMQ DLedger(多副本) 之日志复制-下篇7、基于 raft 协议的 RocketMQ DLedger 多副本日志复制设计原理8、RocketMQ 整合 DLedger(多副本)即主从切换实现平滑升级的设计技巧 原文发布时间为:2019-10-02本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
上一篇 源码分析 RocketMQ DLedger(多副本) 之日志复制(传播) ,可能有不少读者朋友们觉得源码阅读较为枯燥,看的有点云里雾里,本篇将首先梳理一下 RocketMQ DLedger 多副本关于日志复制的三个核心流程图,然后再思考一下在异常情况下如何保证数据一致性。 1、RocketMQ DLedger 多副本日志复制流程图 1.1 RocketMQ DLedger 日志转发(append) 请求流程图 1.2 RocketMQ DLedger 日志仲裁流程图 1.3 RocketMQ DLedger 从节点日志复制流程图 2、RocketMQ DLedger 多副本日志复制实现要点 上图是一个简易的日志复制的模型:图中客户端向 DLedger 集群发起一个写请求,集群中的 Leader 节点来处理写请求,首先数据先存入 Leader 节点,然后需要广播给它的所有从节点,从节点接收到 Leader 节点的数据推送对数据进行存储,然后向主节点汇报存储的结果,Leader 节点会对该日志的存储结果进行仲裁,如果超过集群数量的一半都成功存储了该数据,主节点则向客户端返回写入成功,否则向客户端写入写入失败。 接下来我们来探讨日志复制的核心设计要点。 2.1 日志编号 为了方便对日志进行管理与辨别,raft 协议为一条一条的消息进行编号,每一条消息达到主节点时会生成一个全局唯一的递增号,这样可以根据日志序号来快速的判断数据在主从复制过程中数据是否一致,在 DLedger 的实现中对应 DLedgerMemoryStore 中的 ledgerBeginIndex、ledgerEndIndex,分别表示当前节点最小的日志序号与最大的日志序号,下一条日志的序号为 ledgerEndIndex + 1 。 与日志序号还与一个概念绑定的比较紧密,即当前的投票轮次。 2.2 追加与提交机制 请思考如下问题,Leader 节点收到客户端的数据写入请求后,通过解析请求,提取数据部分,构建日志对象,并生成日志序号,用 seq 表示,然后存储到 Leader 节点中,然后将日志广播(推送)到其从节点,由于这个过程中存在网络时延,如果此时客户端向主节点查询 seq 的日志,由于日志已经存储在 Leader 节点中了,如果直接返回给客户端显然是有问题的,那该如何来避免这种情况的发生呢? 为了解决上述问题,DLedger 的实现(应该也是 raft 协议的一部分)引入了已提交指针(committedIndex)。即当主节点收到客户端请求时,首先先将数据存储,但此时数据是未提交的,此过程可以称之为追加,此时客户端无法访问,只有当集群内超过半数的节点都将日志追加完成后,才会更新 committedIndex 指针,得以是数据能否客户端访问。 一条日志要能被提交的充分必要条件是日志得到了集群内超过半数节点成功追加,才能被认为已提交。 2.3 日志一致性如何保证 从上文得知,一个拥有3个节点的 DLedger 集群,只要主节点和其中一个从节点成功追加日志,则认为已提交,客户端即可通过主节点访问。由于部分数据存在延迟,在 DLedger 的实现中,读写请求都将由 Leader 节点来负责。那落后的从节点如何再次跟上集群的步骤呢? 要重新跟上主节点的日志记录,首先要知道的是如何判断从节点已丢失数据呢? DLedger 的实现思路是,DLedger 会按照日志序号向从节点源源不断的转发日志,从节点接收后将这些待追加的数据放入一个待写队列中。关键中的关键:从节点并不是从挂起队列中处理一个一个的追加请求,而是首先查阅从节点当前已追加的最大日志序号,用 ledgerEndIndex 表示,然后尝试追加 (ledgerEndIndex + 1)的日志,用该序号从代写队列中查找,如果该队列不为空,并且没有 (ledgerEndIndex + 1)的日志条目,说明从节点未接收到这条日志,发生了数据缺失。然后从节点在响应主节点 append 的请求时会告知数据不一致,然后主节点的日志转发线程其状态会变更为COMPARE,将向该从节点发送COMPARE命令,用来比较主从节点的数据差异,根据比较的差异重新从主节点同步数据或删除从节点上多余的数据,最终达到一致。于此同时,主节点也会对PUSH超时推送的消息发起重推,尽最大可能帮助从节点及时更新到主节点的数据。 更多问题,`欢迎大家留言与我一起探讨。如果觉得文章对自己有些用处的话,麻烦帮忙点个赞,谢谢。 推荐阅读:RocketMQ 日志复制系列文章:1、源码分析 RocketMQ DLedger 多副本存储实现2、源码分析 RocketMQ DLedger(多副本) 之日志追加流程3、源码分析 RocketMQ DLedger(多副本) 之日志复制(传播) 原文发布时间为:2019-10-02本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
在任务执行的前后,ElasticJob可以提供扩展,其主要类图如下:ElastciJobListener:elasticJob任务执行事件监听器,提供如下两个方法: void beforeJobExecuted(final ShardingContexts shardingContexts); 在任务执行之前调用,shardingContexts为分片上下文信息。 void afterJobExecuted(final ShardingContexts shardingContexts) 在任务执行之后调用,shardingContexts为分片上下文信息。 上述回调函数是分片级的,也就是说默认情况下,同一个任务的多个分片都会执行beforeJobExecuted、afterJobExecuted方法,如果某些情况同一个任务只需在最后一个分片执行之前执行,最后一个分片执行完成后才执行,又该如何实现呢。AbstractDistributeOnceElasticJobListener粉墨登场。 AbstractDistributeOnceElasticJobListener:在分布式作业中只执行一次的监听器。 private final long startedTimeoutMilliseconds:分片等待beforeJobExecuted方法执行的超时时间,单位为毫秒。 private final Object startedWait = new Object():分片等待beforeJobExecuted的监视器。 private final long completedTimeoutMilliseconds:分片等待afterJobExecuted方法执行的超时时间,单位为毫秒。 private final Object completedWait = new Object():分片等待afterJobExecuted的监视器。 private GuaranteeService guaranteeService:保证分布式任务全部开始和结束状态的服务。 private TimeService timeService = new TimeService():时间服务器,主要用来获取当前服务器的系统时间。 public final void beforeJobExecuted(final ShardingContexts shardingContexts):分片任务执行之前调用,该方法是一个模板方法,最后一个分片成功启动后调用doBeforeJobExecutedAtLastStarted方法,该方法为抽象方法,由具体子类实现,如果有其他分片未执行完成,该方法会阻塞等待,或最后启动的分片执行完doBeforeJobExecutedAtLastStarted方法。 public final void afterJobExecuted(final ShardingContexts shardingContexts):分片任务执行之后调用,该方法是一个模板方法,实现当最后一个分片成功执行完成后调用doAfterJobExecutedAtLastCompleted方法,该方法为抽象方法,由具体子类实现,如果有其他分片未执行完成,该方法会阻塞等待,或最后启动的分片执行完doAfterJobExecutedAtLastCompleted方法。 public abstract void doBeforeJobExecutedAtLastStarted(ShardingContexts shardingContexts):分布式环境中最后一个作业分片执行前的执行的方法。 public abstract void doAfterJobExecutedAtLastCompleted(ShardingContexts shardingContexts):分布式环境中最后一个作业分片执行完成后的执行方法。 public void notifyWaitingTaskStart():通知分片节点上的任务开始之前(唤醒由于还有其他分片未启动造成自身等待阻塞)。 public void notifyWaitingTaskComplete():通知分片节点任务执行完成(唤醒由于存在其他分片任务未执行完成时阻塞)。 接下来重点分析AbstractDistributeOnceElasticJobListener实现原理(分布式环境中,监听器只在一个节点上执行的实现逻辑) 重点分析beforeJobExecuted方法实现原理,afterJobExecuted方法类似。 AbstractDistributeOnceElasticJobListener#beforeJobExecuted public final void beforeJobExecuted(final ShardingContexts shardingContexts) { guaranteeService.registerStart(shardingContexts.getShardingItemParameters().keySet()); // @1 if (guaranteeService.isAllStarted()) { // @2 doBeforeJobExecutedAtLastStarted(shardingContexts); guaranteeService.clearAllStartedInfo(); return; } long before = timeService.getCurrentMillis(); // @3 try { synchronized (startedWait) { startedWait.wait(startedTimeoutMilliseconds); } } catch (final InterruptedException ex) { Thread.interrupted(); } if (timeService.getCurrentMillis() - before >= startedTimeoutMilliseconds) { // @4 guaranteeService.clearAllStartedInfo(); handleTimeout(startedTimeoutMilliseconds); } } 代码@1:使用GuaranteeService注册分片开始。 代码@2:判断该任务所有的分片是否都已经注册启动,如果都注册启动,则调用doBeforeJobExecutedAtLastStarted()方法。 代码@3:获取服务器当前时间。代码@4:利用startWait.wait(startedTimeoutMilliseconds)带超时时间的等待,这里如何唤醒呢?代码@5:判断唤醒是超时唤醒还是正常唤醒,如果是超时唤醒,清除所有的分片注册启动信息,处理超时异常。 上述流程简单明了,上面有两个问题需要进一步探究,如何注册分片启动信息与如何被唤醒。 1、任务节点注册分配给当前节点的任务分片 /** * 根据分片项注册任务开始运行. * * @param shardingItems 待注册的分片项 */ public void registerStart(final Collection<Integer> shardingItems) { for (int each : shardingItems) { jobNodeStorage.createJobNodeIfNeeded(GuaranteeNode.getStartedNode(each)); } } 创建持久节点:${namespace}/jobname/guarantee/started/{item}。 2、当最后一个节点注册启动执行doBeforeJobExecutedAtLastStarted方法后,如果唤醒其他节点以便进入到任务执行阶段 if (guaranteeService.isAllStarted()) { doBeforeJobExecutedAtLastStarted(shardingContexts); guaranteeService.clearAllStartedInfo(); return; } 也就是回调函数执行完毕后,会删除任务所有的分片。温馨提示:业务实现子类实现doBeforeJobExecutedAtLastStarted方法时最好不要抛出异常,不然各节点的唤醒操作只能是等待超时后被唤醒。 GuaranteeService#clearAllStartedInfo /** * 清理所有任务启动信息. */ public void clearAllStartedInfo() { jobNodeStorage.removeJobNodeIfExisted(GuaranteeNode.STARTED_ROOT); } 直接删除${namespace}/jobname/guarantee/started根节点。基于ZK的开发模式,触发一次删除操作,肯定会有事件监听器来监听该节点的删除事件,从而触发其他节点的唤醒操作,果不奇然,ElastciJob提供GuaranteeListenerManager事件监听来监听${namespace}/jobname/guarantee/started节点的删除事件。 GuaranteeListenerManager$StartedNodeRemovedJobListener class StartedNodeRemovedJobListener extends AbstractJobListener { @Override protected void dataChanged(final String path, final Type eventType, final String data) { if (Type.NODE_REMOVED == eventType && guaranteeNode.isStartedRootNode(path)) { for (ElasticJobListener each : elasticJobListeners) { if (each instanceof AbstractDistributeOnceElasticJobListener) { ((AbstractDistributeOnceElasticJobListener) each).notifyWaitingTaskStart(); } } } } } 每个Job实例在监听到${namespace}/jobname/guarantee/started节点被删除后,会执行AbstractDistributeOnceElasticJobListener的notifyWaitingTaskStart方法唤醒被阻塞的线程,是线程进入到任务执行阶段。 同理,任务执行后监听方法afterJobExecuted的执行流程实现原理一样,在这里就不在重复讲解了。 原文发布时间为:2018-12-15本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
任务在调度执行中,由于某种原因未执行完毕,下一次调度任务触发后,在同一个Job实例中,会出现两个线程处理同一个分片上的数据,这样就会造成两个线程可能处理到相同的数据。为了避免同一条数据可能会被多次执行的问题,ElasticJob引入幂等机制,确保同一条数据不会再被多个Job同时处理,也避免同一条数据在同一个Job实例的多个线程处理。再重申一次ElastciJob的分布式是数据的分布式,一个任务在多个Job实例上运行,每个Job实例处理该Job的部分数据(数据分片)。 本文重点分析ElasticJob是如何做到如下两点的。 ElasticJob如何确保在同一个Job实例中多个线程不会处理相同的数据。 ElasticJob如何确保数据不会被多个Job实例处理。为了解决上述这种情况,ElasticJob引入任务错过补偿执行(misfire)与幂等机制(monitorExecution) 1、ElasticJob如何确保在同一个Job实例中多个线程不会处理相同的数据。 场景:例如任务调度周期为每5s执行一次,正常每次调度任务处理需要耗时2s,如果在某一段时间由于数据库压力变大,导致原本只需要2s就能处理完成的任务,现在需要16s才能运行,在这个数据处理的过程中,每5s又会触发一次调度(任务处理),如果不加以控制的话,在同一个实例上根据分片条件去查询数据库,查询到的数据有可能相同(部分相同),这样同一条任务数据将被多次运行,如果这个任务时处理转账业务,如果在业务方法不实现幂等,则会引发非常严重的问题,那ElasticJob是否可以避免这个问题呢? 答案是肯定。elasticJob提供了一个配置参数:monitorExecution=true,开启幂等性。 一个任务触发后,将执行任务处理逻辑,其入口:AbstractElasticJobExecutor#misfireIfRunning if (jobFacade.misfireIfRunning(shardingContexts.getShardingItemParameters().keySet())) { // @1 if (shardingContexts.isAllowSendJobEvent()) { // @2 jobFacade.postJobStatusTraceEvent(shardingContexts.getTaskId(), State.TASK_FINISHED, String.format( "Previous job '%s' - shardingItems '%s' is still running, misfired job will start after previous job completed.", jobName, shardingContexts.getShardingItemParameters().keySet())); } return; } 代码@1:在一个调度任务触发后如果上一次任务还未执行,则需要设置该分片状态为mirefire,表示错失了一次任务执行。 代码@2:如果该分片被设置为mirefire并开启了事件跟踪,将事件跟踪保存在数据库中。 接下来详细分析JobFacade.misfireIfRunning的实现逻辑: /** * 如果当前分片项仍在运行则设置任务被错过执行的标记. * * @param items 需要设置错过执行的任务分片项 * @return 是否错过本次执行 */ public boolean misfireIfHasRunningItems(final Collection<Integer> items) { if (!hasRunningItems(items)) { return false; } setMisfire(items); return true; } 如果存在未完成的分片,则调用setMisfire(items)方法,ElasticJob在开启monitorExecution(true)【幂等机制】机制的情况下,在分片任务开始时会创建${namespace}/jobname/sharding/{item}/running节点,在任务结束后会删除该目录,所以在判断是否有分片正在运行时,只需判断是否存在上述节点即可。如果存在,调用setMisfire方法。 PS:如果ElasticJob为开启幂等(monitorExecution)的情况下,才会创建${namespace}/jobname/sharding /{item}/running,misfire机制才能生效。 ExecutionService#setMisfire /** * 设置任务被错过执行的标记. * * @param items 需要设置错过执行的任务分片项 */ public void setMisfire(final Collection<Integer> items) { for (int each : items) { jobNodeStorage.createJobNodeIfNeeded(ShardingNode.getMisfireNode(each)); } } 设置misfire的方法为分配给该实例下的所有分片创建持久节点${namespace}/jobname/shading/{item}/misfire节点,注意,只要分配给该实例的任何一分片未执行完毕,则在该实例下的所有分片都增加misfire节点,然后忽略本次任务触发执行,等待任务结束后再执行。 AbstractElasticJobExecutor#execute execute(shardingContexts, JobExecutionEvent.ExecutionSource.NORMAL_TRIGGER); while (jobFacade.isExecuteMisfired(shardingContexts.getShardingItemParameters().keySet())) { jobFacade.clearMisfire(shardingContexts.getShardingItemParameters().keySet()); execute(shardingContexts, JobExecutionEvent.ExecutionSource.MISFIRE); } 在任务执行完成后检查是否存在${namespace}/jobname/sharding/{item}/misfire节点,如果存在,则首先清除misfie相关的文件,然后执行任务。 ElasticJob的misfire实现方案总结: 在下一个调度周期到达之后,只要发现这个分片的任何一个分片正在执行,则为该实例分片的所有分片都设置为misfire,等任务执行完毕后,再统一执行下一次任务调度。 2、ElasticJob如何确保数据不会被多个Job实例处理 ElasticJob基于数据分片,不同分片根据分片参数(人为配置),从数据库中查询各自数据(任务数据分片),如果当节点宕机,数据会重新分片,如果任务未执行完成,然后执行分片,数据是否会被不同的任务同时处理呢? 答案是不会,因为当节点宕机后,是否需要重新分片事件监听器会监听到Job实例代表的节点删除,设置重新分片,在任务被调度执行具体处理逻辑之前,需要重新分片,重新分片的前提又是要所有的分片的任务全部执行完毕,这也依赖是否开启幂等控制(monitorExecution),如果开启,ElasticJob能感知正在执行处理逻辑的分片,重新分片需要等待当前所有任务全部运行完毕后才会触发,故不会存在不同节点处理相同数据的问题。 问答:1、如果一个任务JOB的调度频率为每10s一次,在某个时间,该job执行耗时用了33s(平时只需执行5s),按照正常调度,应该后续会触发3次调度,那该job后执行完,会连续执行3次调度吗?答案:在33s这次任务执行完成后,如果后面的任务执行在10s内执行完毕的话,只会触发一次,不会补偿3次,因为ElasticJob记录任务错失执行,只是创建了misfire节点,并不会记录错失的此时,因为也没这个必要。 原文发布时间为:2018-12-11本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
本节将探讨ElasticJob故障失效转移机制。我们知道ElasticJob是一款基于Quartz的分布式任务调度框架,这里的分布式是数据的分布式,ElasticJob的核心设计理念是一个任务在多个节点上执行,每个节点处理一部分数据(任务待处理数据分片)。那如果一个任务节点宕机后,则一次任务调度期间,一部分数据将不会被处理,为了解决由于任务节点宕机引起任务一个调度周期的一次任务执行部分数据未处理,可以设置开启故障失效转移,将本次任务转移到其他正常的节点上执行,实现与该任务在单节点上进行调度相同的效果(本次调度处理的数据量),ElasticJob故障失效转移类图如图所示: FailoverListenerManager:故障失效转移监听管理器。 FailoverListenerManager$JobCrashedJobListener job实现(Job实例宕机)事件监听管理器。 FailoverListenerManager$FailoverSettingsChangedJobListener 失效转移配置变化事件监听器。 1、故障失效转移事件监听管理器详解 1.1 JobCrashedJobListener Job实例节点宕机事件监听器 class JobCrashedJobListener extends AbstractJobListener { protected void dataChanged(final String path, final Type eventType, final String data) { if (isFailoverEnabled() && Type.NODE_REMOVED == eventType && instanceNode.isInstancePath(path)) { // @1 String jobInstanceId = path.substring(instanceNode.getInstanceFullPath().length() + 1); // @2 if (jobInstanceId.equals(JobRegistry.getInstance().getJobInstance(jobName).getJobInstanceId())) { // @3 return; } List<Integer> failoverItems = failoverService.getFailoverItems(jobInstanceId); //@4 if (!failoverItems.isEmpty()) { //@5 for (int each : failoverItems) { failoverService.setCrashedFailoverFlag(each); failoverService.failoverIfNecessary(); } } else { for (int each : shardingService.getShardingItems(jobInstanceId)) { //@6 failoverService.setCrashedFailoverFlag(each); failoverService.failoverIfNecessary(); } } } } } 代码@1:如果配置文件中设置开启故障失效转移机制,监听到${namespace}/jobname/instances节点下子节点的删除事件时,则被认为有节点宕机,将执行故障失效转移相关逻辑。代码@2:获取被宕机的任务实例ID(jobInstanceId)。代码@3:如果被删除的任务节点ID与当前实例的ID相同,则忽略。代码@4:根据jobInstanceId获取作业服务器的失效转移分片项集合。 其实现逻辑如下:FailoverService#getFailoverItems /** * 获取作业服务器的失效转移分片项集合. * * @param jobInstanceId 作业运行实例主键 * @return 作业失效转移的分片项集合 */ public List<Integer> getFailoverItems(final String jobInstanceId) { List<String> items = jobNodeStorage.getJobNodeChildrenKeys(ShardingNode.ROOT); List<Integer> result = new ArrayList<>(items.size()); for (String each : items) { int item = Integer.parseInt(each); String node = FailoverNode.getExecutionFailoverNode(item); if (jobNodeStorage.isJobNodeExisted(node) && jobInstanceId.equals(jobNodeStorage.getJobNodeDataDirectly(node))) { result.add(item); } } Collections.sort(result); return result; } 首先获取${namespace}/jobname/sharding目录下的直接子节点(当前的分片信息),判断${namespace}/jobname/sharding/{item}/failover节点是否存在,如果存在判断该分片是否为当前任务的分片节点,如果是,则返回。该方法的主要目的就是获取已经转移到当前任务节点的分片信息。 代码@5,判断是否有失败分片转移到当前节点,初始状态肯定为空,将执行代码@6,设置故障转移相关准备环境。 代码@6,获取分配给Crashed(宕机的job实例)的所有分片节点,遍历已发生故障的分片,将这些分片设置为故障,待故障转移,设置为故障的实现方法为:创建${namespace}/jobname/leader/failover/items/{item}。 代码@7:执行FailoverService#failoverIfNecessary是否执行故障转移。 /** * 如果需要失效转移, 则执行作业失效转移. */ public void failoverIfNecessary() { if (needFailover()) { jobNodeStorage.executeInLeader(FailoverNode.LATCH, new FailoverLeaderExecutionCallback()); } } private boolean needFailover() { return jobNodeStorage.isJobNodeExisted(FailoverNode.ITEMS_ROOT) && !jobNodeStorage.getJobNodeChildrenKeys(FailoverNode.ITEMS_ROOT).isEmpty() && !JobRegistry.getInstance().isJobRunning(jobName); } 其实现思路:【needFailover方法】首先判断是否存在&dollar;{namespace}/jobname/leader/failover/items节点是否存在,并且其节点下是否有子节点,并且节点也运行该任务,则需要执行故障失效转移。执行失效转移的逻辑也是进行失效转移选主,其分布式锁节点为:${namespace}/jobname/leader/failover/latch,谁先获得锁,则执行失效故障转移具体逻辑(FailoverLeaderExecutionCallback),具体的失效转移算法为: FailoverService#FailoverLeaderExecutionCallback: class FailoverLeaderExecutionCallback implements LeaderExecutionCallback { @Override public void execute() { if (JobRegistry.getInstance().isShutdown(jobName) || !needFailover()) { // @1 return; } int crashedItem = Integer.parseInt(jobNodeStorage.getJobNodeChildrenKeys(FailoverNode.ITEMS_ROOT).get(0)); // @2 log.debug("Failover job '{}' begin, crashed item '{}'", jobName, crashedItem); jobNodeStorage.fillEphemeralJobNode(FailoverNode.getExecutionFailoverNode(crashedItem), JobRegistry.getInstance().getJobInstance(jobName).getJobInstanceId()); // @3 jobNodeStorage.removeJobNodeIfExisted(FailoverNode.getItemsNode(crashedItem)); // @4 // TODO 不应使用triggerJob, 而是使用executor统一调度 JobScheduleController jobScheduleController = JobRegistry.getInstance().getJobScheduleController(jobName); // @5 if (null != jobScheduleController) { jobScheduleController.triggerJob(); } } } 代码@1:如果当前实例停止运行该job或无需执行失效故障转移,则返回。 代码@2:获取第一个待故障转移的分片,获取${namespace}/jobname/leader/failover/items/{itemnum,获取分片序号itemnum。 代码@3:创建临时节点${namespace}/jobname/sharding/itemnum/failover节点。 代码@4:删除${namespace}/jobname/leader/failover/items/{itemnum}节点。 代码@5:触发任务调度,并结束当前节点的故障失效转移,然后释放锁,下一个节点获取锁,进行转移${namespace}/jobname/leader/failover/items目录下的失效分片。 PS:故障实现转移基本实现思路为:当一个任务节点宕机后,其他节点会监听到实例删除事件,从实例目录中获取其实例ID,并从ZK中获取原先分配故障实例的分片信息,并将这些分片标记为需要故障转移(创建${namespace}/jobname/leader/failover/items/{item}持久节点),然后判断是否需要执行故障转移操作。 执行故障转移操作的前提条件是: 当前任务实例也调度该job; 存在${namespace}/jobname/leader/failover/items节点并有子节点。如果满足上述两个条件,则执行失效转移,多个存活节点进行选主(LeaderLatch),创建分布式锁节点(${namespace}/jobname/leader/failover/latch),获取锁的节点优先执行获取分片节点,其具体过程如上述所示,每个存活节点一次故障转移只竞争一个分片。 2、故障分片重新执行逻辑分析 上述事件监听器主要的作用是当任务节点失效后,其他存活节点“瓜分”失效节点的分片,创建${namespace}/jobname/sharding/{item}/failover节点。但这些分片的任务并没有真正执行,本小结将梳理故障节点分片的执行。 可以看得出来,分片故障转移,就是在对应的故障分片下创建了failover节点,在获取分片信息上下文时会优先处理,这也是在分析分片流程时并未重点讲解的。因此,在进入下述内容之前,请先阅读 源码分析ElasticJob的分片机制。 回到定时任务调度执行入口:AbstractElasticJobExecutor#execute /** * 执行作业. */ public final void execute() { try { jobFacade.checkJobExecutionEnvironment(); } catch (final JobExecutionEnvironmentException cause) { jobExceptionHandler.handleException(jobName, cause); } ShardingContexts shardingContexts = jobFacade.getShardingContexts(); // 获取分片上下文环境 ... } LiteJobFacade#getShardingContexts @Override public ShardingContexts getShardingContexts() { boolean isFailover = configService.load(true).isFailover(); if (isFailover) { // @1 List<Integer> failoverShardingItems = failoverService.getLocalFailoverItems(); // @2 if (!failoverShardingItems.isEmpty()) { return executionContextService.getJobShardingContext(failoverShardingItems); // @3 } } shardingService.shardingIfNecessary(); List<Integer> shardingItems = shardingService.getLocalShardingItems(); if (isFailover) { shardingItems.removeAll(failoverService.getLocalTakeOffItems()); } shardingItems.removeAll(executionService.getDisabledItems(shardingItems)); return executionContextService.getJobShardingContext(shardingItems); } 代码@1:获取分片上下文时,如果启用了故障失效转移机制,优先获取故障失效转移的分片上下文。 代码@2:获取本节点获取的实现分片信息。其基本逻辑是遍历&dollar;{namespace}/jobname/sharding下的字节点,获取该任务当前的所有分片信息,遍历每个节点,获取序号,然后依次判断是否存在(&dollar;{namespace}/jobname/sharding/{item}/failover),并且该节点的内容为当前的实例ID,则加入到分片结果中。 代码@3:根据失效分片序号构建分片上下文环境,执行该分片上的任务,根据分片上下文环境,执行任务。【AbstractElasticJob#execute(shardingContexts, JobExecutionEvent.ExecutionSource.NORMAL_TRIGGER);】执行完本次任务调度后,将删除分片的故障标记,待下一次任务调度时重新分片。 删除分片的故障标记代码如下:LiteJobFacade#registerJobCompleted public void registerJobCompleted(final ShardingContexts shardingContexts) { executionService.registerJobCompleted(shardingContexts); // @1 if (configService.load(true).isFailover()) { failoverService.updateFailoverComplete(shardingContexts.getShardingItemParameters().keySet()); // @2 } } 代码@1:将分片的调度任务设置为执行完成,首先在内存中设置任务为非运行中(JobRegistry.getInstance().setJobRunning(false)),如果开启了monitorExecution,则需要删除分片的运行标记,具体做法是,删除&dollar;{namespace}/jobname/sharding/{item}/running节点。 代码@2:如果启用了故障失效转移,调用updateFailoverComplete方法,更新故障实现转移处理完成,删除${namespace}/jobname/sharding/{item}/failover节点,下次任务统一调度的时候,所有的分片会重新再分片,也就完成一次故障失效转移。 总结 故障实现转移,其实就是在一次任务调度期间,分片节点宕机,导致分配在宕机服务上的分片任务未执行,那这一部数据在本次任务调度期间未被处理,为了及时处理那部分数据库,ElasticJob支持故障失效转移,就是在一次任务调度期间,将其他宕机服务所分配的分片上下文转移到当前存活的节点上执行,执行完毕后,才会开始下一次调动任务。 下一次调动任务运行时,会重新进行分片。ElasticJob是一款分布式任务调度平台,这里的分布式更多指的还是数据的分布式,就是一个任务在多个分片上执行,每个节点根据分片上下文获取部分数据进行处理(数据分片)。 原文发布时间为:2018-12-03本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
本文将重点分析 ElasticJob 的分片机制: ElasticJob分片工作机制: ElasticJob在启动时,首先会启动是否需要重新分片的监听器。代码见:ListenerManager#startAllListeners {...; shardingListenerManager.start();...}。 任务执行之前需要获取分片信息,如果需要重新分片,主服务器执行分片算法,其他从服务器等待直到分片完成。代码见:AbstractElasticJobExecutor#execute {...; jobFacade.getShardingContexts();...;} 1、分片管理监听器详解 ElasticJob的事件监听管理器实现类为:AbstractListenerManager。 其类图为: JobNodeStorage jobNodeStorage:Job node操作API。 其核心方法: public abstract void start():启动监听管理器,由子类具体实现。 protected void addDataListener(TreeCacheListener listener):增加事件监听器。 ElasticJob的选主监听管理器、分片监听器管理器、故障转移监听管理器等都是 AbstractListenerManager 的子类。 分片相关的监听管理器类图如图所示: ShardingListenerManager:分片监听管理器。 ShardingTotalCountChangedJobListener:监听总分片数量事件管理器,是TreeCacheListener(curator的事件监听器)子类。 ListenServersChangedJobListener:任务job服务器数量(运行时实例)发生变化后的事件监听器。 1.1 源码分析ShardingTotalCountChangedJobListener监听器 class ShardingTotalCountChangedJobListener extends AbstractJobListener { @Override protected void dataChanged(final String path, final Type eventType, final String data) { if (configNode.isConfigPath(path) && 0 != JobRegistry.getInstance().getCurrentShardingTotalCount(jobName)) { int newShardingTotalCount = LiteJobConfigurationGsonFactory.fromJson(data).getTypeConfig().getCoreConfig().getShardingTotalCount(); if (newShardingTotalCount != JobRegistry.getInstance().getCurrentShardingTotalCount(jobName)) { shardingService.setReshardingFlag(); JobRegistry.getInstance().setCurrentShardingTotalCount(jobName, newShardingTotalCount); } } } } job配置的分片总节点数发生变化监听器(ElasticJob允许通过Web界面修改每个任务配置的分片总数量)。 job的配置信息存储在${namespace}/jobname/config节点上,存储内容为json格式的配置信息。 如果${namespace}/jobname/config节点的内容发生变化,zk会触发该节点的节点数据变化事件,如果zk中存储的分片节点数量与内存中的分片数量不相同的话,调用ShardingService设置需要重新分片标记(创建${namespace}/jobname/leader/sharding/necessary持久节点)并更新内存中的分片节点总数。 1.2 源码分析ListenServersChangedJobListener 监听器 class ListenServersChangedJobListener extends AbstractJobListener { @Override protected void dataChanged(final String path, final Type eventType, final String data) { if (!JobRegistry.getInstance().isShutdown(jobName) && (isInstanceChange(eventType, path) || isServerChange(path))) { shardingService.setReshardingFlag(); } } private boolean isInstanceChange(final Type eventType, final String path) { return instanceNode.isInstancePath(path) && Type.NODE_UPDATED != eventType; } private boolean isServerChange(final String path) { return serverNode.isServerPath(path); } } 分片节点(实例数)发生变化事件监听器,当新的分片节点加入或原的分片实例宕机后,需要进行重新分片。 当${namespace}/jobname/servers或${namespace}/jobname/instances路径下的节点数量是否发生变化,如果检测到发生变化,设置需要重新分片标识。 2、具体分片逻辑 上面详细分析了分片监听管理器,其职责就是监听特定的 ZK 目录,当发生变化后判断是否需要设置重新分片的标记,如果设置了需要重新分片标记后,在什么时候触发重新分片呢? 每个调度任务在执行之前,首先需要获取分片信息(分片上下文环境),然后根据分片信息从服务器拉取不同的数据,进行任务处理,其源码入口为:AbstractElasticJobExecutor#execute。 jobFacade.getShardingContexts()方法。具体实现方法代码为:LiteJobFacade#getShardingContexts。 public ShardingContexts getShardingContexts() { boolean isFailover = configService.load(true).isFailover(); // @1 if (isFailover) { List<Integer> failoverShardingItems = failoverService.getLocalFailoverItems(); if (!failoverShardingItems.isEmpty()) { return executionContextService.getJobShardingContext(failoverShardingItems); } } shardingService.shardingIfNecessary(); // @2 List<Integer> shardingItems = shardingService.getLocalShardingItems(); // @3 if (isFailover) { shardingItems.removeAll(failoverService.getLocalTakeOffItems()); } shardingItems.removeAll(executionService.getDisabledItems(shardingItems)); // @4 return executionContextService.getJobShardingContext(shardingItems); // @5 } 代码@1:是否启动故障转移,本篇重点关注ElasticJob的分片机制,故障转移在下篇文章中详细介绍,本文假定不开启故障转移功能。代码@2:如果有必要,则执行分片,如果不存在分片信息(第一次分片)或需要重新分片,则执行分片算法,接下来详细分析分片的实现逻辑。代码@3:获取本地的分片信息。遍历所有分片信息${namespace}/jobname/sharding/{分片item}下所有instance节点,判断其值jobinstanceId是否与当前的jobInstanceId相等,相等则认为是本节点的分片信息。代码@4:移除本地禁用分片,本地禁用分片的存储目录为${namespace}/jobname/sharding/{分片item}/disable。代码@5:返回当前节点的分片上下文环境,这个主要是根据配置信息(分片参数)与当前的分片实例,构建ShardingContexts对象。 2.1 shardingService.shardingIfNecessary 详解【分片逻辑】 /** * 如果需要分片且当前节点为主节点, 则作业分片. * * <p> * 如果当前无可用节点则不分片. * </p> */ public void shardingIfNecessary() { List<JobInstance> availableJobInstances = instanceService.getAvailableJobInstances(); // @1 if (!isNeedSharding() || availableJobInstances.isEmpty()) { // @2 return; } if (!leaderService.isLeaderUntilBlock()) { // @3 blockUntilShardingCompleted(); //@4 return; } waitingOtherJobCompleted(); // @5 LiteJobConfiguration liteJobConfig = configService.load(false); int shardingTotalCount = liteJobConfig.getTypeConfig().getCoreConfig().getShardingTotalCount(); // @5 log.debug("Job '{}' sharding begin.", jobName); jobNodeStorage.fillEphemeralJobNode(ShardingNode.PROCESSING, ""); // @6 resetShardingInfo(shardingTotalCount); // @7 JobShardingStrategy jobShardingStrategy = JobShardingStrategyFactory.getStrategy(liteJobConfig.getJobShardingStrategyClass()); // @8 jobNodeStorage.executeInTransaction(new PersistShardingInfoTransactionExecutionCallback(jobShardingStrategy.sharding(availableJobInstances, jobName, shardingTotalCount))); // @9 log.debug("Job '{}' sharding complete.", jobName); } 代码@1:获取当前可用实例,首先获取${namespace}/jobname/instances目录下的所有子节点,并且判断该实例节点的IP所在服务器是否可用,${namespace}/jobname/servers/ip节点存储的值如果不是DISABLE,则认为该节点可用。代码@2:如果不需要重新分片(${namespace}/jobname/leader/sharding/necessary节点不存在)或当前不存在可用实例,则返回。代码@3,判断是否是主节点,如果当前正在进行主节点选举,则阻塞直到选主完成,阻塞这里使用的代码如下: while (!hasLeader() && serverService.hasAvailableServers()) { // 如果不存在主节点摈弃有可用的实例,则Thread.sleep()一下,触发一次选主。 log.info("Leader is electing, waiting for {} ms", 100); BlockUtils.waitingShortTime(); if (!JobRegistry.getInstance().isShutdown(jobName) && serverService.isAvailableServer(JobRegistry.getInstance().getJobInstance(jobName).getIp())) { electLeader(); } } return isLeader(); 代码@4:如果当前节点不是主节点,则等待分片结束。分片是否结束的判断依据是${namespace}/jobname/leader/sharding/necessary节点存在或${namespace}/jobname/leader/sharding/processing节点存在(表示正在执行分片操作),如果分片未结束,使用Thread.sleep方法阻塞100毫米后再试。 代码@5:能进入到这里,说明该节点是主节点。主节点在执行分片之前,首先等待该批任务全部执行完毕,判断是否有其他任务在运行的方法是判断是否存在${namespace}/jobname/sharding/{分片item}/running,如果存在,则使用Thread.sleep(100),然后再判断。 代码@6:创建临时节点${namespace}/jobname/leader/sharding/processing节点,表示分片正在执行。 代码@7:重置分片信息。先删除&dollar;{namespace}/jobname/sharding/{分片item}/instance节点,然后创建${namespace}/jobname/sharding/{分片item}节点(如有必要)。然后根据当前配置的分片总数量,如果当前${namespace}/jobname/sharding子节点数大于配置的分片节点数,则删除多余的节点(从大到小删除)。 代码@8:获取配置的分片算法类,常用的分片算法为平均分片算法(AverageAllocationJobShardingStrategy)。 代码@9:在一个事务内创建 相应的分片实例信息${namespace}/jobname/{分片item}/instance,节点存放的内容为JobInstance实例的ID。 在ZK中执行事务操作:JobNodeStorage#executeInTransaction /** * 在事务中执行操作. * * @param callback 执行操作的回调 */ public void executeInTransaction(final TransactionExecutionCallback callback) { try { CuratorTransactionFinal curatorTransactionFinal = getClient().inTransaction().check().forPath("/").and(); // @1 callback.execute(curatorTransactionFinal); // @2 curatorTransactionFinal.commit(); //@3 //CHECKSTYLE:OFF } catch (final Exception ex) { //CHECKSTYLE:ON RegExceptionHandler.handleException(ex); } } 代码@1,使用CuratorFrameworkFactory的inTransaction()方法,级联调用check(),最后通过and()方法返回CuratorTransactionFinal实例,由该实例执行事务中的所有更新节点命令。然后执行commit()命令统一提交(该方法可以保证要么全部成功,要么全部失败)。 代码@2,通过回调PersistShardingInfoTransactionExecutionCallback方法执行具体的逻辑。 代码@3,提交事务。 代码见ShardingService$PersistShardingInfoTransactionExecutionCallback class PersistShardingInfoTransactionExecutionCallback implements TransactionExecutionCallback { private final Map<JobInstance, List<Integer>> shardingResults; @Override public void execute(final CuratorTransactionFinal curatorTransactionFinal) throws Exception { for (Map.Entry<JobInstance, List<Integer>> entry : shardingResults.entrySet()) { for (int shardingItem : entry.getValue()) { curatorTransactionFinal.create().forPath(jobNodePath.getFullPath(ShardingNode.getInstanceNode(shardingItem)), entry.getKey().getJobInstanceId().getBytes()).and(); // @1 } } curatorTransactionFinal.delete().forPath(jobNodePath.getFullPath(ShardingNode.NECESSARY)).and(); // @2 curatorTransactionFinal.delete().forPath(jobNodePath.getFullPath(ShardingNode.PROCESSING)).and(); // @3 } } 代码@1:所谓的分片,主要是创建${namespace}/jobname/sharding/{分片item}/instance,节点内容为JobInstance ID。 代码@2:删除${namespace}/jobname/leader/sharding/necessary节点。 代码@3:删除${namespace}/jobname/leader/sharding/processing节点,表示分片结束。 下面以一张分片流程图来结束本节的讲述: 原文发布时间为:2018-12-02本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
ElasticJob各分布式调度服务器有两个角色:主服务器、从服务器。这里主从服务器与数据库的主从同步不一样,也不是传统意义上的主备,从执行调度任务这一视角来看ElasticJob主从服务器的地位是相同的,都是任务调度执行服务器(彼此之间共同组成一个集群平等的执行分配给自己的数据执行调度任务),主从服务器共同构成任务调度的分片节点。ElasticJob的主服务器的职责是根据当前存活的任务调度服务器生成分片信息,然后拉取属于该分片的任务数据执行任务。为了避免分片信息的不统一,ElasticJob必须从所有的调度服务器中选择一台为主服务器,由该台服务器统一计算分片信息,其他服务根据该分片信息进行任务调度。ElasticJob选主实现由LeaderService实现,从上文可知,在Job调度服务器的启动流程中:ListenerManager#startAllListeners 方法首先会启动ElectionListenerManager(主节点选举监听管理器),然后调用LeaderService.electLeader方法执行选主过程(SchedulerFacade#registerStartUpInfo)。 1、选主实现LeaderService.electLeader String jobName:任务名称。 ServiceService serverService:作业服务器服务服务API。 JobNodeStorage jobNodeStorage:job节点存储实现类,操作ZK api。 LeaderService#electLeader /** * 选举主节点. */ public void electLeader() { log.debug("Elect a new leader now."); jobNodeStorage.executeInLeader(LeaderNode.LATCH, new LeaderElectionExecutionCallback()); log.debug("Leader election completed."); } 选主使用的分布式锁节点目录: {Namespace}/{JobName}/leader/election/latch,LeaderService$LeaderElectionExecutionCallback,获取分布式锁后的回调逻辑。 /** * 在主节点执行操作. * * @param latchNode 分布式锁使用的作业节点名称 * @param callback 执行操作的回调 */ public void executeInLeader(final String latchNode, final LeaderExecutionCallback callback) { try (LeaderLatch latch = new LeaderLatch(getClient(), jobNodePath.getFullPath(latchNode))) { latch.start(); // @1 latch.await(); // @2 callback.execute(); //@3 //CHECKSTYLE:OFF } catch (final Exception ex) { //CHECKSTYLE:ON handleException(ex); } } 选主直接使用cautor开源框架提供的实现类org.apache.curator.framework.recipes.leader.LeaderLatch。LeaderLatch需要传入两个参数: CuratorFramework client:curator框架客户端。 latchPath:锁节点路径,elasticJob的latchPath为:${namespace}/${Jobname}/leader/election/latch。 代码@1、@2:启动 LeaderLatch,其主要过程就是去锁路径下创建一个临时排序节点,如果创建的节点序号最小,await 方法将返回,否则在前一个节点监听该节点事件,并阻塞,如何获得分布式锁后,执行callback回调方法。 LeaderService$LeaderElectionExecutionCallback @RequiredArgsConstructor class LeaderElectionExecutionCallback implements LeaderExecutionCallback { @Override public void execute() { if (!hasLeader()) { jobNodeStorage.fillEphemeralJobNode(LeaderNode.INSTANCE, JobRegistry.getInstance().getJobInstance(jobName).getJobInstanceId()); } } } 成功获取选主的分布式锁后,如果{namespace}/{jobname}/leader/election/instance节点不存在,则创建该临时节点,节点存储的内容为IP地址@-@进程ID,其代码为:jobInstanceId = IpUtils.getIp() + "@-@"+ ManagementFactory.getRuntimeMXBean().getName().split("@")[0]; 选主流程如图所示: 上面完成一次选主过程,如果主服务器宕机怎么办?从节点如何接管主服务器的角色呢?基于ZK的开发模式一般是监听节点的变化事件,做成相应的处理。 2、ElectionListenerManager主节点选举监听管理器 String jobName:job名称。 LeaderNode leaderNode:主节点信息,封装主节点在zk中存储节点信息。 ServerNode serverNode:服务器节点信息。 LeaderService leaderService:选主服务实现类。 ServerService serverService:作业服务器服务类。 LeaderNode、ServerNode 代表存储在 zk 服务器上的路径, LeaderNode 的类图如图所示: 其中 JobNamePath 定义了每一个 Job 在 zk 服务器的存储组织目录,根据器代码显示,例如数据同步项目(MyProject)下定义了两个定时任务(SyncUserJob、SyncRoleJob)。 注册中心命名空间取名为项目名:MyProject,在zk的节点存储节点类似如下目录结构,节点存放内容在具体用到时再分析。 2.1 ElectionListenerManager#start public void start() { addDataListener(new LeaderElectionJobListener()); addDataListener(new LeaderAbdicationJobListener()); } 首先关注一下使用ZK如何添加自定义监听器。 JobNodeStorage#addDataListener /** * 注册数据监听器. * * @param listener 数据监听器 */ public void addDataListener(final TreeCacheListener listener) { TreeCache cache = (TreeCache) regCenter.getRawCache("/" + jobName); cache.getListenable().addListener(listener); } 首先获取 TreeCache,然后获取 cahce.getListenable().addListener(TreeCacheListener)。 根据节点路径创建TreeCache的方法如下: ZookeeperRegistryCenter#addCacheData public void addCacheData(final String cachePath) { TreeCache cache = new TreeCache(client, cachePath); try { cache.start(); //CHECKSTYLE:OFF } catch (final Exception ex) { //CHECKSTYLE:ON RegExceptionHandler.handleException(ex); } caches.put(cachePath + "/", cache); } 2.2 LeaderElectionJobListener 选主事件监听器,监听节点主节点 LeaderNode.INSTANCE{namespace}/{jobname}/leader/election/instance。 如果主节点失去与 zk 的连接,由于 LeaderNode.INSTANCE 为临时节点,当节点被 zk 删除后,会触发其他从节点的选主,但由于任务调度服务器重新建立与zk的连接后,并不能直接参与选主,所以当LeaderNode.INSTANCE 每发送一次变化后,尝试发起一次选主,调用 LeaderService.electLeader 方法。 LeaderElectionJobListener #dataChanged protected void dataChanged(final String path, final Type eventType, final String data) { if (!JobRegistry.getInstance().isShutdown(jobName) && (isActiveElection(path, data) || isPassiveElection(path, eventType))) { leaderService.electLeader(); } } 如果该job未停止,并且可以进行选主或LeaderNode.INSTANCE节点被删除时,触发一次选主。 LeaderElectionJobListener #isActiveElection private boolean isActiveElection(final String path, final String data) { return !leaderService.hasLeader() && isLocalServerEnabled(path, data); } 如果当前节点不是主节点,并且当前服务器运行正常,运行正常的依据是存在{namespace}/{jobname}/servers/server-ip,并且节点内容不为DISABLED。 LeaderElectionJobListener #isPassiveElection private boolean isPassiveElection(final String path, final Type eventType) { return isLeaderCrashed(path, eventType) && serverService.isAvailableServer(JobRegistry.getInstance().getJobInstance(jobName).getIp()); } 如果当前事件节点为 LeaderNode.INSTANCE 并且事件类型为删除,并且该 job 的当前对应的实例({namespace}/{jobname}/instances/ip)存在并且状态不为DISABLED。 2.3 LeaderAbdicationJobListener 主退位监听器,其目的就是删除 LeaderNode.INSTANCE 节点。 class LeaderAbdicationJobListener extends AbstractJobListener { @Override protected void dataChanged(final String path, final Type eventType, final String data) { if (leaderService.isLeader() && isLocalServerDisabled(path, data)) { leaderService.removeLeader(); } } private boolean isLocalServerDisabled(final String path, final String data) { return serverNode.isLocalServerPath(path) && ServerStatus.DISABLED.name().equals(data); } } 本文选主机制就分析到这里,基于 ZK 来开发的常规讨论,就是创建节点、监听节点事件。这个监听器的目的:如果设置了某个节点的服务为 disable,当前节点正好是leader的话,则这个监听器执行leaderService.removeLeader() ;操作退位。 原文发布时间为:2018-12-01本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
本文主要目的:简单梳理了基于 Spring ElasticJob 的启动流程,从下文开始,将重点剖析 ElasticJob 的核心实现细节,例如选主、分片、失效转移机制等等。 1、在Spring中使用Elastic-Job的示例如下: <job:simple id="areaSyncJob" class="full class path" registry-center-ref="regCenter" cron="${elastic.exp.job.gisAMapArea.cron}" disabled="${elastic.exp.job.gisAMapArea.disabled}" sharding-total-count="${elastic.exp.job.areaSyncJob.shardingtotalcount}" sharding-item-parameters="${elastic.exp.job.areaSyncJob.shardingitemparameters}" overwrite="true" job-parameter="1" monitor-execution="true" description="高德行政区域数据同步" event-trace-rdb-data-source="dataSource" /> 从上篇中我们知道,该标签的解析类为:SimpleJobBeanDefinitionParser,最终会构建SpringJobScheduler 实例,并在初始化实例后调用 init 方法,开始 Job 任务的生命周期(启动、运行、调度)。 2、Spring自定义命令空间与标签解析原理分析 JobScheduler:ElasticJob调度任务管理实例. LiteJobConfiguration liteJobConfig:job配置文件。 CoordinatorRegistryCenter regCenter:分布式协调注册中心。 SchedulerFacade schedulerFacade:任务调度门面类。 JobFacade jobFacade:job门面类。 JobScheduleController job封装类,封装了quartz api,包括调度任务、重新调度任务、暂停任务、恢复任务、触发任务,是ElasticJob与Quartz的桥梁。 Spring 加载配置文件并创建调度任务 其时序图所图示: AbstractJobBeanDefinitionParse调用parseInternal()方法解析< job:simpleJob/>标签。 在SimpleJobBeanDefinitionParser类中调用SprinJobScheduler的init()方法。 第二步实际调用的是SpringJobScheduler的父类JobScheduler的init()方法。 利用StdSchedulerFactory创建Quartz的调度器Scheduler。 创建Quartz的JobDetail示例。 根据Scheduler、JobDetail、jobname创建JobScheduleController实例。 注册启动信息,ElasticJob的任务服务器的启动流程就在这里定义,下文详细分析。 启动调度任务,受Quartz框架的定时调度。 4、作业服务器启动流程 上面第7步,ElasticJob注册启动信息,其源码如下: SchedulerFacade#registerStartUpInfo: /** * 注册作业启动信息. * * @param enabled 作业是否启用 */ public void registerStartUpInfo(final boolean enabled) { listenerManager.startAllListeners(); leaderService.electLeader(); serverService.persistOnline(enabled); instanceService.persistOnline(); shardingService.setReshardingFlag(); monitorService.listen(); if (!reconcileService.isRunning()) { reconcileService.startAsync(); } } 启动ElasticJob所有zk事件监听管理器。 选主。 注册并持久化作业服务器信息。 注册并持久化作业运行实例信息。 设置是否需要重新分片。 启动调解分布式作业不一致状态服务。 ElasticJob所有事件监听管理器如图所示: ElectionListenerManager:主节点选举监听管理器 ShardingListenerManager:分片监听管理器。 FailoverListenerManager:失效转移监听管理器。 MonitorExecutionListenerManager:幂等性监听管理器。 ShutdownListenerManager:运行实例关闭监听管理器。 TriggerListenerManager:作业触发监听管理器。 RescheduleListenerManager:重调度监听管理器。 GuaranteeListenerManager:保证分布式任务全部开始和结束状态监听管理器。 本文就到此为止,从下篇文章开始将重点介绍分布式调度任务所需要解决的问题的实现原理,例如如何选主、分片、失效转移等。
在 Spring 中使用 Elastic-Job 的示例如下: <!--配置作业注册中心 --> <reg:zookeeper id="regCenter" server-lists="${gis.dubbo.registry.address}" namespace="example-job" base-sleep-time-milliseconds="${elasticJob.zkBaseSleepTimeMilliseconds}" max-sleep-time-milliseconds="${elasticJob.zkMaxSleepTimeMilliseconds}" max-retries="${elasticJob.zkMaxRetries}" /> 本文重点剖析如何在 Spring 中自定义命名空间 < reg:zookeeper/>。 1、在META-INF目录下定义xsd文件,Elastic-Job reg.xsd文件定义如下: <?xml version="1.0" encoding="UTF-8"?> <xsd:schema xmlns="http://www.dangdang.com/schema/ddframe/reg" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:beans="http://www.springframework.org/schema/beans" targetNamespace="http://www.dangdang.com/schema/ddframe/reg" elementFormDefault="qualified" attributeFormDefault="unqualified"> <xsd:import namespace="http://www.springframework.org/schema/beans"/> <xsd:element name="zookeeper"> <xsd:complexType> <xsd:complexContent> <xsd:extension base="beans:identifiedType"> <xsd:attribute name="server-lists" type="xsd:string" use="required" /> <xsd:attribute name="namespace" type="xsd:string" use="required" /> <xsd:attribute name="base-sleep-time-milliseconds" type="xsd:string" /> <xsd:attribute name="max-sleep-time-milliseconds" type="xsd:string" /> <xsd:attribute name="max-retries" type="xsd:string" /> <xsd:attribute name="session-timeout-milliseconds" type="xsd:string" /> <xsd:attribute name="connection-timeout-milliseconds" type="xsd:string" /> <xsd:attribute name="digest" type="xsd:string" /> </xsd:extension> </xsd:complexContent> </xsd:complexType> </xsd:element> </xsd:schema> xsd:schema元素详解 xmlns="http://www.dangdang.com/schema/ddframe/reg":定义默认命名空间。如果元素没有前缀,默认为该命名空间下的元素。 xmlns:xsd="http://www.w3.org/2001/XMLSchema",引入xsd命名空间,该命名空间的URL为http://www.w3.org/2001/XMLSchema,元素前缀为xsd。 xmlns:beans="http://www.springframework.org/schema/beans",引入Spring beans命名空间.xmlns:xx=""表示引入已存在的命名空间。 targetNamespace="http://www.dangdang.com/schema/ddframe/reg":定义该命名空间所对应的url,在xml文件中如果要使用< reg:xx/>,其xsi:schemaLocation定义reg.xsd路径时必须以该值为键,例如应用程序中定义elasticjob的xml文件如下: elementFormDefault="qualified":指定该xsd所对应的实例xml文件,引用该文件中定义的元素必须被命名空间所限定。例如在reg.xsd中定义了zookeeper这个元素,那么在spring-elastic-job.xml(xml文档实例)中使用该元素来定义时,必须这样写: ,而不能写出。 attributeFormDefault:指定该xsd所对应的示例xml文件,引用该文件中定义的元素属性是否需要被限定,unqualified表示不需要被限定。如果设置为qualified,则则为错误写法,正确写法如下: <reg:zookeeper id="regCenter" reg:server-lists="" .../>。 xsd:import导入其他命名空间,< xsd:import namespace="http://www.springframework.org/schema/beans"/> 表示导入spirng beans命名空间。如果目标命名空间定义文件没有指定targetNamespace,则需要使用include导入其他命令空间,例如: <import namespace="tnsB" schemaLocation="B.xsd"> xsd:zookeeper> 定义zookeeper元素,xml文件中可以使用reg:zookeeper/>。 xsd:complexType,zookeeper元素的类型为复杂类型。 xsd:extension base="beans:identifiedType">继承beans命名空间identifiedType的属性。(id 定义)。 2、定义NamespaceHandlerSupport实现类。 继承NamespaceHandlerSupport,重写init方法。 public final class RegNamespaceHandler extends NamespaceHandlerSupport { @Override public void init() { registerBeanDefinitionParser("zookeeper", new ZookeeperBeanDefinitionParser()); } } 注册BeanDefinitionParser解析< reg:zookeeper/>标签,并初始化实例。 ZookeeperBeanDefinitionParser源码: public final class ZookeeperBeanDefinitionParser extends AbstractBeanDefinitionParser { @Override protected AbstractBeanDefinition parseInternal(final Element element, final ParserContext parserContext) { BeanDefinitionBuilder result = BeanDefinitionBuilder.rootBeanDefinition(ZookeeperRegistryCenter.class);// @1 result.addConstructorArgValue(buildZookeeperConfigurationBeanDefinition(element)); // @2 result.setInitMethodName("init"); // @3 return result.getBeanDefinition(); // @4 } // ....省略部分代码 } 代码@1:构建器模式,表明< reg:zookeeper/>标签对应的实体Bean对象为 ZookeeperRegistryCenter,zk注册中心实现类。代码@2:最终创建 ZookeeperRegistryCenter,其属性通过构造方法注入。代码@3:设置 initMethod,相当于配置文件的 init-method 属性,表明在创建实例时将调用该方法进行初始化。代码@4:返回 AbstractBeanDefinition 对象,方便 Spring 针对该配置创建实例。 ZookeeperBeanDefinitionParser#buildZookeeperConfigurationBeanDefinition private AbstractBeanDefinition buildZookeeperConfigurationBeanDefinition(final Element element) { BeanDefinitionBuilder configuration = BeanDefinitionBuilder.rootBeanDefinition(ZookeeperConfiguration.class); configuration.addConstructorArgValue(element.getAttribute("server-lists")); configuration.addConstructorArgValue(element.getAttribute("namespace")); addPropertyValueIfNotEmpty("base-sleep-time-milliseconds", "baseSleepTimeMilliseconds", element, configuration); addPropertyValueIfNotEmpty("max-sleep-time-milliseconds", "maxSleepTimeMilliseconds", element, configuration); addPropertyValueIfNotEmpty("max-retries", "maxRetries", element, configuration); addPropertyValueIfNotEmpty("session-timeout-milliseconds", "sessionTimeoutMilliseconds", element, configuration); addPropertyValueIfNotEmpty("connection-timeout-milliseconds", "connectionTimeoutMilliseconds", element, configuration); addPropertyValueIfNotEmpty("digest", "digest", element, configuration); return configuration.getBeanDefinition(); } 根据< reg:zookeeper/>元素,获取 element 的server-lists、namespace 属性,使用ZookeeperConfiguration 构造方式初始化 ZookeeperConfiguration属性,然后解析其他非空属性并使用set 方法注入到 ZookeeperConfiguration 实例。 3、将自定义的 NameSpace、xsd 文件纳入 Spring 的管理范围内。 在 META-INF 目录下创建 spring.handlers、spring.schemas 文件,其内容分别是: spring.handlers:http\://www.dangdang.com/schema/ddframe/reg=io.elasticjob.lite.spring.reg.handler.RegNamespaceHandler 格式如下:xsd文件中定义的targetNamespace=自定义namespace实现类。 spring.schemas:http\://www.dangdang.com/schema/ddframe/reg/reg.xsd=META-INF/namespace/reg.xsd 格式如下:xsd文件uri = xsd文件目录。 xsi:schemaLocation="http\://www.dangdang.com/schema/ddframe/reg/reg.xsd=META-INF/namespace/reg.xsd" 取的就是该文件的内容。然后元素解析后,然后实例化Bean并调用ZookeeperRegistryCenter的init方法,开始注册中心的启动流程。 下一篇将详细介绍elastic-job注册中心的启动流程。 原文发布时间为:2019-11-28本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
本文紧接着 源码分析 RocketMQ DLedger(多副本) 之日志追加流程 ,继续 Leader 处理客户端 append 的请求流程中最至关重要的一环:日志复制。 DLedger 多副本的日志转发由 DLedgerEntryPusher 实现,接下来将对其进行详细介绍。 温馨提示:由于本篇幅较长,为了更好的理解其实现,大家可以带着如下疑问来通读本篇文章:1、raft 协议中有一个非常重要的概念:已提交日志序号,该如何实现。2、客户端向 DLedger 集群发送一条日志,必须得到集群中大多数节点的认可才能被认为写入成功。3、raft 协议中追加、提交两个动作如何实现。 日志复制(日志转发)由 DLedgerEntryPusher 实现,具体类图如下:主要由如下4个类构成: DLedgerEntryPusherDLedger 日志转发与处理核心类,该内会启动如下3个对象,其分别对应一个线程。 EntryHandler日志接收处理线程,当节点为从节点时激活。 QuorumAckChecker日志追加ACK投票处理线程,当前节点为主节点时激活。 EntryDispatcher日志转发线程,当前节点为主节点时追加。 接下来我们将详细介绍上述4个类,从而揭晓日志复制的核心实现原理。 1、DLedgerEntryPusher 1.1 核心类图 DLedger 多副本日志推送的核心实现类,里面会创建 EntryDispatcher、QuorumAckChecker、EntryHandler 三个核心线程。其核心属性如下: DLedgerConfig dLedgerConfig多副本相关配置。 DLedgerStore dLedgerStore存储实现类。 MemberState memberState节点状态机。 DLedgerRpcService dLedgerRpcServiceRPC 服务实现类,用于集群内的其他节点进行网络通讯。 Map> peerWaterMarksByTerm每个节点基于投票轮次的当前水位线标记。键值为投票轮次,值为 ConcurrentMap 节点id/, Long/ 节点对应的日志序号/>。 Map>> pendingAppendResponsesByTerm用于存放追加请求的响应结果(Future模式)。 EntryHandler entryHandler从节点上开启的线程,用于接收主节点的 push 请求(append、commit、append)。 QuorumAckChecker quorumAckChecker主节点上的追加请求投票器。 Map dispatcherMap主节点日志请求转发器,向从节点复制消息等。 接下来介绍一下其核心方法的实现。 1.2 构造方法 public DLedgerEntryPusher(DLedgerConfig dLedgerConfig, MemberState memberState, DLedgerStore dLedgerStore, DLedgerRpcService dLedgerRpcService) { this.dLedgerConfig = dLedgerConfig; this.memberState = memberState; this.dLedgerStore = dLedgerStore; this.dLedgerRpcService = dLedgerRpcService; for (String peer : memberState.getPeerMap().keySet()) { if (!peer.equals(memberState.getSelfId())) { dispatcherMap.put(peer, new EntryDispatcher(peer, logger)); } } } 构造方法的重点是会根据集群内的节点,依次构建对应的 EntryDispatcher 对象。 1.3 startup DLedgerEntryPusher#startup public void startup() { entryHandler.start(); quorumAckChecker.start(); for (EntryDispatcher dispatcher : dispatcherMap.values()) { dispatcher.start(); } } 依次启动 EntryHandler、QuorumAckChecker 与 EntryDispatcher 线程。 备注:DLedgerEntryPusher 的其他核心方法在详细分析其日志复制原理的过程中会一一介绍。 接下来将从 EntryDispatcher、QuorumAckChecker、EntryHandler 来阐述 RocketMQ DLedger(多副本)的实现原理。 2、EntryDispatcher 详解 2.1 核心类图 其核心属性如下。 AtomicReference type = new AtomicReference<>(PushEntryRequest.Type.COMPARE)向从节点发送命令的类型,可选值:PushEntryRequest.Type.COMPARE、TRUNCATE、APPEND、COMMIT,下面详细说明。 long lastPushCommitTimeMs = -1上一次发送提交类型的时间戳。 String peerId目标节点ID。 long compareIndex = -1已完成比较的日志序号。 long writeIndex = -1已写入的日志序号。 int maxPendingSize = 1000允许的最大挂起日志数量。 long term = -1 Leader 节点当前的投票轮次。 String leaderId = nullLeader 节点ID。 long lastCheckLeakTimeMs = System.currentTimeMillis()上次检测泄漏的时间,所谓的泄漏,就是看挂起的日志请求数量是否查过了 maxPendingSize 。 ConcurrentMap pendingMap = new ConcurrentHashMap<>()记录日志的挂起时间,key:日志的序列(entryIndex),value:挂起时间戳。 Quota quota = new Quota(dLedgerConfig.getPeerPushQuota())配额。 2.2 Push 请求类型 DLedger 主节点向从从节点复制日志总共定义了4类请求类型,其枚举类型为 PushEntryRequest.Type,其值分别为 COMPARE、TRUNCATE、APPEND、COMMIT。 COMPARE如果 Leader 发生变化,新的 Leader 需要与他的从节点的日志条目进行比较,以便截断从节点多余的数据。 TRUNCATE如果 Leader 通过索引完成日志对比,则 Leader 将发送 TRUNCATE 给它的从节点。 APPEND将日志条目追加到从节点。 COMMIT通常,leader 会将提交的索引附加到 append 请求,但是如果 append 请求很少且分散,leader 将发送一个单独的请求来通知从节点提交的索引。 对主从节点的请求类型有了一个初步的认识后,我们将从 EntryDispatcher 的业务处理入口 doWork 方法开始讲解。 2.3 doWork 方法详解 public void doWork() { try { if (!checkAndFreshState()) { // @1 waitForRunning(1); return; } if (type.get() == PushEntryRequest.Type.APPEND) { // @2 doAppend(); } else { doCompare(); // @3 } waitForRunning(1); } catch (Throwable t) { DLedgerEntryPusher.logger.error("[Push-{}]Error in {} writeIndex={} compareIndex={}", peerId, getName(), writeIndex, compareIndex, t); DLedgerUtils.sleep(500); } } 代码@1:检查状态,是否可以继续发送 append 或 compare。 代码@2:如果推送类型为APPEND,主节点向从节点传播消息请求。 代码@3:主节点向从节点发送对比数据差异请求(当一个新节点被选举成为主节点时,往往这是第一步)。 2.3.1 checkAndFreshState 详解 EntryDispatcher#checkAndFreshState private boolean checkAndFreshState() { if (!memberState.isLeader()) { // @1 return false; } if (term != memberState.currTerm() || leaderId == null || !leaderId.equals(memberState.getLeaderId())) { // @2 synchronized (memberState) { if (!memberState.isLeader()) { return false; } PreConditions.check(memberState.getSelfId().equals(memberState.getLeaderId()), DLedgerResponseCode.UNKNOWN); term = memberState.currTerm(); leaderId = memberState.getSelfId(); changeState(-1, PushEntryRequest.Type.COMPARE); } } return true; } 代码@1:如果节点的状态不是主节点,则直接返回 false。则结束 本次 doWork 方法。因为只有主节点才需要向从节点转发日志。 代码@2:如果当前节点状态是主节点,但当前的投票轮次与状态机轮次或 leaderId 还未设置,或 leaderId 与状态机的 leaderId 不相等,这种情况通常是集群触发了重新选举,设置其term、leaderId与状态机同步,即将发送COMPARE 请求。 接下来看一下 changeState (改变状态)。 private synchronized void changeState(long index, PushEntryRequest.Type target) { logger.info("[Push-{}]Change state from {} to {} at {}", peerId, type.get(), target, index); switch (target) { case APPEND: // @1 compareIndex = -1; updatePeerWaterMark(term, peerId, index); quorumAckChecker.wakeup(); writeIndex = index + 1; break; case COMPARE: // @2 if (this.type.compareAndSet(PushEntryRequest.Type.APPEND, PushEntryRequest.Type.COMPARE)) { compareIndex = -1; pendingMap.clear(); } break; case TRUNCATE: // @3 compareIndex = -1; break; default: break; } type.set(target); } 代码@1:如果将目标类型设置为 append,则重置 compareIndex ,并设置 writeIndex 为当前 index 加1。 代码@2:如果将目标类型设置为 COMPARE,则重置 compareIndex 为负一,接下将向各个从节点发送 COMPARE 请求类似,并清除已挂起的请求。 代码@3:如果将目标类型设置为 TRUNCATE,则重置 compareIndex 为负一。 接下来具体来看一下 APPEND、COMPARE、TRUNCATE 等请求。 2.3.2 append 请求详解 EntryDispatcher#doAppend private void doAppend() throws Exception { while (true) { if (!checkAndFreshState()) { // @1 break; } if (type.get() != PushEntryRequest.Type.APPEND) { // @2 break; } if (writeIndex > dLedgerStore.getLedgerEndIndex()) { // @3 doCommit(); doCheckAppendResponse(); break; } if (pendingMap.size() >= maxPendingSize || (DLedgerUtils.elapsed(lastCheckLeakTimeMs) > 1000)) { // @4 long peerWaterMark = getPeerWaterMark(term, peerId); for (Long index : pendingMap.keySet()) { if (index < peerWaterMark) { pendingMap.remove(index); } } lastCheckLeakTimeMs = System.currentTimeMillis(); } if (pendingMap.size() >= maxPendingSize) { // @5 doCheckAppendResponse(); break; } doAppendInner(writeIndex); // @6 writeIndex++; } } 代码@1:检查状态,已经在上面详细介绍。 代码@2:如果请求类型不为 APPEND,则退出,结束本轮 doWork 方法执行。 代码@3:writeIndex 表示当前追加到从该节点的序号,通常情况下主节点向从节点发送 append 请求时,会附带主节点的已提交指针,但如何 append 请求发不那么频繁,writeIndex 大于 leaderEndIndex 时(由于pending请求超过其 pending 请求的队列长度(默认为1w),时,会阻止数据的追加,此时有可能出现 writeIndex 大于 leaderEndIndex 的情况,此时单独发送 COMMIT 请求。 代码@4:检测 pendingMap(挂起的请求数量)是否发送泄漏,即挂起队列中容量是否超过允许的最大挂起阀值。获取当前节点关于本轮次的当前水位线(已成功 append 请求的日志序号),如果发现正在挂起请求的日志序号小于水位线,则丢弃。 代码@5:如果挂起的请求(等待从节点追加结果)大于 maxPendingSize 时,检查并追加一次 append 请求。 代码@6:具体的追加请求。 2.3.2.1 doCommit 发送提交请求 EntryDispatcher#doCommit private void doCommit() throws Exception { if (DLedgerUtils.elapsed(lastPushCommitTimeMs) > 1000) { // @1 PushEntryRequest request = buildPushRequest(null, PushEntryRequest.Type.COMMIT); // @2 //Ignore the results dLedgerRpcService.push(request); // @3 lastPushCommitTimeMs = System.currentTimeMillis(); } } 代码@1:如果上一次单独发送 commit 的请求时间与当前时间相隔低于 1s,放弃本次提交请求。 代码@2:构建提交请求。 代码@3:通过网络向从节点发送 commit 请求。 接下来先了解一下如何构建 commit 请求包。 EntryDispatcher#buildPushRequest private PushEntryRequest buildPushRequest(DLedgerEntry entry, PushEntryRequest.Type target) { PushEntryRequest request = new PushEntryRequest(); request.setGroup(memberState.getGroup()); request.setRemoteId(peerId); request.setLeaderId(leaderId); request.setTerm(term); request.setEntry(entry); request.setType(target); request.setCommitIndex(dLedgerStore.getCommittedIndex()); return request; } 提交包请求字段主要包含如下字段:DLedger 节点所属组、从节点 id、主节点 id,当前投票轮次、日志内容、请求类型与 committedIndex(主节点已提交日志序号)。 2.3.2.2 doCheckAppendResponse 检查并追加请求 EntryDispatcher#doCheckAppendResponse private void doCheckAppendResponse() throws Exception { long peerWaterMark = getPeerWaterMark(term, peerId); // @1 Long sendTimeMs = pendingMap.get(peerWaterMark + 1); if (sendTimeMs != null && System.currentTimeMillis() - sendTimeMs > dLedgerConfig.getMaxPushTimeOutMs()) { // @2 logger.warn("[Push-{}]Retry to push entry at {}", peerId, peerWaterMark + 1); doAppendInner(peerWaterMark + 1); } } 该方法的作用是检查 append 请求是否超时,其关键实现如下: 获取已成功 append 的序号。 从挂起的请求队列中获取下一条的发送时间,如果不为空并去超过了 append 的超时时间,则再重新发送 append 请求,最大超时时间默认为 1s,可以通过 maxPushTimeOutMs 来改变默认值。 2.3.2.3 doAppendInner 追加请求 向从节点发送 append 请求。 EntryDispatcher#doAppendInner private void doAppendInner(long index) throws Exception { DLedgerEntry entry = dLedgerStore.get(index); // @1 PreConditions.check(entry != null, DLedgerResponseCode.UNKNOWN, "writeIndex=%d", index); checkQuotaAndWait(entry); // @2 PushEntryRequest request = buildPushRequest(entry, PushEntryRequest.Type.APPEND); // @3 CompletableFuture<PushEntryResponse> responseFuture = dLedgerRpcService.push(request); // @4 pendingMap.put(index, System.currentTimeMillis()); // @5 responseFuture.whenComplete((x, ex) -> { try { PreConditions.check(ex == null, DLedgerResponseCode.UNKNOWN); DLedgerResponseCode responseCode = DLedgerResponseCode.valueOf(x.getCode()); switch (responseCode) { case SUCCESS: // @6 pendingMap.remove(x.getIndex()); updatePeerWaterMark(x.getTerm(), peerId, x.getIndex()); quorumAckChecker.wakeup(); break; case INCONSISTENT_STATE: // @7 logger.info("[Push-{}]Get INCONSISTENT_STATE when push index={} term={}", peerId, x.getIndex(), x.getTerm()); changeState(-1, PushEntryRequest.Type.COMPARE); break; default: logger.warn("[Push-{}]Get error response code {} {}", peerId, responseCode, x.baseInfo()); break; } } catch (Throwable t) { logger.error("", t); } }); lastPushCommitTimeMs = System.currentTimeMillis(); } 代码@1:首先根据序号查询出日志。 代码@2:检测配额,如果超过配额,会进行一定的限流,其关键实现点: 首先触发条件:append 挂起请求数已超过最大允许挂起数;基于文件存储并主从差异超过300m,可通过 peerPushThrottlePoint 配置。 每秒追加的日志超过 20m(可通过 peerPushQuota 配置),则会 sleep 1s中后再追加。 代码@3:构建 PUSH 请求日志。 代码@4:通过 Netty 发送网络请求到从节点,从节点收到请求会进行处理(本文并不会探讨与网络相关的实现细节)。 代码@5:用 pendingMap 记录待追加的日志的发送时间,用于发送端判断是否超时的一个依据。 代码@6:请求成功的处理逻辑,其关键实现点如下: 移除 pendingMap 中的关于该日志的发送超时时间。 更新已成功追加的日志序号(按投票轮次组织,并且每个从服务器一个键值对)。 唤醒 quorumAckChecker 线程(主要用于仲裁 append 结果),后续会详细介绍。 代码@7:Push 请求出现状态不一致情况,将发送 COMPARE 请求,来对比主从节点的数据是否一致。 日志转发 append 追加请求类型就介绍到这里了,接下来我们继续探讨另一个请求类型 compare。 2.3.3 compare 请求详解 COMPARE 类型的请求有 doCompare 方法发送,首先该方法运行在 while (true) 中,故在查阅下面代码时,要注意其退出循环的条件。EntryDispatcher#doCompare if (!checkAndFreshState()) { break; } if (type.get() != PushEntryRequest.Type.COMPARE && type.get() != PushEntryRequest.Type.TRUNCATE) { break; } if (compareIndex == -1 && dLedgerStore.getLedgerEndIndex() == -1) { break; } Step1:验证是否执行,有几个关键点如下: 判断是否是主节点,如果不是主节点,则直接跳出。 如果是请求类型不是 COMPARE 或 TRUNCATE 请求,则直接跳出。 如果已比较索引 和 ledgerEndIndex 都为 -1 ,表示一个新的 DLedger 集群,则直接跳出。 EntryDispatcher#doCompare if (compareIndex == -1) { compareIndex = dLedgerStore.getLedgerEndIndex(); logger.info("[Push-{}][DoCompare] compareIndex=-1 means start to compare", peerId); } else if (compareIndex > dLedgerStore.getLedgerEndIndex() || compareIndex < dLedgerStore.getLedgerBeginIndex()) { logger.info("[Push-{}][DoCompare] compareIndex={} out of range {}-{}", peerId, compareIndex, dLedgerStore.getLedgerBeginIndex(), dLedgerStore.getLedgerEndIndex()); compareIndex = dLedgerStore.getLedgerEndIndex(); } Step2:如果 compareIndex 为 -1 或compareIndex 不在有效范围内,则重置待比较序列号为当前已已存储的最大日志序号:ledgerEndIndex。 DLedgerEntry entry = dLedgerStore.get(compareIndex); PreConditions.check(entry != null, DLedgerResponseCode.INTERNAL_ERROR, "compareIndex=%d", compareIndex); PushEntryRequest request = buildPushRequest(entry, PushEntryRequest.Type.COMPARE); CompletableFuture<PushEntryResponse> responseFuture = dLedgerRpcService.push(request); PushEntryResponse response = responseFuture.get(3, TimeUnit.SECONDS); Step3:根据序号查询到日志,并向从节点发起 COMPARE 请求,其超时时间为 3s。 EntryDispatcher#doCompare long truncateIndex = -1; if (response.getCode() == DLedgerResponseCode.SUCCESS.getCode()) { // @1 if (compareIndex == response.getEndIndex()) { changeState(compareIndex, PushEntryRequest.Type.APPEND); break; } else { truncateIndex = compareIndex; } } else if (response.getEndIndex() < dLedgerStore.getLedgerBeginIndex() || response.getBeginIndex() > dLedgerStore.getLedgerEndIndex()) { // @2 truncateIndex = dLedgerStore.getLedgerBeginIndex(); } else if (compareIndex < response.getBeginIndex()) { // @3 truncateIndex = dLedgerStore.getLedgerBeginIndex(); } else if (compareIndex > response.getEndIndex()) { // @4 compareIndex = response.getEndIndex(); } else { // @5 compareIndex--; } if (compareIndex < dLedgerStore.getLedgerBeginIndex()) { // @6 truncateIndex = dLedgerStore.getLedgerBeginIndex(); } Step4:根据响应结果计算需要截断的日志序号,其主要实现关键点如下: 代码@1:如果两者的日志序号相同,则无需截断,下次将直接先从节点发送 append 请求;否则将 truncateIndex 设置为响应结果中的 endIndex。 代码@2:如果从节点存储的最大日志序号小于主节点的最小序号,或者从节点的最小日志序号大于主节点的最大日志序号,即两者不相交,这通常发生在从节点崩溃很长一段时间,而主节点删除了过期的条目时。truncateIndex 设置为主节点的 ledgerBeginIndex,即主节点目前最小的偏移量。 代码@3:如果已比较的日志序号小于从节点的开始日志序号,很可能是从节点磁盘发送损耗,从主节点最小日志序号开始同步。 代码@4:如果已比较的日志序号大于从节点的最大日志序号,则已比较索引设置为从节点最大的日志序号,触发数据的继续同步。 代码@5:如果已比较的日志序号大于从节点的开始日志序号,但小于从节点的最大日志序号,则待比较索引减一。 代码@6:如果比较出来的日志序号小于主节点的最小日志需要,则设置为主节点的最小序号。 if (truncateIndex != -1) { changeState(truncateIndex, PushEntryRequest.Type.TRUNCATE); doTruncate(truncateIndex); break; } Step5:如果比较出来的日志序号不等于 -1 ,则向从节点发送 TRUNCATE 请求。 2.3.3.1 doTruncate 详解 private void doTruncate(long truncateIndex) throws Exception { PreConditions.check(type.get() == PushEntryRequest.Type.TRUNCATE, DLedgerResponseCode.UNKNOWN); DLedgerEntry truncateEntry = dLedgerStore.get(truncateIndex); PreConditions.check(truncateEntry != null, DLedgerResponseCode.UNKNOWN); logger.info("[Push-{}]Will push data to truncate truncateIndex={} pos={}", peerId, truncateIndex, truncateEntry.getPos()); PushEntryRequest truncateRequest = buildPushRequest(truncateEntry, PushEntryRequest.Type.TRUNCATE); PushEntryResponse truncateResponse = dLedgerRpcService.push(truncateRequest).get(3, TimeUnit.SECONDS); PreConditions.check(truncateResponse != null, DLedgerResponseCode.UNKNOWN, "truncateIndex=%d", truncateIndex); PreConditions.check(truncateResponse.getCode() == DLedgerResponseCode.SUCCESS.getCode(), DLedgerResponseCode.valueOf(truncateResponse.getCode()), "truncateIndex=%d", truncateIndex); lastPushCommitTimeMs = System.currentTimeMillis(); changeState(truncateIndex, PushEntryRequest.Type.APPEND); } 该方法主要就是构建 truncate 请求到从节点。 关于服务端的消息复制转发就介绍到这里了,主节点负责向从服务器PUSH请求,从节点自然而然的要处理这些请求,接下来我们就按照主节点发送的请求,来具体分析一下从节点是如何响应的。 3、EntryHandler 详解 EntryHandler 同样是一个线程,当节点状态为从节点时激活。 3.1 核心类图 其核心属性如下: long lastCheckFastForwardTimeMs上一次检查主服务器是否有 push 消息的时间戳。 ConcurrentMap>> writeRequestMap 请求处理队列。 BlockingQueue>> compareOrTruncateRequestsCOMMIT、COMPARE、TRUNCATE 相关请求 3.2 handlePush 从上文得知,主节点会主动向从节点传播日志,从节点会通过网络接受到请求数据进行处理,其调用链如图所示:最终会调用 EntryHandler 的 handlePush 方法。 EntryHandler#handlePush public CompletableFuture<PushEntryResponse> handlePush(PushEntryRequest request) throws Exception { //The timeout should smaller than the remoting layer's request timeout CompletableFuture<PushEntryResponse> future = new TimeoutFuture<>(1000); // @1 switch (request.getType()) { case APPEND: // @2 PreConditions.check(request.getEntry() != null, DLedgerResponseCode.UNEXPECTED_ARGUMENT); long index = request.getEntry().getIndex(); Pair<PushEntryRequest, CompletableFuture<PushEntryResponse>> old = writeRequestMap.putIfAbsent(index, new Pair<>(request, future)); if (old != null) { logger.warn("[MONITOR]The index {} has already existed with {} and curr is {}", index, old.getKey().baseInfo(), request.baseInfo()); future.complete(buildResponse(request, DLedgerResponseCode.REPEATED_PUSH.getCode())); } break; case COMMIT: // @3 compareOrTruncateRequests.put(new Pair<>(request, future)); break; case COMPARE: case TRUNCATE: // @4 PreConditions.check(request.getEntry() != null, DLedgerResponseCode.UNEXPECTED_ARGUMENT); writeRequestMap.clear(); compareOrTruncateRequests.put(new Pair<>(request, future)); break; default: logger.error("[BUG]Unknown type {} from {}", request.getType(), request.baseInfo()); future.complete(buildResponse(request, DLedgerResponseCode.UNEXPECTED_ARGUMENT.getCode())); break; } return future; } 从几点处理主节点的 push 请求,其实现关键点如下。 代码@1:首先构建一个响应结果Future,默认超时时间 1s。 代码@2:如果是 APPEND 请求,放入到 writeRequestMap 集合中,如果已存在该数据结构,说明主节点重复推送,构建返回结果,其状态码为 REPEATED_PUSH。放入到 writeRequestMap 中,由 doWork 方法定时去处理待写入的请求。 代码@3:如果是提交请求, 将请求存入 compareOrTruncateRequests 请求处理中,由 doWork 方法异步处理。 代码@4:如果是 COMPARE 或 TRUNCATE 请求,将待写入队列 writeRequestMap 清空,并将请求放入 compareOrTruncateRequests 请求队列中,由 doWork 方法异步处理。 接下来,我们重点来分析 doWork 方法的实现。 3.3 doWork 方法详解 EntryHandler#doWork public void doWork() { try { if (!memberState.isFollower()) { // @1 waitForRunning(1); return; } if (compareOrTruncateRequests.peek() != null) { // @2 Pair<PushEntryRequest, CompletableFuture<PushEntryResponse>> pair = compareOrTruncateRequests.poll(); PreConditions.check(pair != null, DLedgerResponseCode.UNKNOWN); switch (pair.getKey().getType()) { case TRUNCATE: handleDoTruncate(pair.getKey().getEntry().getIndex(), pair.getKey(), pair.getValue()); break; case COMPARE: handleDoCompare(pair.getKey().getEntry().getIndex(), pair.getKey(), pair.getValue()); break; case COMMIT: handleDoCommit(pair.getKey().getCommitIndex(), pair.getKey(), pair.getValue()); break; default: break; } } else { // @3 long nextIndex = dLedgerStore.getLedgerEndIndex() + 1; Pair<PushEntryRequest, CompletableFuture<PushEntryResponse>> pair = writeRequestMap.remove(nextIndex); if (pair == null) { checkAbnormalFuture(dLedgerStore.getLedgerEndIndex()); waitForRunning(1); return; } PushEntryRequest request = pair.getKey(); handleDoAppend(nextIndex, request, pair.getValue()); } } catch (Throwable t) { DLedgerEntryPusher.logger.error("Error in {}", getName(), t); DLedgerUtils.sleep(100); } } 代码@1:如果当前节点的状态不是从节点,则跳出。 代码@2:如果 compareOrTruncateRequests 队列不为空,说明有COMMIT、COMPARE、TRUNCATE 等请求,这类请求优先处理。值得注意的是这里使用是 peek、poll 等非阻塞方法,然后根据请求的类型,调用对应的方法。稍后详细介绍。 代码@3:如果只有 append 类请求,则根据当前节点最大的消息序号,尝试从 writeRequestMap 容器中,获取下一个消息复制请求(ledgerEndIndex + 1) 为 key 去查找。如果不为空,则执行 doAppend 请求,如果为空,则调用 checkAbnormalFuture 来处理异常情况。 接下来我们来重点分析各个处理细节。 3.3.1 handleDoCommit 处理提交请求,其处理比较简单,就是调用 DLedgerStore 的 updateCommittedIndex 更新其已提交偏移量,故我们还是具体看一下DLedgerStore 的 updateCommittedIndex 方法。 DLedgerMmapFileStore#updateCommittedIndex public void updateCommittedIndex(long term, long newCommittedIndex) { // @1 if (newCommittedIndex == -1 || ledgerEndIndex == -1 || term < memberState.currTerm() || newCommittedIndex == this.committedIndex) { // @2 return; } if (newCommittedIndex < this.committedIndex || newCommittedIndex < this.ledgerBeginIndex) { // @3 logger.warn("[MONITOR]Skip update committed index for new={} < old={} or new={} < beginIndex={}", newCommittedIndex, this.committedIndex, newCommittedIndex, this.ledgerBeginIndex); return; } long endIndex = ledgerEndIndex; if (newCommittedIndex > endIndex) { // @4 //If the node fall behind too much, the committedIndex will be larger than enIndex. newCommittedIndex = endIndex; } DLedgerEntry dLedgerEntry = get(newCommittedIndex); // @5 PreConditions.check(dLedgerEntry != null, DLedgerResponseCode.DISK_ERROR); this.committedIndex = newCommittedIndex; this.committedPos = dLedgerEntry.getPos() + dLedgerEntry.getSize(); // @6 } 代码@1:首先介绍一下方法的参数: long term主节点当前的投票轮次。 long newCommittedIndex:主节点发送日志复制请求时的已提交日志序号。 代码@2:如果待更新提交序号为 -1 或 投票轮次小于从节点的投票轮次或主节点投票轮次等于从节点的已提交序号,则直接忽略本次提交动作。 代码@3:如果主节点的已提交日志序号小于从节点的已提交日志序号或待提交序号小于当前节点的最小有效日志序号,则输出警告日志[MONITOR],并忽略本次提交动作。 代码@4:如果从节点落后主节点太多,则重置 提交索引为从节点当前最大有效日志序号。 代码@5:尝试根据待提交序号从从节点查找数据,如果数据不存在,则抛出 DISK_ERROR 错误。 代码@6:更新 commitedIndex、committedPos 两个指针,DledgerStore会定时将已提交指针刷入 checkpoint 文件,达到持久化 commitedIndex 指针的目的。 3.3.2 handleDoCompare 处理主节点发送过来的 COMPARE 请求,其实现也比较简单,最终调用 buildResponse 方法构造响应结果。 EntryHandler#buildResponse private PushEntryResponse buildResponse(PushEntryRequest request, int code) { PushEntryResponse response = new PushEntryResponse(); response.setGroup(request.getGroup()); response.setCode(code); response.setTerm(request.getTerm()); if (request.getType() != PushEntryRequest.Type.COMMIT) { response.setIndex(request.getEntry().getIndex()); } response.setBeginIndex(dLedgerStore.getLedgerBeginIndex()); response.setEndIndex(dLedgerStore.getLedgerEndIndex()); return response; } 主要也是返回当前从几点的 ledgerBeginIndex、ledgerEndIndex 以及投票轮次,供主节点进行判断比较。 3.3.3 handleDoTruncate handleDoTruncate 方法实现比较简单,删除从节点上 truncateIndex 日志序号之后的所有日志,具体调用dLedgerStore 的 truncate 方法,由于其存储与 RocketMQ 的存储设计基本类似故本文就不在详细介绍,简单介绍其实现要点:根据日志序号,去定位到日志文件,如果命中具体的文件,则修改相应的读写指针、刷盘指针等,并将所在在物理文件之后的所有文件删除。大家如有兴趣,可以查阅笔者的《RocketMQ技术内幕》第4章:RocketMQ 存储相关内容。 3.3.4 handleDoAppend private void handleDoAppend(long writeIndex, PushEntryRequest request, CompletableFuture<PushEntryResponse> future) { try { PreConditions.check(writeIndex == request.getEntry().getIndex(), DLedgerResponseCode.INCONSISTENT_STATE); DLedgerEntry entry = dLedgerStore.appendAsFollower(request.getEntry(), request.getTerm(), request.getLeaderId()); PreConditions.check(entry.getIndex() == writeIndex, DLedgerResponseCode.INCONSISTENT_STATE); future.complete(buildResponse(request, DLedgerResponseCode.SUCCESS.getCode())); dLedgerStore.updateCommittedIndex(request.getTerm(), request.getCommitIndex()); } catch (Throwable t) { logger.error("[HandleDoWrite] writeIndex={}", writeIndex, t); future.complete(buildResponse(request, DLedgerResponseCode.INCONSISTENT_STATE.getCode())); } } 其实现也比较简单,调用DLedgerStore 的 appendAsFollower 方法进行日志的追加,与appendAsLeader 在日志存储部分相同,只是从节点无需再转发日志。 3.3.5 checkAbnormalFuture 该方法是本节的重点,doWork 的从服务器存储的最大有效日志序号(ledgerEndIndex) + 1 序号,尝试从待写请求中获取不到对应的请求时调用,这种情况也很常见,例如主节点并么有将最新的数据 PUSH 给从节点。接下来我们详细来看看该方法的实现细节。EntryHandler#checkAbnormalFuture if (DLedgerUtils.elapsed(lastCheckFastForwardTimeMs) < 1000) { return; } lastCheckFastForwardTimeMs = System.currentTimeMillis(); if (writeRequestMap.isEmpty()) { return; } Step1:如果上一次检查的时间距现在不到1s,则跳出;如果当前没有积压的append请求,同样跳出,因为可以同样明确的判断出主节点还未推送日志。 EntryHandler#checkAbnormalFuture for (Pair<PushEntryRequest, CompletableFuture<PushEntryResponse>> pair : writeRequestMap.values()) { long index = pair.getKey().getEntry().getIndex(); // @1 //Fall behind if (index <= endIndex) { // @2 try { DLedgerEntry local = dLedgerStore.get(index); PreConditions.check(pair.getKey().getEntry().equals(local), DLedgerResponseCode.INCONSISTENT_STATE); pair.getValue().complete(buildResponse(pair.getKey(), DLedgerResponseCode.SUCCESS.getCode())); logger.warn("[PushFallBehind]The leader pushed an entry index={} smaller than current ledgerEndIndex={}, maybe the last ack is missed", index, endIndex); } catch (Throwable t) { logger.error("[PushFallBehind]The leader pushed an entry index={} smaller than current ledgerEndIndex={}, maybe the last ack is missed", index, endIndex, t); pair.getValue().complete(buildResponse(pair.getKey(), DLedgerResponseCode.INCONSISTENT_STATE.getCode())); } writeRequestMap.remove(index); continue; } //Just OK if (index == endIndex + 1) { // @3 //The next entry is coming, just return return; } //Fast forward TimeoutFuture<PushEntryResponse> future = (TimeoutFuture<PushEntryResponse>) pair.getValue(); // @4 if (!future.isTimeOut()) { continue; } if (index < minFastForwardIndex) { // @5 minFastForwardIndex = index; } } Step2:遍历当前待写入的日志追加请求(主服务器推送过来的日志复制请求),找到需要快速快进的的索引。其关键实现点如下: 代码@1:首先获取待写入日志的序号。 代码@2:如果待写入的日志序号小于从节点已追加的日志(endIndex),并且日志的确已存储在从节点,则返回成功,并输出警告日志【PushFallBehind】,继续监测下一条待写入日志。 代码@3:如果待写入 index 等于 endIndex + 1,则结束循环,因为下一条日志消息已经在待写入队列中,即将写入。 代码@4:如果待写入 index 大于 endIndex + 1,并且未超时,则直接检查下一条待写入日志。 代码@5:如果待写入 index 大于 endIndex + 1,并且已经超时,则记录该索引,使用 minFastForwardIndex 存储。 EntryHandler#checkAbnormalFuture if (minFastForwardIndex == Long.MAX_VALUE) { return; } Pair<PushEntryRequest, CompletableFuture<PushEntryResponse>> pair = writeRequestMap.get(minFastForwardIndex); if (pair == null) { return; } Step3:如果未找到需要快速失败的日志序号或 writeRequestMap 中未找到其请求,则直接结束检测。 EntryHandler#checkAbnormalFuture logger.warn("[PushFastForward] ledgerEndIndex={} entryIndex={}", endIndex, minFastForwardIndex); pair.getValue().complete(buildResponse(pair.getKey(), DLedgerResponseCode.INCONSISTENT_STATE.getCode())); Step4:则向主节点报告从节点已经与主节点发生了数据不一致,从节点并没有写入序号 minFastForwardIndex 的日志。如果主节点收到此种响应,将会停止日志转发,转而向各个从节点发送 COMPARE 请求,从而使数据恢复一致。 行为至此,已经详细介绍了主服务器向从服务器发送请求,从服务做出响应,那接下来就来看一下,服务端收到响应结果后的处理,我们要知道主节点会向它所有的从节点传播日志,主节点需要在指定时间内收到超过集群一半节点的确认,才能认为日志写入成功,那我们接下来看一下其实现过程。 4、QuorumAckChecker 日志复制投票器,一个日志写请求只有得到集群内的的大多数节点的响应,日志才会被提交。 4.1 类图 其核心属性如下: long lastPrintWatermarkTimeMs上次打印水位线的时间戳,单位为毫秒。 long lastCheckLeakTimeMs上次检测泄漏的时间戳,单位为毫秒。 long lastQuorumIndex已投票仲裁的日志序号。 4.2 doWork 详解 QuorumAckChecker#doWork if (DLedgerUtils.elapsed(lastPrintWatermarkTimeMs) > 3000) { logger.info("[{}][{}] term={} ledgerBegin={} ledgerEnd={} committed={} watermarks={}", memberState.getSelfId(), memberState.getRole(), memberState.currTerm(), dLedgerStore.getLedgerBeginIndex(), dLedgerStore.getLedgerEndIndex(), dLedgerStore.getCommittedIndex(), JSON.toJSONString(peerWaterMarksByTerm)); lastPrintWatermarkTimeMs = System.currentTimeMillis(); } Step1:如果离上一次打印 watermak 的时间超过3s,则打印一下当前的 term、ledgerBegin、ledgerEnd、committed、peerWaterMarksByTerm 这些数据日志。 QuorumAckChecker#doWork if (!memberState.isLeader()) { // @2 waitForRunning(1); return; } Step2:如果当前节点不是主节点,直接返回,不作为。 QuorumAckChecker#doWork if (pendingAppendResponsesByTerm.size() > 1) { // @1 for (Long term : pendingAppendResponsesByTerm.keySet()) { if (term == currTerm) { continue; } for (Map.Entry<Long, TimeoutFuture<AppendEntryResponse>> futureEntry : pendingAppendResponsesByTerm.get(term).entrySet()) { AppendEntryResponse response = new AppendEntryResponse(); response.setGroup(memberState.getGroup()); response.setIndex(futureEntry.getKey()); response.setCode(DLedgerResponseCode.TERM_CHANGED.getCode()); response.setLeaderId(memberState.getLeaderId()); logger.info("[TermChange] Will clear the pending response index={} for term changed from {} to {}", futureEntry.getKey(), term, currTerm); futureEntry.getValue().complete(response); } pendingAppendResponsesByTerm.remove(term); } } if (peerWaterMarksByTerm.size() > 1) { for (Long term : peerWaterMarksByTerm.keySet()) { if (term == currTerm) { continue; } logger.info("[TermChange] Will clear the watermarks for term changed from {} to {}", term, currTerm); peerWaterMarksByTerm.remove(term); } } Step3:清理pendingAppendResponsesByTerm、peerWaterMarksByTerm 中本次投票轮次的数据,避免一些不必要的内存使用。 Map<String, Long> peerWaterMarks = peerWaterMarksByTerm.get(currTerm); long quorumIndex = -1; for (Long index : peerWaterMarks.values()) { // @1 int num = 0; for (Long another : peerWaterMarks.values()) { // @2 if (another >= index) { num++; } } if (memberState.isQuorum(num) && index > quorumIndex) { // @3 quorumIndex = index; } } dLedgerStore.updateCommittedIndex(currTerm, quorumIndex); // @4 Step4:根据各个从节点反馈的进度,进行仲裁,确定已提交序号。为了加深对这段代码的理解,再来啰嗦一下 peerWaterMarks 的作用,存储的是各个从节点当前已成功追加的日志序号。例如一个三节点的 DLedger 集群,peerWaterMarks 数据存储大概如下: { “dledger_group_01_0” : 100, "dledger_group_01_1" : 101, } 其中 dledger_group_01_0 为从节点1的ID,当前已复制的序号为 100,而 dledger_group_01_1 为节点2的ID,当前已复制的序号为 101。再加上主节点,如何确定可提交序号呢? 代码@1:首先遍历 peerWaterMarks 的 value 集合,即上述示例中的 {100, 101},用临时变量 index 来表示待投票的日志序号,需要集群内超过半数的节点的已复制序号超过该值,则该日志能被确认提交。 代码@2:遍历 peerWaterMarks 中的所有已提交序号,与当前值进行比较,如果节点的已提交序号大于等于待投票的日志序号(index),num 加一,表示投赞成票。 代码@3:对 index 进行仲裁,如果超过半数 并且 index 大于 quorumIndex,更新 quorumIndex 的值为 index。quorumIndex 经过遍历的,得出当前最大的可提交日志序号。 代码@4:更新 committedIndex 索引,方便 DLedgerStore 定时将 committedIndex 写入 checkpoint 中。 ConcurrentMap<Long, TimeoutFuture<AppendEntryResponse>> responses = pendingAppendResponsesByTerm.get(currTerm); boolean needCheck = false; int ackNum = 0; if (quorumIndex >= 0) { for (Long i = quorumIndex; i >= 0; i--) { // @1 try { CompletableFuture<AppendEntryResponse> future = responses.remove(i); // @2 if (future == null) { // @3 needCheck = lastQuorumIndex != -1 && lastQuorumIndex != quorumIndex && i != lastQuorumIndex; break; } else if (!future.isDone()) { // @4 AppendEntryResponse response = new AppendEntryResponse(); response.setGroup(memberState.getGroup()); response.setTerm(currTerm); response.setIndex(i); response.setLeaderId(memberState.getSelfId()); response.setPos(((AppendFuture) future).getPos()); future.complete(response); } ackNum++; // @5 } catch (Throwable t) { logger.error("Error in ack to index={} term={}", i, currTerm, t); } } } Step5:处理 quorumIndex 之前的挂起请求,需要发送响应到客户端,其实现步骤: 代码@1:从 quorumIndex 开始处理,没处理一条,该序号减一,直到大于0或主动退出,请看后面的退出逻辑。 代码@2:responses 中移除该日志条目的挂起请求。 代码@3:如果未找到挂起请求,说明前面挂起的请求已经全部处理完毕,准备退出,退出之前再 设置 needCheck 的值,其依据如下(三个条件必须同时满足): 最后一次仲裁的日志序号不等于-1 并且最后一次不等于本次新仲裁的日志序号 最后一次仲裁的日志序号不等于最后一次仲裁的日志。正常情况一下,条件一、条件二通常为true,但这一条大概率会返回false。 代码@4:向客户端返回结果。 代码@5:ackNum,表示本次确认的数量。 if (ackNum == 0) { for (long i = quorumIndex + 1; i < Integer.MAX_VALUE; i++) { TimeoutFuture<AppendEntryResponse> future = responses.get(i); if (future == null) { break; } else if (future.isTimeOut()) { AppendEntryResponse response = new AppendEntryResponse(); response.setGroup(memberState.getGroup()); response.setCode(DLedgerResponseCode.WAIT_QUORUM_ACK_TIMEOUT.getCode()); response.setTerm(currTerm); response.setIndex(i); response.setLeaderId(memberState.getSelfId()); future.complete(response); } else { break; } } waitForRunning(1); } Step6:如果本次确认的个数为0,则尝试去判断超过该仲裁序号的请求,是否已经超时,如果已超时,则返回超时响应结果。 if (DLedgerUtils.elapsed(lastCheckLeakTimeMs) > 1000 || needCheck) { updatePeerWaterMark(currTerm, memberState.getSelfId(), dLedgerStore.getLedgerEndIndex()); for (Map.Entry<Long, TimeoutFuture<AppendEntryResponse>> futureEntry : responses.entrySet()) { if (futureEntry.getKey() < quorumIndex) { AppendEntryResponse response = new AppendEntryResponse(); response.setGroup(memberState.getGroup()); response.setTerm(currTerm); response.setIndex(futureEntry.getKey()); response.setLeaderId(memberState.getSelfId()); response.setPos(((AppendFuture) futureEntry.getValue()).getPos()); futureEntry.getValue().complete(response); responses.remove(futureEntry.getKey()); } } lastCheckLeakTimeMs = System.currentTimeMillis(); } Step7:检查是否发送泄漏。其判断泄漏的依据是如果挂起的请求的日志序号小于已提交的序号,则移除。 Step8:一次日志仲裁就结束了,最后更新 lastQuorumIndex 为本次仲裁的的新的提交值。 关于 DLedger 的日志复制部分就介绍到这里了。本文篇幅较长,看到这里的各位亲爱的读者朋友们,麻烦点个赞,谢谢。 推荐阅读:源码分析RocketMQ DLedger 多副本系列专栏1、RocketMQ 多副本前置篇:初探raft协议2、源码分析 RocketMQ DLedger 多副本之 Leader 选主3、源码分析 RocketMQ DLedger 多副本存储实现4、源码分析 RocketMQ DLedger(多副本) 之日志追加流程 原文发布时间为:2019-09-23本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
Mybatis 与 Hibernate 一样,支持一二级缓存。一级缓存指的是 Session 级别的缓存,即在一个会话中多次执行同一条 SQL 语句并且参数相同,则后面的查询将不会发送到数据库,直接从 Session 缓存中获取。二级缓存,指的是 SessionFactory 级别的缓存,即不同的会话可以共享。 缓存,通常涉及到缓存的写、读、过期(更新缓存)等几个方面,请带着这些问题一起来探究Mybatis关于缓存的实现原理吧。 提出问题:缓存的查询顺序,是先查一级缓存还是二级缓存? 本文以SQL查询与更新两个流程来揭开Mybatis缓存实现的细节。 源码分析Mybatis系列目录:1、源码分析Mybatis MapperProxy初始化之Mapper对象的扫描与构建2、源码分析Mybatis MappedStatement的创建流程3、Mybatis执行SQL的4大基础组件详解4、源码解析MyBatis Sharding-Jdbc SQL语句执行流程详解 1、从 SQL 查询流程看一二级缓存 温馨提示,本文不会详细介绍详细的 SQL 执行流程,如果对其感兴趣,可以查阅笔者的另外一篇文章:源码分析Mybatis SQL执行流程 1.1 创建Executor Configuration#newExecutor public Executor newExecutor(Transaction transaction, ExecutorType executorType) { executorType = executorType == null ? defaultExecutorType : executorType; executorType = executorType == null ? ExecutorType.SIMPLE : executorType; Executor executor; if (ExecutorType.BATCH == executorType) { executor = new BatchExecutor(this, transaction); } else if (ExecutorType.REUSE == executorType) { executor = new ReuseExecutor(this, transaction); } else { executor = new SimpleExecutor(this, transaction); } if (cacheEnabled) { // @1 executor = new CachingExecutor(executor); // @2 } executor = (Executor) interceptorChain.pluginAll(executor); return executor; } 代码@1:如果 cacheEnabled 为 true,表示开启缓存机制,缓存的实现类为 CachingExecutor,这里使用了经典的装饰模式,处理了缓存的相关逻辑后,委托给的具体的 Executor 执行。 cacheEnable 在实际的使用中通过在 mybatis-config.xml 文件中指定,例如: <configuration> <settings> <setting name="cacheEnabled" value="true"> </settings> </configuration> 该值默认为true。 1.2 CachingExecutor#query public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException { BoundSql boundSql = ms.getBoundSql(parameterObject); // @1 CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql); // @2 return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); // @3 } 代码@1:根据参数生成SQL语句。 代码@2:根据 MappedStatement、参数、分页参数、SQL 生成缓存 Key。 代码@3:调用6个参数的 query 方法。 缓存 Key 的创建比较简单,本文就只贴出代码,大家一目了然,大家重点关注组成缓存Key的要素。BaseExecute#createCacheKey public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) { if (closed) { throw new ExecutorException("Executor was closed."); } CacheKey cacheKey = new CacheKey(); cacheKey.update(ms.getId()); cacheKey.update(rowBounds.getOffset()); cacheKey.update(rowBounds.getLimit()); cacheKey.update(boundSql.getSql()); List<ParameterMapping> parameterMappings = boundSql.getParameterMappings(); TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry(); // mimic DefaultParameterHandler logic for (ParameterMapping parameterMapping : parameterMappings) { if (parameterMapping.getMode() != ParameterMode.OUT) { Object value; String propertyName = parameterMapping.getProperty(); if (boundSql.hasAdditionalParameter(propertyName)) { value = boundSql.getAdditionalParameter(propertyName); } else if (parameterObject == null) { value = null; } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) { value = parameterObject; } else { MetaObject metaObject = configuration.newMetaObject(parameterObject); value = metaObject.getValue(propertyName); } cacheKey.update(value); } } if (configuration.getEnvironment() != null) { // issue #176 cacheKey.update(configuration.getEnvironment().getId()); } return cacheKey; } 接下来重点看CachingExecutor的另外一个query方法。 CachingExecutor#query public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { Cache cache = ms.getCache(); // @1 if (cache != null) { flushCacheIfRequired(ms); // @2 if (ms.isUseCache() && resultHandler == null) { ensureNoOutParams(ms, boundSql); @SuppressWarnings("unchecked") List<E> list = (List<E>) tcm.getObject(cache, key); // @3 if (list == null) { // @4 list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); //@5 tcm.putObject(cache, key, list); // issue #578 and #116 // @6 } return list; } } return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); //@7 } 代码@1:获取 MappedStatement 中的 Cache cache 属性。代码@2:如果不为空,则尝试从缓存中获取,否则直接委托给具体的执行器执行,例如 SimpleExecutor (@7)。代码@3:尝试从缓存中根据缓存 Key 查找。代码@4:如果从缓存中获取的值不为空,则直接返回缓存中的值,否则先从数据库查询@5,将查询结果更新到缓存中。 这里的缓存即 MappedStatement 中的 Cache 对象是一级缓存还是二级缓存?通常在 ORM 类框架中,Session 级别的缓存为一级缓存,即会话结束后就会失效,显然这里不会随着 Session 的失效而失效,因为 Cache 对象是存储在于 MappedStatement 对象中的,每一个 MappedStatement 对象代表一个 Dao(Mapper) 中的一个方法,即代表一条对应的 SQL 语句,是一个全局的概念。 相信大家也会觉得,想继续深入了解 CachingExecutor 中使用的 Cache 是一级缓存还是二级缓存,了解 Cache 对象的创建至关重要。关于 MappedStatement 的创建流程,建议查阅笔者的另外一篇博文:源码分析Mybatis MappedStatement的创建流程。 本文只会关注 MappedStatement 对象流程中关于于缓存相关的部分。 接下来将按照先二级缓存,再一级缓存的思路进行讲解。 1.2.1 二级缓存 1.2.1.1 MappedStatement#cache属性创建机制 从上面看,如果 cacheEnable 为 true 并且 MappedStatement 对象的 cache 属性不为空,则能使用二级缓存。 我们可以看到 MappedStatement 对象的 cache 属性赋值的地方为:MapperBuilderAssistant#addMappedStatement,从该方法的调用链可以得知是在解析 Mapper 定义的时候就会创建。使用的 cache 属性为 MapperBuilderAssistant 的 currentCache,我们跟踪一下该属性的赋值方法: public Cache useCacheRef(String namespace) 其调用链如下:可以看出是在解析 cacheRef 标签,即在解析 Mapper.xml 文件中的 cacheRef 标签时,即二级缓存的使用和 cacheRef 标签离不开关系,并且特别注意一点,其参数为 namespace,即每一个 namespace 对应一个 Cache 对象,在 Mybatis 的方法中,通常namespace 对一个 Mapper.java 对象,对应对数据库一张表的更新、新增操作。 public Cache useNewCache 其调用链如下图所示:在解析 Mapper.xml 文件中的 cache 标签时被调用。 1.2.1.2 cache标签解析 接下来我们根据 cache 标签简单看一下 cache 标签的解析,下面以 xml 配置方式为例展开,基于注解的解析,其原理类似,其代码 XMLMapperBuilder 的 cacheElement 方法。 private void cacheElement(XNode context) throws Exception { if (context != null) { String type = context.getStringAttribute("type", "PERPETUAL"); Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type); String eviction = context.getStringAttribute("eviction", "LRU"); Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction); Long flushInterval = context.getLongAttribute("flushInterval"); Integer size = context.getIntAttribute("size"); boolean readWrite = !context.getBooleanAttribute("readOnly", false); boolean blocking = context.getBooleanAttribute("blocking", false); Properties props = context.getChildrenAsProperties(); builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props); } } 从上面 cache 标签的核心属性如下: type缓存实现类,可选择值:PERPETUAL、LRU 等,Mybatis 中所有的缓存实现类如下: eviction移除算法,默认为 LRU。 flushInterval缓存过期时间。 size 缓存在内存中的缓存个数。 readOnly是否是只读。 blocking是否阻塞,具体实现请看 BlockingCache。 1.2.1.3 cacheRef cacheRef 只有一个属性,就是 namespace,就是引用其他 namespace 中的 cache。 Cache 的创建流程就讲解到这里,同一个 Namespace 只会定义一个 Cache。二级缓存的创建是在 *Mapper.xml 文件中使用了< cache/>、< cacheRef/>标签时创建,并且会按 NameSpace 为维度,为各个 MapperStatement 传入它所属的 Namespace 的二级缓存对象。 二级缓存的查询逻辑就介绍到这里了,我们再次回成 CacheingExecutor 的查询方法:CachingExecutor#query public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { Cache cache = ms.getCache(); // @1 if (cache != null) { flushCacheIfRequired(ms); // @2 if (ms.isUseCache() && resultHandler == null) { ensureNoOutParams(ms, boundSql); @SuppressWarnings("unchecked") List<E> list = (List<E>) tcm.getObject(cache, key); // @3 if (list == null) { // @4 list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); //@5 tcm.putObject(cache, key, list); // issue #578 and #116 // @6 } return list; } } return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); //@7 } 如果 MappedStatement 的 cache 属性为空,则直接调用内部的 Executor 的查询方法。也就时如果在 *.Mapper.xm l文件中未定义< cache/>或< cacheRef/>,则 cache 属性会为空。 1.2.2 一级缓存 Mybatis 根据 SQL 的类型共有如下3种 Executor类型,分别是 SIMPLE, REUSE, BATCH,本文将以 SimpleExecutor为 例来对一级缓存的介绍。 BaseExecutor#query public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId()); if (closed) { throw new ExecutorException("Executor was closed."); } if (queryStack == 0 && ms.isFlushCacheRequired()) { // @1 clearLocalCache(); } List<E> list; try { queryStack++; list = resultHandler == null ? (List<E>) localCache.getObject(key) : null; // @2 if (list != null) { handleLocallyCachedOutputParameters(ms, key, parameter, boundSql); } else { list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); // @3 } } finally { queryStack--; } if (queryStack == 0) { for (DeferredLoad deferredLoad : deferredLoads) { deferredLoad.load(); } // issue #601 deferredLoads.clear(); if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) { // issue #482 clearLocalCache(); } } return list; } 代码@1:queryStack:查询栈,每次查询之前,加一,查询返回结果后减一,如果为1,表示整个会会话中没有执行的查询语句,并根据 MappedStatement 是否需要执行清除缓存,如果是查询类的请求,无需清除缓存,如果是更新类操作的MappedStatemt,每次执行之前都需要清除缓存。代码@2:如果缓存中存在,直接返回缓存中的数据。代码@3:如果缓存未命中,则调用 queryFromDatabase 从数据中查询。 我们顺便看一下 queryFromDatabase 方法,再来看一下一级缓存的实现类。 private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { List<E> list; localCache.putObject(key, EXECUTION_PLACEHOLDER); //@! try { list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql); // @2 } finally { localCache.removeObject(key); // @3 } localCache.putObject(key, list); // @4 if (ms.getStatementType() == StatementType.CALLABLE) { localOutputParameterCache.putObject(key, parameter); } return list; } 代码@1:先往本地遍历存储一个厂里,表示正在执行中。代码@2:从数据中查询数据。代码@3:先移除正在执行中的标记。代码@4:将数据库中的值存储到一级缓存中。 可以看出一级缓存的属性为 localCache,为 Executor 的属性。如果大家看过笔者发布的这个 Mybatis 系列就能轻易得出一个结论,每一个 SQL 会话对应一个 SqlSession 对象,每一个 SqlSession 会对应一个 Executor 对象,故 Executor 级别的缓存即为Session 级别的缓存,即为 Mybatis 的一级缓存。 上面已经介绍了一二级缓存的查找与添加,在查询的时候,首先查询缓存,如果缓存未命中,则查询数据库,然后将查询到的结果存入缓存中。 下面我们来简单看看缓存的更新。 2、从SQL更新流程看一二级缓存 从更新的角度,更加的是关注缓存的更新,即当数据发生变化后,如果清除对应的缓存。 2.1 二级缓存 CachingExecutor#update public int update(MappedStatement ms, Object parameterObject) throws SQLException { flushCacheIfRequired(ms); // @1 return delegate.update(ms, parameterObject); // @2 } 代码@1:如果有必要则刷新缓存。代码@2:调用内部的 Executor,例如 SimpleExecutor。 接下来重点看一下 flushCacheIfRequired 方法。 private void flushCacheIfRequired(MappedStatement ms) { Cache cache = ms.getCache(); if (cache != null && ms.isFlushCacheRequired()) { tcm.clear(cache); } } TransactionalCacheManager#clear public void clear(Cache cache) { getTransactionalCache(cache).clear(); } TransactionalCacheManager 事务缓存管理器,其实就是对 MappedStatement 的 cache 属性进行装饰,最终调用的还是MappedStatement 的 getCache 方法得到其缓存对象然后调用 clear 方法,清空所有的缓存,即缓存的更新策略是只要namespace 的任何一条插入或更新语句执行,整个 namespace 的缓存数据将全部清空。 2.2 一级缓存的更新 public int update(MappedStatement ms, Object parameter) throws SQLException { ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId()); if (closed) { throw new ExecutorException("Executor was closed."); } clearLocalCache(); return doUpdate(ms, parameter); } 其更新策略与二级缓存维护的一样。 一二级缓存的的新增、查询、更新就介绍到这里了,接下来对其进行一个总结。 3、总结 3.1 一二级缓存作用序列图 Mybatis 一二级缓存时序图如下: 3.2 如何使用二级缓存 1、在mybatis-config.xml中将cacheEnable设置为true。例如: <configuration> <settings> <setting name="cacheEnabled" value="true"> </settings> </configuration> 不过该值默认为true。 2、在需要缓存的表操作,对应的 Dao 的配置文件中,例如 *Mapper.xml 文件中使用 cache、或 cacheRef 标签来定义缓存。 <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace="com.winterchen.dao.UserDao" > <insert id="insert" parameterType="com.winterchen.model.UserDomain"> //省略 </insert> <select id="selectUsers" resultType="com.winterchen.model.UserDomain"> //省略 </select> <cache type="lru" readOnly="true" flushInterval="3600000"></cache> </mapper> 这样就定义了一个 Cache,其 namespace 为 com.winterchen.dao.UserDao。其中 flushInterval 定义该 cache 定时清除的时间间隔,单位为 ms。 如果一个表的更新操作、新增操作位于不同的 Mapper.xml 文件中,如果对一个表的操作的 Cache 定义在不同的文件,则缓存数据则会出现不一致的情况,因为 Cache 的更新逻辑是,在一个 Namespace 中,如果有更新、插入语句的执行,则会清除该 namespace 对应的 cache 里面的所有缓存。那怎么来处理这种场景呢?cacheRef 闪亮登场。 如果一个 Mapper.xml 文件需要引入定义在别的 Mapper.xml 文件中定义的 cache,则使用 cacheRef,示例如下: <cacheRef "namespace" = "com.winterchen.dao.UserDao"/> 一级缓存默认是开启的,也无法关闭。 原文发布时间为:2019-08-26本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
源码分析Mybatis系列目录:1、源码分析Mybatis MapperProxy初始化之Mapper对象的扫描与构建2、源码分析Mybatis MappedStatement的创建流程3、Mybatis执行SQL的4大基础组件详解4、源码解析MyBatis Sharding-Jdbc SQL语句执行流程详解 有了《Mybatis执行SQL的4大基础组件详解》 与 《源码解析MyBatis Sharding-Jdbc SQL语句执行流程详解》两篇文章的铺垫,本文将直奔主题:Mybatis插件机制。 温馨提示:本文也是以提问式阅读与探究源码的技巧展示。 1、回顾 从前面的文章我们已经知道,Mybatis在执行SQL语句的扩展点为Executor、StatementHandler、ParameterHandler与ResultSetHandler,我们本节将以Executor为入口,向大家展示Mybatis插件的扩展机制。 我们先来看回顾一下Mybatis Executor的创建入口。 1.1 Configuration#newExecutor public Executor newExecutor(Transaction transaction, ExecutorType executorType) { executorType = executorType == null ? defaultExecutorType : executorType; executorType = executorType == null ? ExecutorType.SIMPLE : executorType; Executor executor; if (ExecutorType.BATCH == executorType) { executor = new BatchExecutor(this, transaction); } else if (ExecutorType.REUSE == executorType) { executor = new ReuseExecutor(this, transaction); } else { executor = new SimpleExecutor(this, transaction); } if (cacheEnabled) { executor = new CachingExecutor(executor); } executor = (Executor) interceptorChain.pluginAll(executor); // @1 return executor; } 代码@1,:使用InterceptorChain.pluginAll(executor)进行拆件化处理。 思考:使用该方法调用后,会返回一个什么对象呢?如何自定义拆件,自定义插件如何执行呢? 那接下来我们带着上述疑问,从InterceptorChain类开始进行深入学习。 2、InterceptorChain 从名字上看其大意为拦截器链。 2.1 类图 InterceptorChain拦截器链,其内部维护一个interceptors,表示拦截器链中所有的拦截器,并提供增加或获取拦截器链的方法,下面会重点分析pluginAll方法。 Interceptor拦截器接口,用户自定义的拦截器需要实现该接口。 Invocation拦截器执行时的上下文环境,其实就是目标方法的调用信息,包含目标对象、调用的方法信息、参数信息。其中包含一个非常重要的方法:proceed。 public Object proceed() throws InvocationTargetException, IllegalAccessException { return method.invoke(target, args); } 该方法的主要目的就是进行处理链的传播,执行完拦截器的方法后,最终需要调用目标方法的invoke方法。 记下来中先重点分析一下InterceptorChain方法的pluginAll方法,因为从开头也知道,Mybatis在创建对象时,是调用该方法,完成目标对象的包装。 2.2 核心方法一览 2.2.1 pluginAll public Object pluginAll(Object target) { // @1 for (Interceptor interceptor : interceptors) { // @2 target = interceptor.plugin(target); // @3 } return target; } 代码@1:目标对象,需要被代理的对象。 代码@2:遍历InterceptorChain的拦截器链,分别调用Intercpetor对象的Plugin进行拦截(@3)。 那接下来有三个疑问?问题1:InterceptorChain中的interceptors是从什么时候初始化的呢,即拦截链中的拦截器从何而来。问题2:从前面也得知,无论是创建Executor,还是创建StatementHandler等,都是调用InterceptorChain#pluginAll方法,那是不是拦截器中的拦截器都会作用与目标对象,这应该是有问题的,该如何处理?问题3:代理对象是如何创建的。 2.2.1 addInterceptor public void addInterceptor(Interceptor interceptor) { interceptors.add(interceptor); } 要想知道interceptors是如何初始化的,我们只需要查看该方法的调用链即可。 一路跟踪到源头,我们会发现在初始化SqlSessionFactory时,会解析一个标签plugin,就可以得知,会在SqlSessionFacotry的一个属性中配置所有的拦截器。具体配置示例如下: <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="shardingDataSource"/> <property name="mapperLocations" value="classpath*:META-INF/mybatis/mappers/OrderMapper.xml"/> <property name="plugins"> <array> <bean id = "teneantInteceptor" class="com.demo.inteceptor.TenaInteceptor"></bean> </array> </property> </bean> 问题1已经解决。但后面两个问题似乎没有什么突破口。由于目前所涉及的三个类,显然不足以给我们提供答案,我们先将目光移到InterceptorChain所在包中的其他类,看看其他类的职责如何。 3、Intercepts与Signature 在org.apache.ibatis.plugin中存在如下两个注解类:Intercepts与Signature,从字面意思就是用来配置拦截的方法信息。 Siganature注解的属性说明如下: Class<?> type :需要拦截目标对象的类。 String method:需要拦截目标类的方法名。 Class<?>[] args:需要拦截目标类的方法名的参数类型签名。 备注:至于如何得知上述字段的含义,请看下文的Plugin#getSignatureMap方法。 但另外一个类型Plugin类确引起了我的注意。接下来我们将重点分析Plugin方法。 4、Plugin详解 4.1 Plugin类图 其中InvocationHandler为JDK的动态代理机制中的事件执行器,我们可以隐约阈值代理对象的生成将基于JDK内置的动态代理机制。 Plugin的核心属性如下: Object target目标对象。 Interceptor interceptor拦截器对象。 Map, Set< Method>> signatureMap拦截器中的签名映射。 4.2 构造函数 private Plugin(Object target, Interceptor interceptor, Map<Class<?>, Set<Method>> signatureMap) { this.target = target; this.interceptor = interceptor; this.signatureMap = signatureMap; } 注意:其构造函数为私有的,那如何构建Plugin呢,其构造方法为Plugin的镜头方法wrap中被调用。 4.3 核心方法详解 4.3.1 wrap public static Object wrap(Object target, Interceptor interceptor) { Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor); // @1 Class<?> type = target.getClass(); Class<?>[] interfaces = getAllInterfaces(type, signatureMap); // @2 if (interfaces.length > 0) { return Proxy.newProxyInstance( // @3 type.getClassLoader(), interfaces, new Plugin(target, interceptor, signatureMap)); } return target; } 代码@1:获取待包装的Interceptor的方法签名映射表,稍后详细分析。 代码@2:获取需要代理的对象的Class上声明的所有接口。 代码@3:使用JDK内置的Proxy创建代理对象。Proxy创建代理对象的方法声明如下: public static Object newProxyInstance(ClassLoader loader,Class<?>[] interfaces,InvocationHandler h), 注意其事件处理器为Plugin,故在动态运行过程中会执行Plugin的invoker方法。 在进入Plugin#invoker方法学习之前,我们先重点查看一下getSignatureMap、getAllInterfaces的实现。 4.3.2 getSignatureMap private static Map<Class<?>, Set<Method>> getSignatureMap(Interceptor interceptor) { Intercepts interceptsAnnotation = interceptor.getClass().getAnnotation(Intercepts.class); // @1 if (interceptsAnnotation == null) { // issue #251 // @2 throw new PluginException("No @Intercepts annotation was found in interceptor " + interceptor.getClass().getName()); } Signature[] sigs = interceptsAnnotation.value(); // @3 Map<Class<?>, Set<Method>> signatureMap = new HashMap<Class<?>, Set<Method>>(); for (Signature sig : sigs) { Set<Method> methods = signatureMap.get(sig.type()); if (methods == null) { methods = new HashSet<Method>(); signatureMap.put(sig.type(), methods); } try { Method method = sig.type().getMethod(sig.method(), sig.args()); methods.add(method); } catch (NoSuchMethodException e) { throw new PluginException("Could not find method on " + sig.type() + " named " + sig.method() + ". Cause: " + e, e); } } return signatureMap; } 代码@1:首先从Interceptor的类上获取Intercepts注解。 代码@2:如果Interceptor的类上没有定义Intercepts注解,则抛出异常,说明我们在自定义插件时,必须要有Intercepts注解。 代码@3:解析Interceptor的values属性(Signature[])数组,然后存入HashMap, Set< Method>>容器内。 温馨提示:从这里可以得知:自定义的插件必须定义Intercepts注解,其注解的value值为Signature。 4.3.3 getAllInterfaces private static Class<?>[] getAllInterfaces(Class<?> type, Map<Class<?>, Set<Method>> signatureMap) { Set<Class<?>> interfaces = new HashSet<Class<?>>(); while (type != null) { for (Class<?> c : type.getInterfaces()) { if (signatureMap.containsKey(c)) { interfaces.add(c); } } type = type.getSuperclass(); } return interfaces.toArray(new Class<?>[interfaces.size()]); } 该方法的实现比较简单,并不是获取目标对象所实现的所有接口,而是返回需要拦截的方法所包括的接口。 4.3.4 invoke public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // @1 try { Set<Method> methods = signatureMap.get(method.getDeclaringClass()); if (methods != null && methods.contains(method)) { // @2 return interceptor.intercept(new Invocation(target, method, args)); // @3 } return method.invoke(target, args); // @4 } catch (Exception e) { throw ExceptionUtil.unwrapThrowable(e); } } 代码@1:首先对其参数列表做一个简单的说明: Object proxy 当前的代理对象 Method method 当前执行的方法 Object[] args 当前执行方法的参数 代码@2:获取当前执行方法所属的类,并获取需要被拦截的方法集合。 代码@3:如果需被拦截的方法集合包含当前执行的方法,则执行拦截器的interceptor方法。 代码@4:如果不是,则直接调用目标方法的Invoke方法。 从该方法可以看出Interceptor接口的intercept方法就是拦截器自身需要实现的逻辑,其参数为Invocation,在该方法的结束,需要调用invocation#proceed()方法,进行拦截器链的传播。 从目前的学习中,我们已经了解了Plugin.wrap方法就是生成带来带来类的唯一入口,那该方法在什么地方调用呢?从代码类库中没有找到该方法的调用链,说明该方法是供用户调用的。 再看InterceptorChain方法的pluginAll方法: public Object pluginAll(Object target) { // @1 for (Interceptor interceptor : interceptors) { // @2 target = interceptor.plugin(target); // @3 } return target; } 该方法会遍历用户定义的插件实现类(Interceptor),并调用Interceptor的plugin方法,对target进行拆件化处理,即我们在实现自定义的Interceptor方法时,在plugin中需要根据自己的逻辑,对目标对象进行包装(代理),创建代理对象,那我们就可以在该方法中使用Plugin#wrap来创建代理类。 接下来我们再来用序列图来对上述源码分析做一个总结:看到这里,大家是否对上面提出的3个问题都已经有了自己的答案了。 问题1:InterceptorChain中的interceptors是从什么时候初始化的呢,即拦截链中的拦截器从何而来。答:在初始化SqlSesstionFactory的时候,会解析属性plugins属性,会加载所有的拦截器到InterceptorChain中。 问题2:从前面也得知,无论是创建Executor,还是创建StatementHandler等,都是调用InterceptorChain#pluginAll方法,那是不是拦截器中的拦截器都会作用与目标对象,这应该是有问题的,该如何处理? 答案是在各自订阅的Interceptor#plugin方法中,我们可以根据传入的目标对象,是否是该拦截器关注的,如果不关注,则直接返回目标对象,如果关注,则使用Plugin#wrap方法创建代理对象。 问题3:代理对象是如何创建的?代理对象是使用JDK的动态代理机制创建,使用Plugin#wrap方法创建。 5、实践 实践是检验真理的唯一标准,那到底如何使用Mybatis的插件机制呢?创建自定义的拦截器Interceptor,实现Interceptor接口。1)实现plugin方法,在该方法中决定是否需要创建代理对象,如果创建,使用Plugin#wrap方法创建。2)实现interceptor方法,该方法中定义拦截器的逻辑,并且在最后请调用invocation.proceed()方法传递拦截器链。3)使用Intercepts注解,定义需要拦截目标对象的方法签名,支持多个。将实现的Interceptor在定义SqlSessionFactory的配置中,放入plugins属性。 最后给出一个Mybatis Plugin插件机制使用案例:基于Mycat+Mybatis的多租户方案:基于Mybatis与Mycat的多租户方式,通过Mybatis的插件机制,动态改写SQL语句来实现多租户 原文发布时间为:2019-05-30本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
源码分析Mybatis系列:1、源码分析Mybatis MapperProxy初始化之Mapper对象的扫描与构建2、源码分析Mybatis MappedStatement的创建流程3、Mybatis执行SQL的4大基础组件详解 本文将详细介绍Mybatis SQL语句执行的全流程,本文与上篇具有一定的关联性,建议先阅读该系列中的前面3篇文章,重点掌握Mybatis Mapper类的初始化过程,因为在Mybatis中,Mapper是执行SQL语句的入口,类似下面这段代码: @Service public UserService implements IUserService { @Autowired private UserMapper userMapper; public User findUser(Integer id) { return userMapper.find(id); } } 开始进入本文的主题,以源码为手段,分析Mybatis执行SQL语句的流行,并且使用了数据库分库分表中间件sharding-jdbc,其版本为sharding-jdbc1.4.1。 为了方便大家对本文的源码分析,先给出Mybatis层面核心类的方法调用序列图。 1、SQL执行序列图 2、源码解析SQL执行流程 接下来从从源码的角度对其进行剖析。 温馨提示:在本文的末尾,还会给出一张详细的Mybatis Shardingjdbc语句执行流程图。(请勿错过哦)。 2.1 MapperProxy#invoker public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (Object.class.equals(method.getDeclaringClass())) { try { return method.invoke(this, args); } catch (Throwable t) { throw ExceptionUtil.unwrapThrowable(t); } } final MapperMethod mapperMethod = cachedMapperMethod(method); // @1 return mapperMethod.execute(sqlSession, args); // @2 } 代码@1:创建并缓存MapperMethod对象。 代码@2:调用MapperMethod对象的execute方法,即mapperInterface中定义的每一个方法最终会对应一个MapperMethod。 2.2 MapperMethod#execute public Object execute(SqlSession sqlSession, Object[] args) { Object result; if (SqlCommandType.INSERT == command.getType()) { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.insert(command.getName(), param)); } else if (SqlCommandType.UPDATE == command.getType()) { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.update(command.getName(), param)); } else if (SqlCommandType.DELETE == command.getType()) { Object param = method.convertArgsToSqlCommandParam(args); result = rowCountResult(sqlSession.delete(command.getName(), param)); } else if (SqlCommandType.SELECT == command.getType()) { if (method.returnsVoid() && method.hasResultHandler()) { executeWithResultHandler(sqlSession, args); result = null; } else if (method.returnsMany()) { result = executeForMany(sqlSession, args); } else if (method.returnsMap()) { result = executeForMap(sqlSession, args); } else { Object param = method.convertArgsToSqlCommandParam(args); result = sqlSession.selectOne(command.getName(), param); } } else { throw new BindingException("Unknown execution method for: " + command.getName()); } if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) { throw new BindingException("Mapper method '" + command.getName() + " attempted to return null from a method with a primitive return type (" + method.getReturnType() + ")."); } return result; } 该方法主要是根据SQL类型,insert、update、select等操作,执行对应的逻辑,本文我们以查询语句,进行跟踪,进入executeForMany(sqlSession, args)方法。 2.3 MapperMethod#executeForMany private <E> Object executeForMany(SqlSession sqlSession, Object[] args) { List<E> result; Object param = method.convertArgsToSqlCommandParam(args); if (method.hasRowBounds()) { RowBounds rowBounds = method.extractRowBounds(args); result = sqlSession.<E>selectList(command.getName(), param, rowBounds); } else { result = sqlSession.<E>selectList(command.getName(), param); } // issue #510 Collections & arrays support if (!method.getReturnType().isAssignableFrom(result.getClass())) { if (method.getReturnType().isArray()) { return convertToArray(result); } else { return convertToDeclaredCollection(sqlSession.getConfiguration(), result); } } return result; } 该方法也比较简单,最终通过SqlSession调用selectList方法。 2.4 DefaultSqlSession#selectList public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) { try { MappedStatement ms = configuration.getMappedStatement(statement); // @1 List<E> result = executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER); // @2 return result; } catch (Exception e) { throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e); } finally { ErrorContext.instance().reset(); } } 代码@1:根据资源名称获取对应的MappedStatement对象,此时的statement为资源名称,例如com.demo.UserMapper.findUser。至于MappedStatement对象的生成在上一节初始化时已详细介绍过,此处不再重复介绍。 代码@2:调用Executor的query方法。这里说明一下,其实一开始会进入到CachingExecutor#query方法,由于CachingExecutor的Executor delegate属性默认是SimpleExecutor,故最终还是会进入到SimpleExecutor#query中。 接下来我们进入到SimpleExecutor的父类BaseExecutor的query方法中。 2.5 BaseExecutor#query public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { // @1 ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId()); if (closed) throw new ExecutorException("Executor was closed."); if (queryStack == 0 && ms.isFlushCacheRequired()) { clearLocalCache(); } List<E> list; try { queryStack++; list = resultHandler == null ? (List<E>) localCache.getObject(key) : null; // @2 if (list != null) { handleLocallyCachedOutputParameters(ms, key, parameter, boundSql); } else { list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); // @3 } } finally { queryStack--; } if (queryStack == 0) { for (DeferredLoad deferredLoad : deferredLoads) { deferredLoad.load(); } deferredLoads.clear(); // issue #601 if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) { // @4 clearLocalCache(); // issue #482 } } return list; } 代码@1:首先介绍一下该方法的入参,这些类都是Mybatis的重要类: MappedStatement ms映射语句,一个MappedStatemnet对象代表一个Mapper中的一个方法,是映射的最基本对象。 Object parameterSQL语句的参数列表。 RowBounds rowBounds行边界对象,其实就是分页参数limit与size。 ResultHandler resultHandler结果处理Handler。 CacheKey keyMybatis缓存Key BoundSql boundSqlSQL与参数绑定信息,从该对象可以获取在映射文件中的SQL语句。 代码@2:首先从缓存中获取,Mybatis支持一级缓存(SqlSession)与二级缓存(多个SqlSession共享)。 代码@3:从数据库查询结果,然后进入到doQuery方法,执行真正的查询动作。 代码@4:如果一级缓存是语句级别的,则语句执行完毕后,删除缓存。 2.6 SimpleExecutor#doQuery public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException { Statement stmt = null; try { Configuration configuration = ms.getConfiguration(); StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql); // @1 stmt = prepareStatement(handler, ms.getStatementLog()); // @2 return handler.<E>query(stmt, resultHandler); // @3 } finally { closeStatement(stmt); } } 代码@1:创建StatementHandler,这里会加入Mybatis的插件扩展机制(将在下篇详细介绍),如图所示:代码@2:创建Statement对象,注意,这里就是JDBC协议的java.sql.Statement对象了。 代码@3:使用Statment对象执行SQL语句。 接下来详细介绍Statement对象的创建过程与执行过程,即分布详细跟踪代码@2与代码@3。 3、Statement对象创建流程 3.1 java.sql.Connection对象创建 3.1.1 SimpleExecutor#prepareStatement private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException { Statement stmt; Connection connection = getConnection(statementLog); // @1 stmt = handler.prepare(connection); // @2 handler.parameterize(stmt); // @3 return stmt; } 创建Statement对象,分成三步:代码@1:创建java.sql.Connection对象。 代码@2:使用Connection对象创建Statment对象。 代码@3:对Statement进行额外处理,特别是PrepareStatement的参数设置(ParameterHandler)。 3.1.2 SimpleExecutor#getConnection getConnection方法,根据上面流程图所示,先是进入到org.mybatis.spring.transaction.SpringManagedTransaction,再通过spring-jdbc框架,利用DataSourceUtils获取连接,其代码如下: public static Connection doGetConnection(DataSource dataSource) throws SQLException { Assert.notNull(dataSource, "No DataSource specified"); ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource); if (conHolder != null && (conHolder.hasConnection() || conHolder.isSynchronizedWithTransaction())) { conHolder.requested(); if (!conHolder.hasConnection()) { conHolder.setConnection(dataSource.getConnection()); } return conHolder.getConnection(); } // Else we either got no holder or an empty thread-bound holder here. logger.debug("Fetching JDBC Connection from DataSource"); Connection con = dataSource.getConnection(); // @1 // 这里省略与事务处理相关的代码 return con; } 代码@1:通过DataSource获取connection,那此处的DataSource是“谁”呢?看一下我们工程的配置: 故最终dataSouce.getConnection获取的连接,是从SpringShardingDataSource中获取连接。 com.dangdang.ddframe.rdb.sharding.jdbc.ShardingDataSource#getConnection public ShardingConnection getConnection() throws SQLException { MetricsContext.init(shardingProperties); return new ShardingConnection(shardingContext); } 返回的结果如下:备注:这里只是返回了一个ShardingConnection对象,该对象包含了分库分表上下文,但此时并没有执行具体的分库操作(切换数据源)。 Connection的获取流程清楚后,我们继续来看一下Statemnet对象的创建。 3.2 java.sql.Statement对象创建 stmt = prepareStatement(handler, ms.getStatementLog()); 上面语句的调用链:RoutingStatementHandler -》BaseStatementHandler 3.2.1 BaseStatementHandler#prepare public Statement prepare(Connection connection) throws SQLException { ErrorContext.instance().sql(boundSql.getSql()); Statement statement = null; try { statement = instantiateStatement(connection); // @1 setStatementTimeout(statement); // @2 setFetchSize(statement); // @3 return statement; } catch (SQLException e) { closeStatement(statement); throw e; } catch (Exception e) { closeStatement(statement); throw new ExecutorException("Error preparing statement. Cause: " + e, e); } } 代码@1:根据Connection对象(本文中是ShardingConnection)来创建Statement对象,其默认实现类:PreparedStatementHandler#instantiateStatement方法。 代码@2:为Statement设置超时时间。 代码@3:设置fetchSize。 3.2.2 PreparedStatementHandler#instantiateStatement protected Statement instantiateStatement(Connection connection) throws SQLException { String sql = boundSql.getSql(); if (mappedStatement.getKeyGenerator() instanceof Jdbc3KeyGenerator) { String[] keyColumnNames = mappedStatement.getKeyColumns(); if (keyColumnNames == null) { return connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS); } else { return connection.prepareStatement(sql, keyColumnNames); } } else if (mappedStatement.getResultSetType() != null) { return connection.prepareStatement(sql, mappedStatement.getResultSetType().getValue(), ResultSet.CONCUR_READ_ONLY); } else { return connection.prepareStatement(sql); } } 其实Statement对象的创建,就比较简单了,既然Connection是ShardingConnection,那就看一下其对应的prepareStatement方法即可。 3.2.2 ShardingConnection#prepareStatement public PreparedStatement prepareStatement(final String sql) throws SQLException { // sql,为配置在mybatis xml文件中的sql语句 return new ShardingPreparedStatement(this, sql); } ShardingPreparedStatement(final ShardingConnection shardingConnection, final String sql, final int resultSetType, final int resultSetConcurrency, final int resultSetHoldability) { super(shardingConnection, resultSetType, resultSetConcurrency, resultSetHoldability); preparedSQLRouter = shardingConnection.getShardingContext().getSqlRouteEngine().prepareSQL(sql); } 在构建ShardingPreparedStatement对象的时候,会根据SQL语句创建解析SQL路由的解析器对象,但此时并不会执行相关的路由计算,PreparedStatement对象创建完成后,就开始进入SQL执行流程中。 4、SQL执行流程 接下来我们继续看SimpleExecutor#doQuery方法的第3步,执行SQL语句: handler.<E>query(stmt, resultHandler)。 首先会进入RoutingStatementHandler这个类中,进行Mybatis层面的路由(主要是根据Statement类型)然后进入到PreparedStatementHandler#query中。 4.1 PreparedStatementHandler#query public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException { PreparedStatement ps = (PreparedStatement) statement; ps.execute(); // @1 return resultSetHandler.<E> handleResultSets(ps); // @2 } 代码@1:调用PreparedStatement的execute方法,由于本例是使用了Sharding-jdbc分库分表,此时调用的具体实现为:ShardingPreparedStatement。 代码@2:处理结果。 我们接下来分别来跟进execute与结果处理方法。 4.2 ShardingPreparedStatement#execute public boolean execute() throws SQLException { try { return new PreparedStatementExecutor(getShardingConnection().getShardingContext().getExecutorEngine(), routeSQL()).execute(); // @1 } finally { clearRouteContext(); } } 这里奥妙无穷,其关键点如下:1)创造PreparedStatementExecutor对象,其两个核心参数: ExecutorEngine executorEngine:shardingjdbc执行引擎。 Collection< PreparedStatementExecutorWrapper> preparedStatemenWrappers一个集合,每一个集合是PreparedStatement的包装类,这个集合如何而来? 2)preparedStatemenWrappers是通过routeSQL方法产生的。 3)最终调用PreparedStatementExecutor方法的execute来执行。 接下来分别看一下routeSQL与execute方法。 4.3 ShardingPreparedStatement#routeSQL private List<PreparedStatementExecutorWrapper> routeSQL() throws SQLException { List<PreparedStatementExecutorWrapper> result = new ArrayList<>(); SQLRouteResult sqlRouteResult = preparedSQLRouter.route(getParameters()); // @1 MergeContext mergeContext = sqlRouteResult.getMergeContext(); setMergeContext(mergeContext); setGeneratedKeyContext(sqlRouteResult.getGeneratedKeyContext()); for (SQLExecutionUnit each : sqlRouteResult.getExecutionUnits()) { // @2 PreparedStatement preparedStatement = (PreparedStatement) getStatement(getShardingConnection().getConnection(each.getDataSource(), sqlRouteResult.getSqlStatementType()), each.getSql()); // @3 replayMethodsInvocation(preparedStatement); getParameters().replayMethodsInvocation(preparedStatement); result.add(wrap(preparedStatement, each)); } return result; } 代码@1:根据SQL参数进行路由计算,本文暂不关注其具体实现细节,这些将在具体分析Sharding-jdbc时具体详解,在这里就直观看一下其结果: 代码@2、@3:对分库分表的结果进行遍历,然后使用底层Datasource来创建Connection,创建PreparedStatement 对象。 routeSQL就暂时讲到这,从这里我们得知,会在这里根据路由结果,使用底层的具体数据源创建对应的Connection与PreparedStatement 对象。 4.4 PreparedStatementExecutor#execute public boolean execute() { Context context = MetricsContext.start("ShardingPreparedStatement-execute"); eventPostman.postExecutionEvents(); final boolean isExceptionThrown = ExecutorExceptionHandler.isExceptionThrown(); final Map<String, Object> dataMap = ExecutorDataMap.getDataMap(); try { if (1 == preparedStatementExecutorWrappers.size()) { // @1 PreparedStatementExecutorWrapper preparedStatementExecutorWrapper = preparedStatementExecutorWrappers.iterator().next(); return executeInternal(preparedStatementExecutorWrapper, isExceptionThrown, dataMap); } List<Boolean> result = executorEngine.execute(preparedStatementExecutorWrappers, new ExecuteUnit<PreparedStatementExecutorWrapper, Boolean>() { // @2 @Override public Boolean execute(final PreparedStatementExecutorWrapper input) throws Exception { synchronized (input.getPreparedStatement().getConnection()) { return executeInternal(input, isExceptionThrown, dataMap); } } }); return (null == result || result.isEmpty()) ? false : result.get(0); } finally { MetricsContext.stop(context); } } 代码@1:如果计算出来的路由信息为1个,则同步执行。 代码@2:如果计算出来的路由信息有多个,则使用线程池异步执行。 那还有一个问题,通过PreparedStatement#execute方法执行后,如何返回结果呢?特别是异步执行的。 在上文其实已经谈到: 4.4 DefaultResultSetHandler#handleResultSets public List<Object> handleResultSets(Statement stmt) throws SQLException { ErrorContext.instance().activity("handling results").object(mappedStatement.getId()); final List<Object> multipleResults = new ArrayList<Object>(); int resultSetCount = 0; ResultSetWrapper rsw = getFirstResultSet(stmt); // @1 //省略部分代码,完整代码可以查看DefaultResultSetHandler方法。 return collapseSingleResultList(multipleResults); } private ResultSetWrapper getFirstResultSet(Statement stmt) throws SQLException { ResultSet rs = stmt.getResultSet(); // @2 while (rs == null) { // move forward to get the first resultset in case the driver // doesn't return the resultset as the first result (HSQLDB 2.1) if (stmt.getMoreResults()) { rs = stmt.getResultSet(); } else { if (stmt.getUpdateCount() == -1) { // no more results. Must be no resultset break; } } } return rs != null ? new ResultSetWrapper(rs, configuration) : null; } 我们看一下其关键代码如下:代码@1:调用Statement#getResultSet()方法,如果使用shardingJdbc,则会调用ShardingStatement#getResultSet(),并会处理分库分表结果集的合并,在这里就不详细进行介绍,该部分会在shardingjdbc专栏详细分析。 代码@2:jdbc statement中获取结果集的通用写法,这里也不过多的介绍。 mybatis shardingjdbc SQL执行流程就介绍到这里了,为了方便大家对上述流程的理解,最后给出SQL执行的流程图: Mybatis Sharding-Jdbc的SQL执行流程就介绍到这里了,从图中也能清晰看到Mybatis的插件机制,将在下文详细介绍。 原文发布时间为:2019-05-28本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
温馨提示:本篇是源码分析Mybatis ShardingJdbc SQL语句执行的前置篇。源码分析Mybatis系列目录: 1、源码分析Mybatis MapperProxy初始化之Mapper对象的扫描与构建2、源码分析Mybatis MappedStatement的创建流程 1、Executor sql执行器,其对应的类全路径:org.apache.ibatis.executor.Executor。 1.1 Executor类图 Executor执行器根据接口,定义update(更新或插入)、query(查询)、commit(提交事务)、rollback(回滚事务)。接下来简单介绍几个重要方法: int update(MappedStatement ms, Object parameter) throws SQLException更新或插入方法,其参数含义如下:、 1)MappedStatement ms:SQL映射语句(Mapper.xml文件每一个方法对应一个MappedStatement对象)2)Object parameter:参数,通常是List集合。 - < E> List< E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) 查询方法,其参数含义如下:1)RowBounds:行边界,主要值分页参数limit、offset。2)ResultHandler resultHandler:结果处理器。 - CacheKey createCacheKey(MappedStatement ms, Object parameterObj, RowBounds bounds, BoundSql bSql) 创建缓存Key,Mybatis一二级缓存的缓存Key,可以看出Key由上述4个参数来决定。1)BoundSql boundSql:可以通过该对象获取SQL语句。 CachingExecutor支持结果缓存的SQL执行器,注意其设计模式的应用,该类中,会持有Executor的一个委托对象,CachingExecutor关注与缓存特定的逻辑,其最终的SQL执行由其委托对象来实现,即其内部的委托对象为BaseExecutor的实现类。 BaseExecutorExecutor的基础实现类,该类为抽象类,关于查询、更新具体的实现由其子类来实现,下面4个都是其子类。 SimpleExecutor简单的Executor执行器。 BatchExecutor支持批量执行的Executor执行器。 ClosedExecutor表示一个已关闭的Executor。 ReuseExecutor支持重复使用Statement,以SQL为键,缓存Statement对象。 1.2 创建Executor 在Mybatis中,Executor的创建由Configuration对象来创建,具体的代码如下: Configuration#newExecitor public Executor newExecutor(Transaction transaction) { return newExecutor(transaction, defaultExecutorType); // @1 } public Executor newExecutor(Transaction transaction, ExecutorType executorType) { executorType = executorType == null ? defaultExecutorType : executorType; executorType = executorType == null ? ExecutorType.SIMPLE : executorType; Executor executor; if (ExecutorType.BATCH == executorType) { // @2 executor = new BatchExecutor(this, transaction); } else if (ExecutorType.REUSE == executorType) { executor = new ReuseExecutor(this, transaction); } else { executor = new SimpleExecutor(this, transaction); } if (cacheEnabled) { // @3 executor = new CachingExecutor(executor); } executor = (Executor) interceptorChain.pluginAll(executor); // @4 return executor; } 从上面的代码可以看出,Executor的创建由如下三个关键点:代码@1:默认的ExecutorType为ExecutorType.SIMPLE,即默认创建的Executory为SimpleExecutor。代码@2:根据executorType的值创建对应的Executory。代码@3:如果cacheEnabled为true,则创建CachingExecutory,然后在其内部持有上面创建的Executor,cacheEnabled默认为true,则默认创建的Executor为CachingExecutor,并且其内部包裹着SimpleExecutor。代码@4:使用InterceptorChain.pluginAll为executor创建代理对象,即Mybatis的拆件机制,将在该系列文章中详细介绍。 2、StatementHandler 在学习StatementHandler之前,我们先来回顾一下JDBC相关的知识。JDBC与语句执行的两大主流对象:java.sql.Statement、java.sql.PrepareStatement对象大家应该不会陌生,该对象的execute方法就是执行SQL语句的入口,通过java.sql.Connection对象创建Statement对象。Mybatis的StatementHandler,是Mybatis创建Statement对象的处理器,即StatementHandler会接管Statement对象的创建。 2.1 StatementHandler类图 StatementHandler根接口,我们重点关注一下其定义的方法: Statement prepare(Connection connection)创建Statement对象,即该方法会通过Connection对象创建Statement对象。 void parameterize(Statement statement)对Statement对象参数化,特别是PreapreStatement对象。 void batch(Statement statement)批量执行SQL。 int update(Statement statement)更新操作。 < E> List< E> query(Statement statement, ResultHandler resultHandler)查询操作。 BoundSql getBoundSql()获取SQL语句。 ParameterHandler getParameterHandler()获取对应的参数处理器。 BaseStatementHandlerStatementHandler的抽象实现类,SimpleStatementHandler、PrepareStatementHandler、CallableStatementHandler是其子类。 我们来一一看一下其示例变量: - Configuration configuration Mybatis全局配置对象。 - ObjectFactory objectFactory 对象工厂。 - TypeHandlerRegistry typeHandlerRegistry 类型注册器。 - ResultSetHandler resultSetHandler 结果集Handler。 - ParameterHandler parameterHandler 参数处理器Handler。 - Executor executor SQL执行器。 - MappedStatement mappedStatement SQL映射语句(Mapper.xml文件每一个方法对应一个MappedStatement对象) - RowBounds rowBounds 行边界,主要值分页参数limit、offset。 - BoundSql boundSql 可以通过该对象获取SQL语句。 SimpleStatementHandler具体的StatementHandler实现器,java.sql.Statement对象创建处理器。 PrepareStatementHandlerjava.sql.PrepareStatement对象的创建处理器。 CallableStatementHandlerjava.sql.CallableStatement对象的创建处理器,可用来执行存储过程调用的Statement。 RoutingStatementHandlerStatementHandler路由器,我们看一下其构造方法后,就会对该类了然于胸。 public RoutingStatementHandler(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { switch (ms.getStatementType()) { // @1 case STATEMENT: delegate = new SimpleStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql); break; case PREPARED: delegate = new PreparedStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql); break; case CALLABLE: delegate = new CallableStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql); break; default: throw new ExecutorException("Unknown statement type: " + ms.getStatementType()); } } 原来是会根据MappedStatement对象的statementType创建对应的StatementHandler。 2.2 创建StatementHandler Configuration#newStatementHandler public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql); // @1 statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler); // @2 return statementHandler; } 该方法的两个关键点如下:代码@1:创建RoutingStatementHandler对象,在其内部再根据SQL语句的类型,创建对应的StatementHandler对象。代码@2:对StatementHandler引入拆件机制,该部分将在该专题的后续文章中会详细介绍,这里暂时跳过。 3、ParameterHandler 参数处理器。同样我们先来看一下其类图。 3.1 ParameterHandler类图 这个比较简单,就是处理PreparedStatemet接口的参数化处理,也可以顺便看一下其调用链(该部分会在下一篇中详细介绍)。 3.2 创建ParameterHandler Configuration#newParameterHandler public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) { ParameterHandler parameterHandler = mappedStatement.getLang().createParameterHandler(mappedStatement, parameterObject, boundSql); parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler); // @1 return parameterHandler; } 同样该接口也支持插件化机制。 4、ResultSetHandler 处理结果的Handler。我们同样看一下其类图。 4.1 ResultSetHandler类图 处理Jdbc ResultSet的处理器。 4.2 ResultSetHandler创建 Configuration#newResultSetHandler public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement mappedStatement, RowBounds rowBounds, ParameterHandler parameterHandler, ResultHandler resultHandler, BoundSql boundSql) { ResultSetHandler resultSetHandler = new DefaultResultSetHandler(executor, mappedStatement, parameterHandler, resultHandler, boundSql, rowBounds); resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler); return resultSetHandler; } 同样支持插件化机制,我们也稍微再看一下其调用链:可以看出其调用的入口为SQL执行时。 本文作为下一篇《源码分析Mybatis整合ShardingJdbc SQL执行流程》的前置篇,重点介绍Executor、StatementHandler、ParameterHandler、ResultSetHandler的具体职责,以类图为基础并详细介绍其核心方法的作用,然后详细介绍了这些对象是如何创建,并引出Mybatis拆件机制。 原文发布时间为:2019-05-21本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
上文源码分析Mybatis MapperProxy创建流程重点阐述MapperProxy的创建流程,但并没有介绍.Mapper.java(UserMapper.java)是如何与Mapper.xml文件中的SQL语句是如何建立关联的。本文将重点接开这个谜团。 接下来重点从源码的角度分析Mybatis MappedStatement的创建流程。 @TOC 1、上节回顾 我们注意到这里有两三个与Mapper相关的配置: SqlSessionFactory#mapperLocations,指定xml文件的配置路径。 SqlSessionFactory#configLocation,指定mybaits的配置文件,该配置文件也可以配置mapper.xml的配置路径信息。 MapperScannerConfigurer,扫描Mapper的java类(DAO)。 我们已经详细介绍了Mybatis Mapper对象的扫描与构建,那接下来我们将重点介绍MaperProxy与mapper.xml文件是如何建立关联关系的。 根据上面的罗列以及上文的讲述,Mapper.xml与Mapper建立联系主要的入口有三:1)MapperScannerConfigurer扫描Bean流程中,在调用MapperReigistry#addMapper时如果Mapper对应的映射文件(Mapper.xml)未加载到内存,会触发加载。2)实例化SqlSessionFactory时,如果配置了mapperLocations。3)示例化SqlSessionFactory时,如果配置了configLocation。 本节的行文思路:从SqlSessionFacotry的初始化开始讲起,因为mapperLocations、configLocation都是是SqlSessionFactory的属性。 温馨提示:下面开始从源码的角度对其进行介绍,大家可以先跳到文末看看其调用序列图。 2、SqlSessionFacotry if (xmlConfigBuilder != null) { // XMLConfigBuilder // @1 try { xmlConfigBuilder.parse(); if (logger.isDebugEnabled()) { logger.debug("Parsed configuration file: '" + this.configLocation + "'"); } } catch (Exception ex) { throw new NestedIOException("Failed to parse config resource: " + this.configLocation, ex); } finally { ErrorContext.instance().reset(); } } if (!isEmpty(this.mapperLocations)) { // @2 for (Resource mapperLocation : this.mapperLocations) { if (mapperLocation == null) { continue; } try { XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(mapperLocation.getInputStream(), configuration, mapperLocation.toString(), configuration.getSqlFragments()); xmlMapperBuilder.parse(); } catch (Exception e) { throw new NestedIOException("Failed to parse mapping resource: '" + mapperLocation + "'", e); } finally { ErrorContext.instance().reset(); } if (logger.isDebugEnabled()) { logger.debug("Parsed mapper file: '" + mapperLocation + "'"); } } } else { if (logger.isDebugEnabled()) { logger.debug("Property 'mapperLocations' was not specified or no matching resources found"); } } 上文有两个入口:代码@1:处理configLocation属性。代码@2:处理mapperLocations属性。 我们先从XMLConfigBuilder#parse开始进行追踪。该方法主要是解析configLocation指定的配置路径,对其进行解析,具体调用parseConfiguration方法。 2.1 XMLConfigBuilder 我们直接查看其parseConfiguration方法。 private void parseConfiguration(XNode root) { try { propertiesElement(root.evalNode("properties")); //issue #117 read properties first typeAliasesElement(root.evalNode("typeAliases")); pluginElement(root.evalNode("plugins")); objectFactoryElement(root.evalNode("objectFactory")); objectWrapperFactoryElement(root.evalNode("objectWrapperFactory")); settingsElement(root.evalNode("settings")); environmentsElement(root.evalNode("environments")); // read it after objectFactory and objectWrapperFactory issue #631 databaseIdProviderElement(root.evalNode("databaseIdProvider")); typeHandlerElement(root.evalNode("typeHandlers")); mapperElement(root.evalNode("mappers")); // @1 } catch (Exception e) { throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e); } } 重点关注mapperElement,从名称与参数即可以看出,该方法主要是处理中mappers的定义,即mapper sql语句的解析与处理。如果使用过Mapper的人应该不难知道,我们使用mapper节点,通过resource标签定义具体xml文件的位置。 2.1.1XMLConfigBuilder#mapperElement private void mapperElement(XNode parent) throws Exception { if (parent != null) { for (XNode child : parent.getChildren()) { if ("package".equals(child.getName())) { String mapperPackage = child.getStringAttribute("name"); configuration.addMappers(mapperPackage); } else { String resource = child.getStringAttribute("resource"); String url = child.getStringAttribute("url"); String mapperClass = child.getStringAttribute("class"); if (resource != null && url == null && mapperClass == null) { ErrorContext.instance().resource(resource); InputStream inputStream = Resources.getResourceAsStream(resource); XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments()); // @1 mapperParser.parse(); } else if (resource == null && url != null && mapperClass == null) { ErrorContext.instance().resource(url); InputStream inputStream = Resources.getUrlAsStream(url); XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments()); mapperParser.parse(); } else if (resource == null && url == null && mapperClass != null) { Class<?> mapperInterface = Resources.classForName(mapperClass); configuration.addMapper(mapperInterface); } else { throw new BuilderException("A mapper element may only specify a url, resource or class, but not more than one."); } } } } } 上面的代码比较简单,不难看出,解析出Mapper标签,解析出resource标签的属性,创建对应的文件流,通过构建XMLMapperBuilder来解析对应的mapper.xml文件。此时大家会惊讶的发现,在SqlSessionFacotry的初始化代码中,处理mapperLocations时就是通过构建XMLMapperBuilder来解析mapper文件,其实也不难理解,因为这是mybatis支持的两个地方可以使用mapper标签来定义mapper映射文件,具体解析代码当然是一样的逻辑。那我们解析来重点把目光投向XMLMapperBuilder。 2.2 XMLMapperBuilder XMLMapperBuilder#parse public void parse() { if (!configuration.isResourceLoaded(resource)) { // @1 configurationElement(parser.evalNode("/mapper")); configuration.addLoadedResource(resource); bindMapperForNamespace(); } parsePendingResultMaps(); // @2 parsePendingChacheRefs(); // @3 parsePendingStatements(); // @4 } 代码@1:如果该映射文件(*.Mapper.xml)文件未加载,则首先先加载,完成xml文件的解析,提取xml中与mybatis相关的数据,例如sql、resultMap等等。代码@2:处理mybatis xml中ResultMap。代码@3:处理mybatis缓存相关的配置。代码@4:处理mybatis statment相关配置,这里就是本篇关注的,Sql语句如何与Mapper进行关联的核心实现。 接下来我们重点探讨parsePendingStatements()方法,解析statement(对应SQL语句)。 2.2.1 XMLMapperBuilder#parsePendingStatements private void parsePendingStatements() { Collection<XMLStatementBuilder> incompleteStatements = configuration.getIncompleteStatements(); synchronized (incompleteStatements) { Iterator<XMLStatementBuilder> iter = incompleteStatements.iterator(); // @1 while (iter.hasNext()) { try { iter.next().parseStatementNode(); // @2 iter.remove(); } catch (IncompleteElementException e) { // Statement is still missing a resource... } } } } 代码@1:遍历解析出来的所有SQL语句,用的是XMLStatementBuilder对象封装的,故接下来重点看一下代码@2,如果解析statmentNode。 2.2.2 XMLStatementBuilder#parseStatementNode public void parseStatementNode() { String id = context.getStringAttribute("id"); // @1 start String databaseId = context.getStringAttribute("databaseId"); if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) return; Integer fetchSize = context.getIntAttribute("fetchSize"); Integer timeout = context.getIntAttribute("timeout"); String parameterMap = context.getStringAttribute("parameterMap"); String parameterType = context.getStringAttribute("parameterType"); Class<?> parameterTypeClass = resolveClass(parameterType); String resultMap = context.getStringAttribute("resultMap"); String resultType = context.getStringAttribute("resultType"); String lang = context.getStringAttribute("lang"); LanguageDriver langDriver = getLanguageDriver(lang); Class<?> resultTypeClass = resolveClass(resultType); String resultSetType = context.getStringAttribute("resultSetType"); StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString())); ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType); String nodeName = context.getNode().getNodeName(); SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH)); boolean isSelect = sqlCommandType == SqlCommandType.SELECT; boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect); boolean useCache = context.getBooleanAttribute("useCache", isSelect); boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false); // Include Fragments before parsing XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant); includeParser.applyIncludes(context.getNode()); // Parse selectKey after includes and remove them. processSelectKeyNodes(id, parameterTypeClass, langDriver); // @1 end // Parse the SQL (pre: <selectKey> and <include> were parsed and removed) SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass); // @2 String resultSets = context.getStringAttribute("resultSets"); String keyProperty = context.getStringAttribute("keyProperty"); String keyColumn = context.getStringAttribute("keyColumn"); KeyGenerator keyGenerator; String keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX; keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true); if (configuration.hasKeyGenerator(keyStatementId)) { keyGenerator = configuration.getKeyGenerator(keyStatementId); } else { keyGenerator = context.getBooleanAttribute("useGeneratedKeys", configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType)) ? new Jdbc3KeyGenerator() : new NoKeyGenerator(); } builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType, // @3 fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass, resultSetTypeEnum, flushCache, useCache, resultOrdered, keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets); } 这个方法有点长,其关注点主要有3个:代码@1:构建基本属性,其实就是构建MappedStatement的属性,因为MappedStatement对象就是用来描述Mapper-SQL映射的对象。 代码@2:根据xml配置的内容,解析出实际的SQL语句,使用SqlSource对象来表示。 代码@3:使用MapperBuilderAssistant对象,根据准备好的属性,构建MappedStatement对象,最终将其存储在Configuration中。 2.2.3 Configuration#addMappedStatement public void addMappedStatement(MappedStatement ms) { mappedStatements.put(ms.getId(), ms); } MappedStatement的id为:mapperInterface + methodName,例如com.demo.dao.UserMapper.findUser。 即上述流程完成了xml的解析与初始化,对终极目标是创建MappedStatement对象,上一篇文章介绍了mapperInterface的初始化,最终会初始化为MapperProxy对象,那这两个对象如何关联起来呢? 从下文可知,MapperProxy与MappedStatement是在调用具Mapper方法时,可以根据mapperInterface.getName + methodName构建出MappedStatement的id,然后就可以从Configuration的mappedStatements容器中根据id获取到对应的MappedStatement对象,这样就建立起联系了。 其对应的代码: // MapperMethod 构造器 public MapperMethod(Class<?> mapperInterface, Method method, Configuration config) { this.command = new SqlCommand(config, mapperInterface, method); this.method = new MethodSignature(config, method); } // SqlCommand 构造器 public SqlCommand(Configuration configuration, Class<?> mapperInterface, Method method) throws BindingException { String statementName = mapperInterface.getName() + "." + method.getName(); MappedStatement ms = null; if (configuration.hasStatement(statementName)) { ms = configuration.getMappedStatement(statementName); } else if (!mapperInterface.equals(method.getDeclaringClass().getName())) { // issue #35 String parentStatementName = method.getDeclaringClass().getName() + "." + method.getName(); if (configuration.hasStatement(parentStatementName)) { ms = configuration.getMappedStatement(parentStatementName); } } if (ms == null) { throw new BindingException("Invalid bound statement (not found): " + statementName); } name = ms.getId(); type = ms.getSqlCommandType(); if (type == SqlCommandType.UNKNOWN) { throw new BindingException("Unknown execution method for: " + name); } } 怎么样,从上面的源码分析中,大家是否已经了解MapperProxy与Xml中的SQL语句是怎样建立的关系了吗?为了让大家更清晰的了解上述过程,现给出其调用时序图: 原文发布时间为:2019-05-23本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
温馨提示:本文基于Mybatis.3.x版本。 MapperScannerConfigurer,Spring整合Mybatis的核心类,其作用是扫描项目中Dao类,将其创建为Mybatis的Maper对象即MapperProxy对象。 首先进入源码学习之前,我们先看一下在项目中的配置文件信息。我们注意到这里有两三个与Mapper相关的配置: SqlSessionFactory#mapperLocations,指定xml文件的配置路径。 SqlSessionFactory#configLocation,指定mybaits的配置文件,该配置文件也可以配置mapper.xml的配置路径信息。 MapperScannerConfigurer,扫描Mapper的java类(DAO)。 本文的行文思路如下: Mybatis MapperProxy对象的扫描与构建 Mapper类与SQL语句如何建立关联这部分主要阐述Java类的运行实例Mapper对象(例如UserMapper、BookMapper)是如何与mapper.xml(UserMapper.xml、BookMapper.xml文件建立联系的)。 Mybatis MapperProxy对象创建流程 下面的源码分析或许会比较枯燥,进入源码分析之前,先给出MapperProxy的创建序列图。 1.1 MapperProxy创建序列图 1.2 MapperScannerConfigurer详解 MapperScannerConfigurer的类图如下所示:MapperScannerConfigurer实现Spring Bean生命周期相关的类:BeanNameAware、ApplicationContextAware、BeanFactoryPostProcessor、InitializingBean、BeanDefinitionRegistryPostProcessor,我们先来看一下这些接口对应的方法的调用时机: BeanNameAware是Bean对自己的名称感知,也就是在Bean创建的时候,自动将Bean的名称设置在Bean中,外部应用程序不需要调用setBeanName,就可以通过getBeanName()方法获取其bean名称。 ApplicationContextAware自动感知ApplicationContext对象,即在Bean创建的时候,Spring工厂会自动将当前的ApplicationContext注入该Bean中。 InitializingBean实现该接口,Spring在初始化Bean后会自动调用InitializingBean#afterPropertiesSet方法。 BeanFactoryPostProcessorBeanFactory后置处理器,这个时候只是创建好了Bean的定义信息(BeanDefinition),在BeanFactoryPostProcessor接口的postProcessBeanFactory方法中,我们可以修改bean的定义信息,例如修改属性的值,修改bean的scope为单例或者多例。与其相似的是BeanPostProcessor,这个是在bean初始化前后对Bean执行,即bean的构造方法调用后,init-method前执行。 BeanDefinitionRegistryPostProcessor主要用来增加Bean的定义,增加BeanDefinition。由于MapperScannerConfigurer主要的目的就是扫描特定的包,并创建对应的Mapper对象,估这里是MapperScannerConfigurer重点实现的接口。 那我们接下来从BeanDefinitionRegistryPostProcessor的实现接口开始跟踪。 BeanDefinitionRegistryPostProcessor#postProcessBeanDefinitionRegistry public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { if (this.processPropertyPlaceHolders) { processPropertyPlaceHolders(); } ClassPathMapperScanner scanner = new ClassPathMapperScanner(registry); scanner.setAddToConfig(this.addToConfig); scanner.setAnnotationClass(this.annotationClass); scanner.setMarkerInterface(this.markerInterface); scanner.setSqlSessionFactory(this.sqlSessionFactory); // @1 scanner.setSqlSessionTemplate(this.sqlSessionTemplate); scanner.setSqlSessionFactoryBeanName(this.sqlSessionFactoryBeanName); scanner.setSqlSessionTemplateBeanName(this.sqlSessionTemplateBeanName); scanner.setResourceLoader(this.applicationContext); scanner.setBeanNameGenerator(this.nameGenerator); scanner.registerFilters(); scanner.scan(StringUtils.tokenizeToStringArray(this.basePackage, ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS)); // @2 } 代码@1:首先设置SqlSessionFactory,从该Scan器生成的Mapper最终都是受该SqlSessionFactory的管辖。代码@2:调用ClassPathMapperScanner的scan方法进行扫描动作,接下来详细介绍。 ClassPathMapperScanner#doScan public Set<BeanDefinitionHolder> doScan(String... basePackages) { Set<BeanDefinitionHolder> beanDefinitions = super.doScan(basePackages); //@1 if (beanDefinitions.isEmpty()) { logger.warn("No MyBatis mapper was found in '" + Arrays.toString(basePackages) + "' package. Please check your configuration."); } else { processBeanDefinitions(beanDefinitions); // @2 } return beanDefinitions; } 代码@1:首先调用父类(org.springframework.context.annotation.ClassPathBeanDefinitionScanner)方法,根据扫描的文件,构建对应的BeanDefinitionHolder对象。代码@2:对这些BeanDefinitions进行处理,对Bean进行加工,加入Mybatis特性。 ClassPathMapperScanner#processBeanDefinitions private void processBeanDefinitions(Set<BeanDefinitionHolder> beanDefinitions) { GenericBeanDefinition definition; for (BeanDefinitionHolder holder : beanDefinitions) { definition = (GenericBeanDefinition) holder.getBeanDefinition(); if (logger.isDebugEnabled()) { logger.debug("Creating MapperFactoryBean with name '" + holder.getBeanName() + "' and '" + definition.getBeanClassName() + "' mapperInterface"); } // the mapper interface is the original class of the bean // but, the actual class of the bean is MapperFactoryBean definition.getPropertyValues().add("mapperInterface", definition.getBeanClassName()); definition.setBeanClass(this.mapperFactoryBean.getClass()); // @1 definition.getPropertyValues().add("addToConfig", this.addToConfig); boolean explicitFactoryUsed = false; if (StringUtils.hasText(this.sqlSessionFactoryBeanName)) { // @2 start definition.getPropertyValues().add("sqlSessionFactory", new RuntimeBeanReference(this.sqlSessionFactoryBeanName)); explicitFactoryUsed = true; } else if (this.sqlSessionFactory != null) { definition.getPropertyValues().add("sqlSessionFactory", this.sqlSessionFactory); explicitFactoryUsed = true; } // @2 end if (StringUtils.hasText(this.sqlSessionTemplateBeanName)) { // @3 if (explicitFactoryUsed) { logger.warn("Cannot use both: sqlSessionTemplate and sqlSessionFactory together. sqlSessionFactory is ignored."); } definition.getPropertyValues().add("sqlSessionTemplate", new RuntimeBeanReference(this.sqlSessionTemplateBeanName)); explicitFactoryUsed = true; } else if (this.sqlSessionTemplate != null) { if (explicitFactoryUsed) { logger.warn("Cannot use both: sqlSessionTemplate and sqlSessionFactory together. sqlSessionFactory is ignored."); } definition.getPropertyValues().add("sqlSessionTemplate", this.sqlSessionTemplate); explicitFactoryUsed = true; } if (!explicitFactoryUsed) { if (logger.isDebugEnabled()) { logger.debug("Enabling autowire by type for MapperFactoryBean with name '" + holder.getBeanName() + "'."); } definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE); } } } 该方法有3个关键点:代码@1:BeanDefinition中的beanClass设置的类为MapperFactoryBean,即该BeanDefinition初始化的实例为MapperFactoryBean,其名字可以看出,这是一个FactoryBean对象,会通过其getObject方法进行构建具体实例。 代码@2:将为MapperFactoryBean设置属性,将SqlSessionFactory放入其属性中,在实例化时可以自动获取到该SqlSessionFactory。 代码@3:如果sqlSessionTemplate不为空,则放入到属性中,以便Spring在实例化MapperFactoryBean时可以得到对应的SqlSessionTemplate。 分析到这里,MapperScannerConfigurer的doScan方法就结束了,但并没有初始化Mapper,只是创建了很多的BeanDefinition,并且其beanClass为MapperFactoryBean,那我们将目光转向MapperFactoryBean。 1.3 MapperFactoryBean MapperFactoryBean的类图如下:先对上述核心类做一个简述: DaoSupport Dao层的基类,定义一个模板方法,供其子类实现具体的逻辑,DaoSupport的模板方法如下: public final void afterPropertiesSet() throws IllegalArgumentException, BeanInitializationException { // Let abstract subclasses check their configuration. checkDaoConfig(); // @1 // Let concrete implementations initialize themselves. try { initDao(); // @2 } catch (Exception ex) { throw new BeanInitializationException("Initialization of DAO failed", ex); } } 代码@1:检查或构建dao的配置信息,该方法为抽象类,供子类实现,等下我们本节的主角MapperFactoryBean主要实现该方法,从而实现与Mybatis相关的整合信息。代码@2:初始化Dao相关的方法,该方法为一个空实现。 SqlSessionDaoSupport SqlSession支持父类,通过使用SqlSessionFactory或SqlSessionTemplate创建SqlSession,那下面两个方法会在什么时候被调用呢? public void setSqlSessionFactory(SqlSessionFactory sqlSessionFactory) public void setSqlSessionTemplate(SqlSessionTemplate sqlSessionTemplate) 不知道大家还记不记得,在创建MapperFactoryBean的时候,其属性里会设置SqlSessionFacotry或SqlSessionTemplate,见上文代码(processBeanDefinitions),这样的话在示例化Bean时,Spring会自动注入实例,即在实例化Bean时,上述方法中的一个或多个会被调用。 MapperFactoryBean 主要看它是如何实现checkDaoConfig的。 MapperFactoryBean#checkDaoConfig protected void checkDaoConfig() { super.checkDaoConfig(); // @1 notNull(this.mapperInterface, "Property 'mapperInterface' is required"); Configuration configuration = getSqlSession().getConfiguration(); if (this.addToConfig && !configuration.hasMapper(this.mapperInterface)) { // @2 try { configuration.addMapper(this.mapperInterface); } catch (Throwable t) { logger.error("Error while adding the mapper '" + this.mapperInterface + "' to configuration.", t); throw new IllegalArgumentException(t); } finally { ErrorContext.instance().reset(); } } } 代码@1:首先先调用父类的checkDaoConfig方法。代码@2:mapperInterface,就是具体的Mapper的接口类,例如com.demo.dao.UserMapper,如果以注册,则抛出异常,否则调用configuration增加Mapper。 接下来进入到org.apache.ibatis.session.Configuration中。 public <T> void addMapper(Class<T> type) { mapperRegistry.addMapper(type); } public <T> T getMapper(Class<T> type, SqlSession sqlSession) { return mapperRegistry.getMapper(type, sqlSession); } public boolean hasMapper(Class<?> type) { return mapperRegistry.hasMapper(type); } 从上面代码可以看出,正在注册(添加)、查询、获取Mapper的核心类为MapperRegistry。 1.4 MapperRegistry 其核心类图如下所示:对其属性做个简单的介绍: Configuration configMybatis全局配置对象。 Map, MapperProxyFactory<?>> knownMappers已注册Map,这里的键值为mapper接口,例如com.demo.dao.UserMapper,值为MapperProxyFactory,创建MapperProxy的工厂。 下面简单介绍MapperRegistry的几个方法,其实现都比较简单。 MapperRegistry#addMapper public <T> void addMapper(Class<T> type) { if (type.isInterface()) { if (hasMapper(type)) { // @1 throw new BindingException("Type " + type + " is already known to the MapperRegistry."); } boolean loadCompleted = false; try { knownMappers.put(type, new MapperProxyFactory<T>(type)); // @2 MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type); // @3 parser.parse(); loadCompleted = true; } finally { if (!loadCompleted) { knownMappers.remove(type); } } } } 代码@1:如果该接口已经注册,则抛出已经绑定的异常。代码@2:为该接口注册MapperProxyFactory,但这里只是注册其创建MapperProxy的工厂,并不是创建MapperProxy。代码@3:如果Mapper对应的xml资源未加载,触发xml的绑定操作,将xml中的sql语句与Mapper建立关系。本文将不详细介绍,在下一篇中详细介绍。 注意:addMapper方法,只是为*Mapper创建对应对应的MapperProxyFactory。 MapperRegistry#getMapper public <T> T getMapper(Class<T> type, SqlSession sqlSession) { final MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type); // @1 if (mapperProxyFactory == null) throw new BindingException("Type " + type + " is not known to the MapperRegistry."); try { return mapperProxyFactory.newInstance(sqlSession); // @2 } catch (Exception e) { throw new BindingException("Error getting mapper instance. Cause: " + e, e); } } 根据Mapper接口与SqlSession创建MapperProxy对象。代码@1:根据接口获取MapperProxyFactory。代码@2:调用MapperProxyFactory的newInstance创建MapperProxy对象。 到目前为止Mybatis Mapper的初始化构造过程就完成一半了,即MapperScannerConfigurer通过包扫描,然后构建MapperProxy,但此时MapperProxy还未与mapper.xml文件中的sql语句建立关联,由于篇幅的原因,将在下一节重点介绍其关联关系建立的流程。接下来我们先一睹MapperProxy对象,毕竟这是本文最终要创建的对象,也为后续SQL的执行流程做个简单准备。 1.5 MapperProxy 类图如下:上面的类都比较简单,MapperMethod,代表一个一个的Mapper方法,从SqlCommand可以看出,每一个MapperMethod都会对应一条SQL语句。 下面以一张以SqlSessionFacotry为视角的各核心类的关系图: 温馨提示:本文只阐述了Mybatis MapperProxy的创建流程,MapperProxy与*.Mapper.xml即SQL是如何关联的本文未涉及到,这部分的内容请看下文,即将发布。 原文发布时间为:2019-05-21本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
上一篇我们详细分析了源码分析 RocketMQ DLedger 多副本之 Leader 选主,本文将详细分析日志复制的实现。 根据 raft 协议可知,当整个集群完成 Leader 选主后,集群中的主节点就可以接受客户端的请求,而集群中的从节点只负责从主节点同步数据,而不会处理读写请求,与M-S结构的读写分离有着巨大的区别。 有了前篇文章的基础,本文将直接从 Leader 处理客户端请求入口开始,其入口为:DLedgerServer 的 handleAppend 方法开始讲起。 1、日志复制基本流程 在正式分析 RocketMQ DLedger 多副本复制之前,我们首先来了解客户端发送日志的请求协议字段,其类图如下所示:我们先一一介绍各个字段的含义: String group该集群所属组名。 String remoteId请求目的节点ID。 String localId节点ID。 int code请求响应字段,表示返回响应码。 String leaderId = null集群中的Leader Id。 long term集群当前的选举轮次。 byte[] body待发送的数据。 日志的请求处理处理入口为 DLedgerServer 的 handleAppend 方法。 DLedgerServer#handleAppend PreConditions.check(memberState.getSelfId().equals(request.getRemoteId()), DLedgerResponseCode.UNKNOWN_MEMBER, "%s != %s", request.getRemoteId(), memberState.getSelfId()); reConditions.check(memberState.getGroup().equals(request.getGroup()), DLedgerResponseCode.UNKNOWN_GROUP, "%s != %s", request.getGroup(), memberState.getGroup()); PreConditions.check(memberState.isLeader(), DLedgerResponseCode.NOT_LEADER); Step1:首先验证请求的合理性: 如果请求的节点ID不是当前处理节点,则抛出异常。 如果请求的集群不是当前节点所在的集群,则抛出异常。 如果当前节点不是主节点,则抛出异常。 DLedgerServer#handleAppend long currTerm = memberState.currTerm(); if (dLedgerEntryPusher.isPendingFull(currTerm)) { // @1 AppendEntryResponse appendEntryResponse = new AppendEntryResponse(); appendEntryResponse.setGroup(memberState.getGroup()); appendEntryResponse.setCode(DLedgerResponseCode.LEADER_PENDING_FULL.getCode()); appendEntryResponse.setTerm(currTerm); appendEntryResponse.setLeaderId(memberState.getSelfId()); return AppendFuture.newCompletedFuture(-1, appendEntryResponse); } else { // @2 DLedgerEntry dLedgerEntry = new DLedgerEntry(); dLedgerEntry.setBody(request.getBody()); DLedgerEntry resEntry = dLedgerStore.appendAsLeader(dLedgerEntry); return dLedgerEntryPusher.waitAck(resEntry); } Step2:如果预处理队列已经满了,则拒绝客户端请求,返回 LEADER_PENDING_FULL 错误码;如果未满,将请求封装成 DledgerEntry,则调用 dLedgerStore 方法追加日志,并且通过使用 dLedgerEntryPusher 的 waitAck 方法同步等待副本节点的复制响应,并最终将结果返回给调用方法。 代码@1:如果 dLedgerEntryPusher 的 push 队列已满,则返回追加一次,其错误码为 LEADER_PENDING_FULL。 代码@2:追加消息到 Leader 服务器,并向从节点广播,在指定时间内如果未收到从节点的确认,则认为追加失败。 接下来就按照上述三个要点进行展开: 判断 Push 队列是否已满 Leader 节点存储消息 主节点等待从节点复制 ACK 1.1 如何判断 Push 队列是否已满 DLedgerEntryPusher#isPendingFull public boolean isPendingFull(long currTerm) { checkTermForPendingMap(currTerm, "isPendingFull"); // @1 return pendingAppendResponsesByTerm.get(currTerm).size() > dLedgerConfig.getMaxPendingRequestsNum(); // @2 } 主要分两个步骤:代码@1:检查当前投票轮次是否在 PendingMap 中,如果不在,则初始化,其结构为:Map< Long/ 投票轮次/, ConcurrentMap>>。 代码@2:检测当前等待从节点返回结果的个数是否超过其最大请求数量,可通过maxPendingRequestsNum 配置,该值默认为:10000。 上述逻辑比较简单,但疑问随着而来,ConcurrentMap> 中的数据是从何而来的呢?我们不妨接着往下看。 1.2 Leader 节点存储数据 Leader 节点的数据存储主要由 DLedgerStore 的 appendAsLeader 方法实现。DLedger 分别实现了基于内存、基于文件的存储实现,本文重点关注基于文件的存储实现,其实现类为:DLedgerMmapFileStore。 下面重点来分析一下数据存储流程,其入口为DLedgerMmapFileStore 的 appendAsLeader 方法。 DLedgerMmapFileStore#appendAsLeader PreConditions.check(memberState.isLeader(), DLedgerResponseCode.NOT_LEADER); PreConditions.check(!isDiskFull, DLedgerResponseCode.DISK_FULL); Step1:首先判断是否可以追加数据,其判断依据主要是如下两点: 当前节点的状态是否是 Leader,如果不是,则抛出异常。 当前磁盘是否已满,其判断依据是 DLedger 的根目录或数据文件目录的使用率超过了允许使用的最大值,默认值为85%。 ByteBuffer dataBuffer = localEntryBuffer.get(); ByteBuffer indexBuffer = localIndexBuffer.get(); Step2:从本地线程变量获取一个数据与索引 buffer。其中用于存储数据的 ByteBuffer,其容量固定为 4M ,索引的 ByteBuffer 为两个索引条目的长度,固定为64个字节。 DLedgerEntryCoder.encode(entry, dataBuffer); public static void encode(DLedgerEntry entry, ByteBuffer byteBuffer) { byteBuffer.clear(); int size = entry.computSizeInBytes(); //always put magic on the first position byteBuffer.putInt(entry.getMagic()); byteBuffer.putInt(size); byteBuffer.putLong(entry.getIndex()); byteBuffer.putLong(entry.getTerm()); byteBuffer.putLong(entry.getPos()); byteBuffer.putInt(entry.getChannel()); byteBuffer.putInt(entry.getChainCrc()); byteBuffer.putInt(entry.getBodyCrc()); byteBuffer.putInt(entry.getBody().length); byteBuffer.put(entry.getBody()); byteBuffer.flip(); } Step3:将 DLedgerEntry,即将数据写入到 ByteBuffer中,从这里看出,每一次写入会调用 ByteBuffer 的 clear 方法,将数据清空,从这里可以看出,每一次数据追加,只能存储4M的数据。 DLedgerMmapFileStore#appendAsLeader synchronized (memberState) { PreConditions.check(memberState.isLeader(), DLedgerResponseCode.NOT_LEADER, null); // ... 省略代码 } Step4:锁定状态机,并再一次检测节点的状态是否是 Leader 节点。 DLedgerMmapFileStore#appendAsLeader long nextIndex = ledgerEndIndex + 1; entry.setIndex(nextIndex); entry.setTerm(memberState.currTerm()); entry.setMagic(CURRENT_MAGIC); DLedgerEntryCoder.setIndexTerm(dataBuffer, nextIndex, memberState.currTerm(), CURRENT_MAGIC); Step5:为当前日志条目设置序号,即 entryIndex 与 entryTerm (投票轮次)。并将魔数、entryIndex、entryTerm 等写入到 bytebuffer 中。 DLedgerMmapFileStore#appendAsLeader long prePos = dataFileList.preAppend(dataBuffer.remaining()); entry.setPos(prePos); PreConditions.check(prePos != -1, DLedgerResponseCode.DISK_ERROR, null); DLedgerEntryCoder.setPos(dataBuffer, prePos); Step6:计算新的消息的起始偏移量,关于 dataFileList 的 preAppend 后续详细介绍其实现,然后将该偏移量写入日志的 bytebuffer 中。 DLedgerMmapFileStore#appendAsLeader for (AppendHook writeHook : appendHooks) { writeHook.doHook(entry, dataBuffer.slice(), DLedgerEntry.BODY_OFFSET); } Step7:执行钩子函数。 DLedgerMmapFileStore#appendAsLeader long dataPos = dataFileList.append(dataBuffer.array(), 0, dataBuffer.remaining()); PreConditions.check(dataPos != -1, DLedgerResponseCode.DISK_ERROR, null); PreConditions.check(dataPos == prePos, DLedgerResponseCode.DISK_ERROR, null); Step8:将数据追加到 pagecache 中。该方法稍后详细介绍。 DLedgerMmapFileStore#appendAsLeader DLedgerEntryCoder.encodeIndex(dataPos, entrySize, CURRENT_MAGIC, nextIndex, memberState.currTerm(), indexBuffer); long indexPos = indexFileList.append(indexBuffer.array(), 0, indexBuffer.remaining(), false); PreConditions.check(indexPos == entry.getIndex() * INDEX_UNIT_SIZE, DLedgerResponseCode.DISK_ERROR, null); Step9:构建条目索引并将索引数据追加到 pagecache。 DLedgerMmapFileStore#appendAsLeader ledgerEndIndex++; ledgerEndTerm = memberState.currTerm(); if (ledgerBeginIndex == -1) { ledgerBeginIndex = ledgerEndIndex; } updateLedgerEndIndexAndTerm(); Step10:ledgerEndeIndex 加一(下一个条目)的序号。并设置 leader 节点的状态机的 ledgerEndIndex 与 ledgerEndTerm。 Leader 节点数据追加就介绍到这里,稍后会重点介绍与存储相关方法的实现细节。 1.3 主节点等待从节点复制 ACK 其实现入口为 dLedgerEntryPusher 的 waitAck 方法。 DLedgerEntryPusher#waitAck public CompletableFuture<AppendEntryResponse> waitAck(DLedgerEntry entry) { updatePeerWaterMark(entry.getTerm(), memberState.getSelfId(), entry.getIndex()); // @1 if (memberState.getPeerMap().size() == 1) { // @2 AppendEntryResponse response = new AppendEntryResponse(); response.setGroup(memberState.getGroup()); response.setLeaderId(memberState.getSelfId()); response.setIndex(entry.getIndex()); response.setTerm(entry.getTerm()); response.setPos(entry.getPos()); return AppendFuture.newCompletedFuture(entry.getPos(), response); } else { checkTermForPendingMap(entry.getTerm(), "waitAck"); AppendFuture<AppendEntryResponse> future = new AppendFuture<>(dLedgerConfig.getMaxWaitAckTimeMs()); // @3 future.setPos(entry.getPos()); CompletableFuture<AppendEntryResponse> old = pendingAppendResponsesByTerm.get(entry.getTerm()).put(entry.getIndex(), future); // @4 if (old != null) { logger.warn("[MONITOR] get old wait at index={}", entry.getIndex()); } wakeUpDispatchers(); // @5 return future; } } 代码@1:更新当前节点的 push 水位线。代码@2:如果集群的节点个数为1,无需转发,直接返回成功结果。代码@3:构建 append 响应 Future 并设置超时时间,默认值为:2500 ms,可以通过 maxWaitAckTimeMs 配置改变其默认值。代码@4:将构建的 Future 放入等待结果集合中。代码@5:唤醒 Entry 转发线程,即将主节点中的数据 push 到各个从节点。 接下来分别对上述几个关键点进行解读。 1.3.1 updatePeerWaterMark 方法 DLedgerEntryPusher#updatePeerWaterMark private void updatePeerWaterMark(long term, String peerId, long index) { // 代码@1 synchronized (peerWaterMarksByTerm) { checkTermForWaterMark(term, "updatePeerWaterMark"); // 代码@2 if (peerWaterMarksByTerm.get(term).get(peerId) < index) { // 代码@3 peerWaterMarksByTerm.get(term).put(peerId, index); } } } 代码@1:先来简单介绍该方法的两个参数: long term 当前的投票轮次。 String peerId当前节点的ID。 long index当前追加数据的序号。 代码@2:初始化 peerWaterMarksByTerm 数据结构,其结果为 < Long / term */, Map< String / peerId */, Long /** entry index*/>。 代码@3:如果 peerWaterMarksByTerm 存储的 index 小于当前数据的 index,则更新。 1.3.2 wakeUpDispatchers 详解 DLedgerEntryPusher#updatePeerWaterMark public void wakeUpDispatchers() { for (EntryDispatcher dispatcher : dispatcherMap.values()) { dispatcher.wakeup(); } } 该方法主要就是遍历转发器并唤醒。本方法的核心关键就是 EntryDispatcher,在详细介绍它之前我们先来看一下该集合的初始化。 DLedgerEntryPusher 构造方法 for (String peer : memberState.getPeerMap().keySet()) { if (!peer.equals(memberState.getSelfId())) { dispatcherMap.put(peer, new EntryDispatcher(peer, logger)); } } 原来在构建 DLedgerEntryPusher 时会为每一个从节点创建一个 EntryDispatcher 对象。 显然,日志的复制由 DLedgerEntryPusher 来实现。由于篇幅的原因,该部分内容将在下篇文章中继续。 上面在讲解 Leader 追加日志时并没有详细分析存储相关的实现,为了知识体系的完备,接下来我们来分析一下其核心实现。 2、日志存储实现详情 本节主要对 MmapFileList 的 preAppend 与 append 方法进行详细讲解。 存储部分的设计请查阅笔者的博客:源码分析 RocketMQ DLedger 多副本存储实现,MmapFileList 对标 RocketMQ 的MappedFileQueue。 2.1 MmapFileList 的 preAppend 详解 该方法最终会调用两个参数的 preAppend方法,故我们直接来看两个参数的 preAppend 方法。 MmapFileList#preAppend public long preAppend(int len, boolean useBlank) { // @1 MmapFile mappedFile = getLastMappedFile(); // @2 start if (null == mappedFile || mappedFile.isFull()) { mappedFile = getLastMappedFile(0); } if (null == mappedFile) { logger.error("Create mapped file for {}", storePath); return -1; } // @2 end int blank = useBlank ? MIN_BLANK_LEN : 0; if (len + blank > mappedFile.getFileSize() - mappedFile.getWrotePosition()) { // @3 if (blank < MIN_BLANK_LEN) { logger.error("Blank {} should ge {}", blank, MIN_BLANK_LEN); return -1; } else { ByteBuffer byteBuffer = ByteBuffer.allocate(mappedFile.getFileSize() - mappedFile.getWrotePosition()); // @4 byteBuffer.putInt(BLANK_MAGIC_CODE); // @5 byteBuffer.putInt(mappedFile.getFileSize() - mappedFile.getWrotePosition()); // @6 if (mappedFile.appendMessage(byteBuffer.array())) { // @7 //need to set the wrote position mappedFile.setWrotePosition(mappedFile.getFileSize()); } else { logger.error("Append blank error for {}", storePath); return -1; } mappedFile = getLastMappedFile(0); if (null == mappedFile) { logger.error("Create mapped file for {}", storePath); return -1; } } } return mappedFile.getFileFromOffset() + mappedFile.getWrotePosition();// @8 } 代码@1:首先介绍其参数的含义: int len 需要申请的长度。 boolean useBlank 是否需要填充,默认为true。 代码@2:获取最后一个文件,即获取当前正在写的文件。 代码@3:如果需要申请的资源超过了当前文件可写字节时,需要处理的逻辑。代码@4-@7都是其处理逻辑。 代码@4:申请一个当前文件剩余字节的大小的bytebuffer。 代码@5:先写入魔数。 代码@6:写入字节长度,等于当前文件剩余的总大小。 代码@7:写入空字节,代码@4-@7的用意就是写一条空Entry,填入魔数与 size,方便解析。 代码@8:如果当前文件足以容纳待写入的日志,则直接返回其物理偏移量。 经过上述代码解读,我们很容易得出该方法的作用,就是返回待写入日志的起始物理偏移量。 2.2 MmapFileList 的 append 详解 最终会调用4个参数的 append 方法,其代码如下:MmapFileList#append public long append(byte[] data, int pos, int len, boolean useBlank) { // @1 if (preAppend(len, useBlank) == -1) { return -1; } MmapFile mappedFile = getLastMappedFile(); // @2 long currPosition = mappedFile.getFileFromOffset() + mappedFile.getWrotePosition(); // @3 if (!mappedFile.appendMessage(data, pos, len)) { // @4 logger.error("Append error for {}", storePath); return -1; } return currPosition; } 代码@1:首先介绍一下各个参数: byte[] data待写入的数据,即待追加的日志。 int pos从 data 字节数组哪个位置开始读取。 int len待写入的字节数量。 boolean useBlank是否使用填充,默认为 true。 代码@2:获取最后一个文件,即当前可写的文件。 代码@3:获取当前写入指针。 代码@4:追加消息。 最后我们再来看一下 appendMessage,具体的消息追加实现逻辑。 DefaultMmapFile#appendMessage public boolean appendMessage(final byte[] data, final int offset, final int length) { int currentPos = this.wrotePosition.get(); if ((currentPos + length) <= this.fileSize) { ByteBuffer byteBuffer = this.mappedByteBuffer.slice(); // @1 byteBuffer.position(currentPos); byteBuffer.put(data, offset, length); this.wrotePosition.addAndGet(length); return true; } return false; } 该方法我主要是想突出一下写入的方式是 mappedByteBuffer,是通过 FileChannel 的 map 方法创建,即我们常说的 PageCache,即消息追加首先是写入到 pageCache 中。 本文详细介绍了 Leader 节点处理客户端消息追加请求的前面两个步骤,即 判断 Push 队列是否已满 与 Leader 节点存储消息。考虑到篇幅的问题,各个节点的数据同步将在下一篇文章中详细介绍。 在进入下一篇的文章学习之前,我们不妨思考一下如下问题: 如果主节点追加成功(写入到 PageCache),但同步到从节点过程失败或此时主节点宕机,集群中的数据如何保证一致性? 推荐阅读:源码分析RocketMQ DLedger 多副本系列连载中。1、RocketMQ 多副本前置篇:初探raft协议2、源码分析 RocketMQ DLedger 多副本之 Leader 选主3、源码分析 RocketMQ DLedger 多副本存储实现 原文发布时间为:2019-09-15本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
消息组接到某项目组反馈,topic 在扩容后出现部分队列无法被消费者,导致消息积压,影响线上业务? 考虑到该问题是发送在真实的线上环境,为了避免泄密,本文先在笔者的虚拟机中来重现问题。 1、案情回顾 1.1 集群现状 集群信息如下:例如业务主体名 topic_dw_test_by_order_01 的路由信息如图所示:当前的消费者信息:broker 的配置信息如下: brokerClusterName = DefaultCluster brokerName = broker-a brokerId = 0 deleteWhen = 04 fileReservedTime = 48 brokerRole = ASYNC_MASTER flushDiskType = ASYNC_FLUSH brokerIP1=192.168.0.220 brokerIP2-192.168.0.220 namesrvAddr=192.168.0.221:9876;192.168.0.220:9876 storePathRootDir=/opt/application/rocketmq-all-4.5.2-bin-release/store storePathCommitLog=/opt/application/rocketmq-all-4.5.2-bin-release/store/commitlog autoCreateTopicEnable=false autoCreateSubscriptionGroup=false 备注:公司对 topic、消费组进行了严格的管控,项目组需要使用时需要向运维人员申请,故 broker 集群不允许自动创建主题与自动创建消费组。 由于该业务量稳步提升,项目组觉得该主题的队列数太少,不利于增加消费者来提高其消费能力,故向运维人员提出增加队列的需求。 1.2、RocketMQ 在线扩容队列 运维通过公司自研的消息运维平台,直接以指定集群的方式为 topic 扩容,该运维平台底层其实使用了RocketMQ 提供的 updateTopic 命令,其命令说明如下:从上图可以得知可以通过 -c 命令来指定在集群中所有的 broker 上创建队列,在本例中,将队列数从 4 设置为 8,具体命令如下: sh ./mqadmin upateTopic -n 192.168.0.220:9876 -c DefaultCluster -t topic_dw_test_by_order_01 -r 8 -w 8 执行效果如图所示,表示更新成功。我们再来从 rocketmq-console 中来看命令执行后的效果:从上图可以得知,主题的队列数已经扩容到了8个,并且在集群的两台broker上都创建了队列。 1.3 消息发送 从 RocketMQ 系列可知,RocketMQ 是支持在线 topic 在线扩容机制的,故无需重启 消息发送者、消息消费者,随着时间的推移,我们可以查看topic的所有队列都参与到了消息的负载中,如图所示:我们可以清晰的看到,所有的16个队列(每个 broker 8个队列)都参与到了消息发送的,运维小哥愉快的完成了topic的扩容。 2、问题暴露 该 topic 被 5个消费组所订阅,突然接到通知,其中有两个消费组反馈,部分队列的消息没有被消费,导致下游系统并没有及时处理。 3、问题分析 当时到项目组提交到消息组时,我第一反应是先看消费者的队列,打开该主题的消费情况,如图所示:发现队列数并没有积压,备注(由于生产是4主4从,每一个 broker上8个队列,故总共32个队列),当时由于比较急,并没有第一时间发现这个界面,竟然只包含一个消费者,觉得并没有消息积压,又由于同一个集群,其他消费组没有问题,只有两个消费组有问题,怀疑是应用的问题,就采取了重启,打印线程栈等方法。 事后诸葛亮:其实这完成是错误的,为什么这样说呢?因为项目组(业务方)已经告知一部分业务未处理,说明肯定有队列的消息积压,当根据自己的知识,结合看到的监控页面做出的判断与业务方反馈的出现冲突时,一定是自己的判断出了问题。 正在我们“如火如荼”的认定是项目有问题时,团队的另一成员提出了自己的观点,原来在得到业务方反馈时,他得知同一个主题,被5个消费组订阅,只有其中两个有问题,那他通过rocketmq-console来找两者的区别,找到区别,找到规律,就离解决问题的路近了。 他通过对比发现,出问题的消费组只有两个客户端在消费(通常生产环境是4节点消费),而没有出现问题的发现有4个进程都在处理,即发现现象:出错的消费组,并没有全员参与到消费。正如上面的图所示:只有其中一个进程在处理8个队列,另外8个队列并没有在消费。 那现在就是要分析为啥topic共有16个队列,但这里只有1个消费者队列在消费,另外一个消费者不作为? 首先根据RocketMQ 消息队列负载机制,2个消费者,只有1个消费者在消费,并且一个有一个明显的特点是,只有broker-a上的队列在消费,broker-b上的队列一个也没消费。 正在思考为啥会出现这种现象时,他又在思考是不是集群是不是broker-b(对应我们生产环境是broker-c、broker-d上的队列都未消费)是新扩容的机器?扩容的时候是不是没有把订阅关系在新的集群上创建?提出了疑问,接下来肖工就开始验证猜想,通过查阅broker-c、broker-d在我们系统中创建的时间是2018-4月的时候,就基本得出结论,扩容时并没有在新集群上创建订阅消息,故无法消费消息。 于是运维小哥使用运维工具创建订阅组,创建方法如图所示:创建好消费组后,再去查看topic的消费情况时,另外一个消费组也开始处理消息了,如下图所示: 4、问题复盘 潜在原因:DefaultCluster 集群进行过一次集群扩容,从原来的一台消息服务器( broker-a )额外增加一台broker服务器( broker-b ),但扩容的时候并没有把原先的存在于 broker-a 上的主题、消费组扩容到 broker-b 服务器。 触发原因:接到项目组的扩容需求,将集群队列数从4个扩容到8个,这样该topic就在集群的a、b都会存在8个队列,但Broker不允许自动创建消费组(订阅关系),消费者无法从broker-b上队列上拉取消息,导致在broker-b队列上的消息堆积,无法被消费。 解决办法:运维通过命令,在broker-b上创建对应的订阅消息,问题解决。 经验教训:集群扩容时,需要同步在集群上的topic.json、subscriptionGroup.json文件。 RocketMQ 理论基础,消费者向 Broker 发起消息拉取请求时,如果broker上并没有存在该消费组的订阅消息时,如果不允许自动创建(autoCreateSubscriptionGroup 设置为 false),默认为true,则不会返回消息给客户端,其代码如下:问题解决后,我们团队的成员也分享了一下他在本次排查问题的处理方法:寻找出现问题的规律、推断问题、 然后验证问题。规律可以是问题本身的规律 也可以是和正常对比的差。 原文发布时间为:2019-09-08本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
RocketMQ DLedger 的存储实现思路与 RocketMQ 的存储实现思路相似,本文就不再从源码角度详细剖析其实现,只是点出其实现关键点。我们不妨简单回顾一下 CommitLog 文件、ConsumeQueue 文件设计思想。 其文件组成形式如下:正如上图所示,多个 commitlog 文件组成一个逻辑上的连续文件,使用 MappedFileQueue 表示,单个 commitlog 文件使用 MappedFile 表示。 温馨提示:如果想详细了解 RocketMQ 关于存储部分的讲解,可以关注笔者的《RocketMQ 技术内幕》一书。 1、DLedger 存储相关类图 1.1 DLedgerStore 存储抽象类,定义如下核心方法: public abstract DLedgerEntry appendAsLeader(DLedgerEntry entry)向主节点追加日志(数据)。 public abstract DLedgerEntry appendAsFollower(DLedgerEntry entry, long leaderTerm, String leaderId)向从节点同步日志。 public abstract DLedgerEntry get(Long index)根据日志下标查找日志。 public abstract long getCommittedIndex()获取已提交的下标。 public abstract long getLedgerEndTerm()获取 Leader 当前最大的投票轮次。 public abstract long getLedgerEndIndex()获取 Leader 下一条日志写入的下标(最新日志的下标)。 public abstract long getLedgerBeginIndex()获取 Leader 第一条消息的下标。 public void updateCommittedIndex(long term, long committedIndex)更新commitedIndex的值,为空实现,由具体的存储子类实现。 protected void updateLedgerEndIndexAndTerm()更新 Leader 维护的 ledgerEndIndex 和 ledgerEndTerm 。 public void flush()刷写,空方法,由具体子类实现。 public long truncate(DLedgerEntry entry, long leaderTerm, String leaderId)删除日志,空方法,由具体子类实现。 public void startup()启动存储管理器,空方法,由具体子类实现。 public void shutdown()关闭存储管理器,空方法,由具体子类实现。 1.2 DLedgerMemoryStore Dledger 基于内存实现的日志存储。 1.3 DLedgerMmapFileStore 基于文件内存映射机制的存储实现。其核心属性如下: long ledgerBeginIndex = -1日志的起始索引,默认为 -1。 l- ong ledgerEndIndex = -1下一条日志下标,默认为 -1。 long committedIndex = -1已提交的日志索引。 long ledgerEndTerm当前最大的投票轮次。 DLedgerConfig dLedgerConfigDLedger 的配置信息。 MemberState memberState状态机。 MmapFileList dataFileList日志文件(数据文件)的内存映射Queue。 MmapFileList indexFileList索引文件的内存映射文件集合。(可对标 RocketMQ MappedFIleQueue )。 ThreadLocal< ByteBuffer> localIndexBuffer本地线程变量,用来缓存索引ByteBuffer。 ThreadLocal< ByteBuffer> localEntryBuffer本地线程变量,用来缓存数据索引ByteBuffer。 FlushDataService flushDataService数据文件刷盘线程。 CleanSpaceService cleanSpaceService清除过期日志文件线程。 boolean isDiskFull = false磁盘是否已满。 long lastCheckPointTimeMs上一次检测点(时间戳)。 AtomicBoolean hasLoaded是否已经加载,主要用来避免重复加载(初始化)日志文件。 AtomicBoolean hasRecovered是否已恢复。 2、DLedger 存储 对标 RocketMQ 存储 存储部分主要包含存储映射文件、消息存储格式、刷盘、文件加载与文件恢复、过期文件删除等,由于这些内容在 RocketMQ 存储部分都已详细介绍,故本文点到为止,其对应的参考映射如下:在 RocketMQ 中使用 MappedFile 来表示一个物理文件,而在 DLedger 中使用 DefaultMmapFIle 来表示一个物理文件。 在 RocketMQ 中使用 MappedFile 来表示多个物理文件(逻辑上连续),而在 DLedger 中则使用MmapFileList。 在 RocketMQ 中使用 DefaultMessageStore 来封装存储逻辑,而在 DLedger 中则使用DLedgerMmapFileStore来封装存储逻辑。 在 RocketMQ 中使用 Commitlog$FlushCommitLogService 来实现 commitlog 文件的刷盘,而在 DLedger 中则使用DLedgerMmapFileStore$FlushDataService来实现文件刷盘。 在 RocketMQ 中使用 DefaultMessageStore$CleanCommitlogService 来实现 commitlog 过期文件的删除,而 DLedger 中则使用 DLedgerMmapFileStore$CleanSpaceService来实现。 由于其实现原理相同,上述部分已经在《RocketMQ 技术内幕》第4章中详细剖析,故这里就不重复分析了。 3、DLedger 数据存储格式 存储格式字段的含义如下: magic魔数,4字节。 size条目总长度,包含 Header(协议头) + 消息体,占4字节。 entryIndex当前条目的 index,占8字节。 entryTerm当前条目所属的 投票轮次,占8字节。 pos该条目的物理偏移量,类似于 commitlog 文件的物理偏移量,占8字节。 channel保留字段,当前版本未使用,占4字节。 chain crc当前版本未使用,占4字节。 body crc 的 CRC 校验和,用来区分数据是否损坏,占4字节。 body size用来存储 body 的长度,占4个字节。 body具体消息的内容。 源码参考点:DLedgerMmapFileStore#recover、DLedgerEntry、DLedgerEntryCoder。 4、DLedger 索引存储格式 即一个索引条目占32个字节。 5、思考 DLedger 存储相关就介绍到这里,为了与大家增加互动,特提出如下两个思考题,欢迎与作者互动,这些问题将在该系列的后面文章专题探讨。 1、DLedger 如果整合 RocketMQ 中的 commitlog 文件,使之支持多副本?2、从老版本如何升级到新版本,需要考虑哪些因素呢? 原文发布时间为:2019-09-01本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
本文将按照《RocketMQ 多副本前置篇:初探raft协议》的思路来学习RocketMQ选主逻辑。首先先回顾一下关于Leader的一些思考: 节点状态需要引入3种节点状态:Follower(跟随者)、Candidate(候选者),该状态下的节点会发起投票请求,Leader(主节点)。 选举计时器Follower、Candidate两个状态时,需要维护一个定时器,每次定时时间从150ms-300ms直接进行随机,即每个节点的定时过期不一样,Follower状态时,定时器到点后,触发一轮投票。节点在收到投票请求、Leader的心跳请求并作出响应后,需要重置定时器。 投票轮次TeamCandidate状态的节点,每发起一轮投票,Team加一。 投票机制每一轮一个节点只能为一个节点投赞成票,例如节点A中维护的轮次为3,并且已经为节点B投了赞成票,如果收到其他节点,投票轮次为3,则会投反对票,如果收到轮次为4的节点,是又可以投赞成票的。 成为Leader的条件必须得到集群中初始数量的大多数,例如如果集群中有3台,则必须得到两票,如果其中一台服务器宕机,剩下的两个节点,还能进行选主吗?答案是可以的,因为可以得到2票,超过初始集群中3的一半,所以通常集群中的机器各位尽量为奇数,因为4台的可用性与3台的一样。 温馨提示:本文是从源码的角度分析 DLedger 选主实现原理,可能比较鼓噪,文末给出了选主流程图。 1、DLedger关于选主的核心类图 1.1 DLedgerConfig 多副本模块相关的配置信息,例如集群节点信息。 1.2 MemberState 节点状态机,即raft协议中的follower、candidate、leader三种状态的状态机实现。 1.3 raft协议相关 1.3.1 DLedgerClientProtocol DLedger客户端协议,主要定义如下三个方法,在后面的日志复制部分会重点阐述。 CompletableFuture< GetEntriesResponse> get(GetEntriesRequest request)客户端从服务器获取日志条目(获取数据) CompletableFuture< AppendEntryResponse> append(AppendEntryRequest request)客户端向服务器追加日志(存储数据) CompletableFuture< MetadataResponse> metadata(MetadataRequest request)获取元数据。 1.3.2 DLedgerProtocol DLedger服务端协议,主要定义如下三个方法。 CompletableFuture< VoteResponse> vote(VoteRequest request)发起投票请求。 CompletableFuture< HeartBeatResponse> heartBeat(HeartBeatRequest request)Leader向从节点发送心跳包。 CompletableFuture< PullEntriesResponse> pull(PullEntriesRequest request)拉取日志条目,在日志复制部分会详细介绍。 CompletableFuture< PushEntryResponse> push(PushEntryRequest request)推送日志条件,在日志复制部分会详细介绍。 1.3.3 协议处理Handler DLedgerClientProtocolHandler、DLedgerProtocolHander协议处理器。 1.4 DLedgerRpcService DLedger Server(节点)之间的网络通信,默认基于Netty实现,其实现类为:DLedgerRpcNettyService。 1.5 DLedgerLeaderElector Leader选举实现器。 1.6 DLedgerServer Dledger Server,Dledger节点的封装类。 接下来将从DLedgerLeaderElector开始剖析DLedger是如何实现Leader选举的。(基于raft协议)。 2、源码分析Leader选举 2.1 DLedgerLeaderElector 类图 我们先一一来介绍其属性的含义: Random random随机数生成器,对应raft协议中选举超时时间是一随机数。 DLedgerConfig dLedgerConfig配置参数。 MemberState memberState节点状态机。 DLedgerRpcService dLedgerRpcServicerpc服务,实现向集群内的节点发送心跳包、投票的RPC实现。 l- ong lastLeaderHeartBeatTime上次收到心跳包的时间戳。 long lastSendHeartBeatTime上次发送心跳包的时间戳。 long lastSuccHeartBeatTime上次成功收到心跳包的时间戳。 int heartBeatTimeIntervalMs一个心跳包的周期,默认为2s。 int maxHeartBeatLeak允许最大的N个心跳周期内未收到心跳包,状态为Follower的节点只有超过 maxHeartBeatLeak * heartBeatTimeIntervalMs 的时间内未收到主节点的心跳包,才会重新进入 Candidate 状态,重新下一轮的选举。 long nextTimeToRequestVote发送下一个心跳包的时间戳。 boolean needIncreaseTermImmediately是否应该立即发起投票。 int minVoteIntervalMs最小的发送投票间隔时间,默认为300ms。 int maxVoteIntervalMs最大的发送投票的间隔,默认为1000ms。 List< RoleChangeHandler> roleChangeHandlers注册的节点状态处理器,通过 addRoleChangeHandler 方法添加。 long lastVoteCost上一次投票的开销。 StateMaintainer stateMaintainer状态机管理器。 2.2 启动选举状态管理器 通过 DLedgerLeaderElector 的 startup 方法启动状态管理机,代码如下:DLedgerLeaderElector#startup public void startup() { stateMaintainer.start(); // @1 for (RoleChangeHandler roleChangeHandler : roleChangeHandlers) { // @2 roleChangeHandler.startup(); } } 代码@1:启动状态维护管理器。 代码@2:遍历状态改变监听器并启动它,可通过DLedgerLeaderElector 的 addRoleChangeHandler 方法增加状态变化监听器。 其中的是启动状态管理器线程,其run方法实现: public void run() { while (running.get()) { try { doWork(); } catch (Throwable t) { if (logger != null) { logger.error("Unexpected Error in running {} ", getName(), t); } } } latch.countDown(); } 从上面来看,主要是循环调用doWork方法,接下来重点看其doWork的实现: public void doWork() { try { if (DLedgerLeaderElector.this.dLedgerConfig.isEnableLeaderElector()) { // @1 DLedgerLeaderElector.this.refreshIntervals(dLedgerConfig); // @2 DLedgerLeaderElector.this.maintainState(); // @3 } sleep(10); // @4 } catch (Throwable t) { DLedgerLeaderElector.logger.error("Error in heartbeat", t); } } 代码@1:如果该节点参与Leader选举,则首先调用@2重置定时器,然后驱动状态机(@3),是接下来重点需要剖析的。 代码@4:没执行一次选主,休息10ms。 DLedgerLeaderElector#maintainState private void maintainState() throws Exception { if (memberState.isLeader()) { maintainAsLeader(); } else if (memberState.isFollower()) { maintainAsFollower(); } else { maintainAsCandidate(); } } 根据当前的状态机状态,执行对应的操作,从raft协议中可知,总共存在3种状态: leader领导者,主节点,该状态下,需要定时向从节点发送心跳包,用来传播数据、确保其领导地位。 follower从节点,该状态下,会开启定时器,尝试进入到candidate状态,以便发起投票选举,同时一旦收到主节点的心跳包,则重置定时器。 candidate候选者,该状态下的节点会发起投票,尝试选择自己为主节点,选举成功后,不会存在该状态下的节点。 我们在继续往下看之前,需要知道 memberState 的初始值是什么?我们追溯到创建 MemberState 的地方,发现其初始状态为 CANDIDATE。那我们接下从 maintainAsCandidate 方法开始跟进。 温馨提示:在raft协议中,节点的状态默认为follower,DLedger的实现从candidate开始,一开始,集群内的所有节点都会尝试发起投票,这样第一轮要达成选举几乎不太可能。 2.3 选举状态机状态流转 整个状态机的驱动,由线程反复执行maintainState方法。下面重点来分析其状态的驱动。 2.3.1 maintainAsCandidate 方法 DLedgerLeaderElector#maintainAsCandidate if (System.currentTimeMillis() < nextTimeToRequestVote && !needIncreaseTermImmediately) { return; } long term; long ledgerEndTerm; long ledgerEndIndex; Step1:首先先介绍几个变量的含义: nextTimeToRequestVote下一次发发起的投票的时间,如果当前时间小于该值,说明计时器未过期,此时无需发起投票。 needIncreaseTermImmediately是否应该立即发起投票。如果为true,则忽略计时器,该值默认为false,当收到从主节点的心跳包并且当前状态机的轮次大于主节点的轮次,说明集群中Leader的投票轮次小于从几点的轮次,应该立即发起新的投票。 term投票轮次。 ledgerEndTermLeader节点当前的投票轮次。 ledgerEndIndex当前日志的最大序列,即下一条日志的开始index,在日志复制部分会详细介绍。 DLedgerLeaderElector#maintainAsCandidate synchronized (memberState) { if (!memberState.isCandidate()) { return; } if (lastParseResult == VoteResponse.ParseResult.WAIT_TO_VOTE_NEXT || needIncreaseTermImmediately) { long prevTerm = memberState.currTerm(); term = memberState.nextTerm(); logger.info("{}_[INCREASE_TERM] from {} to {}", memberState.getSelfId(), prevTerm, term); lastParseResult = VoteResponse.ParseResult.WAIT_TO_REVOTE; } else { term = memberState.currTerm(); } ledgerEndIndex = memberState.getLedgerEndIndex(); ledgerEndTerm = memberState.getLedgerEndTerm(); } Step2:初始化team、ledgerEndIndex 、ledgerEndTerm 属性,其实现关键点如下: 如果上一次的投票结果为待下一次投票或应该立即开启投票,并且根据当前状态机获取下一轮的投票轮次,稍后会着重讲解一下状态机轮次的维护机制。 如果上一次的投票结果不是WAIT_TO_VOTE_NEXT(等待下一轮投票),则投票轮次依然为状态机内部维护的轮次。 DLedgerLeaderElector#maintainAsCandidate if (needIncreaseTermImmediately) { nextTimeToRequestVote = getNextTimeToRequestVote(); needIncreaseTermImmediately = false; return; } Step3:如果needIncreaseTermImmediately为true,则重置该标记位为false,并重新设置下一次投票超时时间,其实现代码如下: private long getNextTimeToRequestVote() { return System.currentTimeMillis() + lastVoteCost + minVoteIntervalMs + random.nextInt(maxVoteIntervalMs - minVoteIntervalMs); } 下一次倒计时:当前时间戳 + 上次投票的开销 + 最小投票间隔(300ms) + (1000- 300 )之间的随机值。 final List<CompletableFuture<VoteResponse>> quorumVoteResponses = voteForQuorumResponses(term, ledgerEndTerm, ledgerEndIndex); Step4:向集群内的其他节点发起投票请,并返回投票结果列表,稍后会重点分析其投票过程。可以预见,接下来就是根据各投票结果进行仲裁。 final AtomicLong knownMaxTermInGroup = new AtomicLong(-1); final AtomicInteger allNum = new AtomicInteger(0); final AtomicInteger validNum = new AtomicInteger(0); final AtomicInteger acceptedNum = new AtomicInteger(0); final AtomicInteger notReadyTermNum = new AtomicInteger(0); final AtomicInteger biggerLedgerNum = new AtomicInteger(0); final AtomicBoolean alreadyHasLeader = new AtomicBoolean(false); Step5:在进行投票结果仲裁之前,先来介绍几个局部变量的含义: knownMaxTermInGroup已知的最大投票轮次。 allNum 所有投票票数。 validNum有效投票数。 acceptedNum获得的投票数。 notReadyTermNum未准备投票的节点数量,如果对端节点的投票轮次小于发起投票的轮次,则认为对端未准备好,对端节点使用本次的轮次进入 - Candidate 状态。 biggerLedgerNum 发起投票的节点的ledgerEndTerm小于对端节点的个数。 alreadyHasLeader 是否已经存在Leader。 for (CompletableFuture<VoteResponse> future : quorumVoteResponses) { // 省略部分代码 } Step5:遍历投票结果,收集投票结果,接下来重点看其内部实现。 if (x.getVoteResult() != VoteResponse.RESULT.UNKNOWN) { validNum.incrementAndGet(); } Step6:如果投票结果不是UNKNOW,则有效投票数量增1。 synchronized (knownMaxTermInGroup) { switch (x.getVoteResult()) { case ACCEPT: acceptedNum.incrementAndGet(); break; case REJECT_ALREADY_VOTED: break; case REJECT_ALREADY_HAS_LEADER: alreadyHasLeader.compareAndSet(false, true); break; case REJECT_TERM_SMALL_THAN_LEDGER: case REJECT_EXPIRED_VOTE_TERM: if (x.getTerm() > knownMaxTermInGroup.get()) { knownMaxTermInGroup.set(x.getTerm()); } break; case REJECT_EXPIRED_LEDGER_TERM: case REJECT_SMALL_LEDGER_END_INDEX: biggerLedgerNum.incrementAndGet(); break; case REJECT_TERM_NOT_READY: notReadyTermNum.incrementAndGet(); break; default: break; } } Step7:统计投票结构,几个关键点如下: ACCEPT赞成票,acceptedNum加一,只有得到的赞成票超过集群节点数量的一半才能成为Leader。 REJECT_ALREADY_VOTED拒绝票,原因是已经投了其他节点的票。 REJECT_ALREADY_HAS_LEADER拒绝票,原因是因为集群中已经存在Leaer了。alreadyHasLeader设置为true,无需在判断其他投票结果了,结束本轮投票。 REJECT_TERM_SMALL_THAN_LEDGER拒绝票,如果自己维护的term小于远端维护的ledgerEndTerm,则返回该结果,如果对端的team大于自己的team,需要记录对端最大的投票轮次,以便更新自己的投票轮次。 REJECT_EXPIRED_VOTE_TERM拒绝票,如果自己维护的term小于远端维护的term,更新自己维护的投票轮次。 REJECT_EXPIRED_LEDGER_TERM拒绝票,如果自己维护的 ledgerTerm小于对端维护的ledgerTerm,则返回该结果。如果是此种情况,增加计数器- biggerLedgerNum的值。 REJECT_SMALL_LEDGER_END_INDEX拒绝票,如果对端的ledgerTeam与自己维护的ledgerTeam相等,但是自己维护的dedgerEndIndex小于对端维护的值,返回该值,增加biggerLedgerNum计数器的值。 REJECT_TERM_NOT_READY拒绝票,对端的投票轮次小于自己的team,则认为对端还未准备好投票,对端使用自己的投票轮次,是自己进入到Candidate状态。 try { voteLatch.await(3000 + random.nextInt(maxVoteIntervalMs), TimeUnit.MILLISECONDS); } catch (Throwable ignore) { } Step8:等待收集投票结果,并设置超时时间。 lastVoteCost = DLedgerUtils.elapsed(startVoteTimeMs); VoteResponse.ParseResult parseResult; if (knownMaxTermInGroup.get() > term) { parseResult = VoteResponse.ParseResult.WAIT_TO_VOTE_NEXT; nextTimeToRequestVote = getNextTimeToRequestVote(); changeRoleToCandidate(knownMaxTermInGroup.get()); } else if (alreadyHasLeader.get()) { parseResult = VoteResponse.ParseResult.WAIT_TO_VOTE_NEXT; nextTimeToRequestVote = getNextTimeToRequestVote() + heartBeatTimeIntervalMs * maxHeartBeatLeak; } else if (!memberState.isQuorum(validNum.get())) { parseResult = VoteResponse.ParseResult.WAIT_TO_REVOTE; nextTimeToRequestVote = getNextTimeToRequestVote(); } else if (memberState.isQuorum(acceptedNum.get())) { parseResult = VoteResponse.ParseResult.PASSED; } else if (memberState.isQuorum(acceptedNum.get() + notReadyTermNum.get())) { parseResult = VoteResponse.ParseResult.REVOTE_IMMEDIATELY; } else if (memberState.isQuorum(acceptedNum.get() + biggerLedgerNum.get())) { parseResult = VoteResponse.ParseResult.WAIT_TO_REVOTE; nextTimeToRequestVote = getNextTimeToRequestVote(); } else { parseResult = VoteResponse.ParseResult.WAIT_TO_VOTE_NEXT; nextTimeToRequestVote = getNextTimeToRequestVote(); } Step9:根据收集的投票结果判断是否能成为Leader。 温馨提示:在讲解关键点之前,我们先定义先将(当前时间戳 + 上次投票的开销 + 最小投票间隔(300ms) + (1000- 300 )之间的随机值)定义为“ 1个常规计时器”。 其关键点如下: 如果对端的投票轮次大于发起投票的节点,则该节点使用对端的轮次,重新进入到Candidate状态,并且重置投票计时器,其值为“1个常规计时器” 如果已经存在Leader,该节点重新进入到Candidate,并重置定时器,该定时器的时间: “1个常规计时器” + heartBeatTimeIntervalMs * maxHeartBeatLeak ,其中 heartBeatTimeIntervalMs 为一次心跳间隔时间, maxHeartBeatLeak 为 允许最大丢失的心跳包,即如果Flower节点在多少个心跳周期内未收到心跳包,则认为Leader已下线。 如果收到的有效票数未超过半数,则重置计时器为“ 1个常规计时器”,然后等待重新投票,注意状态为WAIT_TO_REVOTE,该状态下的特征是下次投票时不增加投票轮次。 如果得到的赞同票超过半数,则成为Leader。 如果得到的赞成票加上未准备投票的节点数超过半数,则应该立即发起投票,故其结果为REVOTE_IMMEDIATELY。 如果得到的赞成票加上对端维护的ledgerEndIndex超过半数,则重置计时器,继续本轮次的选举。 其他情况,开启下一轮投票。 if (parseResult == VoteResponse.ParseResult.PASSED) { logger.info("[{}] [VOTE_RESULT] has been elected to be the leader in term {}", memberState.getSelfId(), term); changeRoleToLeader(term); } Step10:如果投票成功,则状态机状态设置为Leader,然后状态管理在驱动状态时会调用DLedgerLeaderElector#maintainState时,将进入到maintainAsLeader方法。 2.3.2 maintainAsLeader 方法 经过maintainAsCandidate 投票选举后,被其他节点选举成为领导后,会执行该方法,其他节点的状态还是Candidate,并在计时器过期后,又尝试去发起选举。接下来重点分析成为Leader节点后,该节点会做些什么? DLedgerLeaderElector#maintainAsLeader private void maintainAsLeader() throws Exception { if (DLedgerUtils.elapsed(lastSendHeartBeatTime) > heartBeatTimeIntervalMs) { // @1 long term; String leaderId; synchronized (memberState) { if (!memberState.isLeader()) { // @2 //stop sending return; } term = memberState.currTerm(); leaderId = memberState.getLeaderId(); lastSendHeartBeatTime = System.currentTimeMillis(); // @3 } sendHeartbeats(term, leaderId); // @4 } } 代码@1:首先判断上一次发送心跳的时间与当前时间的差值是否大于心跳包发送间隔,如果超过,则说明需要发送心跳包。 代码@2:如果当前不是leader节点,则直接返回,主要是为了二次判断。 代码@3:重置心跳包发送计时器。 代码@4:向集群内的所有节点发送心跳包,稍后会详细介绍心跳包的发送。 2.3.3 maintainAsFollower方法 当 Candidate 状态的节点在收到主节点发送的心跳包后,会将状态变更为follower,那我们先来看一下在follower状态下,节点会做些什么事情? private void maintainAsFollower() { if (DLedgerUtils.elapsed(lastLeaderHeartBeatTime) > 2 * heartBeatTimeIntervalMs) { synchronized (memberState) { if (memberState.isFollower() && (DLedgerUtils.elapsed(lastLeaderHeartBeatTime) > maxHeartBeatLeak * heartBeatTimeIntervalMs)) { logger.info("[{}][HeartBeatTimeOut] lastLeaderHeartBeatTime: {} heartBeatTimeIntervalMs: {} lastLeader={}", memberState.getSelfId(), new Timestamp(lastLeaderHeartBeatTime), heartBeatTimeIntervalMs, memberState.getLeaderId()); changeRoleToCandidate(memberState.currTerm()); } } } } 如果maxHeartBeatLeak (默认为3)个心跳包周期内未收到心跳,则将状态变更为Candidate。 状态机的驱动就介绍到这里,在上面的流程中,其实我们忽略了两个重要的过程,一个是发起投票请求与投票请求响应、发送心跳包与心跳包响应,那我们接下来将重点介绍这两个过程。 2.4 投票与投票请求 节点的状态为 Candidate 时会向集群内的其他节点发起投票请求(个人觉得理解为拉票更好),向对方询问是否愿意选举我为Leader,对端节点会根据自己的情况对其投赞成票、拒绝票,如果是拒绝票,还会给出拒绝原因,具体由voteForQuorumResponses、handleVote 这两个方法来实现,接下来我们分别对这两个方法进行详细分析。 2.4.1 voteForQuorumResponses 发起投票请求。 private List<CompletableFuture<VoteResponse>> voteForQuorumResponses(long term, long ledgerEndTerm, long ledgerEndIndex) throws Exception { // @1 List<CompletableFuture<VoteResponse>> responses = new ArrayList<>(); for (String id : memberState.getPeerMap().keySet()) { // @2 VoteRequest voteRequest = new VoteRequest(); // @3 start voteRequest.setGroup(memberState.getGroup()); voteRequest.setLedgerEndIndex(ledgerEndIndex); voteRequest.setLedgerEndTerm(ledgerEndTerm); voteRequest.setLeaderId(memberState.getSelfId()); voteRequest.setTerm(term); voteRequest.setRemoteId(id); CompletableFuture<VoteResponse> voteResponse; // @3 end if (memberState.getSelfId().equals(id)) { // @4 voteResponse = handleVote(voteRequest, true); } else { //async voteResponse = dLedgerRpcService.vote(voteRequest); // @5 } responses.add(voteResponse); } return responses; } 代码@1:首先先解释一下参数的含义: long term发起投票的节点当前的投票轮次。 long ledgerEndTerm发起投票节点维护的已知的最大投票轮次。 long ledgerEndIndex发起投票节点维护的已知的最大日志条目索引。 代码@2:遍历集群内的节点集合,准备异步发起投票请求。这个集合在启动的时候指定,不能修改。 代码@3:构建投票请求。 代码@4:如果是发送给自己的,则直接调用handleVote进行投票请求响应,如果是发送给集群内的其他节点,则通过网络发送投票请求,对端节点调用各自的handleVote对集群进行响应。 接下来重点关注 handleVote 方法,重点探讨其投票处理逻辑。 2.4.2 handleVote 方法 由于handleVote 方法会并发被调用,因为可能同时收到多个节点的投票请求,故本方法都被synchronized方法包含,锁定的对象为状态机 memberState 对象。 if (!memberState.isPeerMember(request.getLeaderId())) { logger.warn("[BUG] [HandleVote] remoteId={} is an unknown member", request.getLeaderId()); return CompletableFuture.completedFuture(newVoteResponse(request).term(memberState.currTerm()).voteResult(VoteResponse.RESULT.REJECT_UNKNOWN_LEADER)); } if (!self && memberState.getSelfId().equals(request.getLeaderId())) { logger.warn("[BUG] [HandleVote] selfId={} but remoteId={}", memberState.getSelfId(), request.getLeaderId()); return CompletableFuture.completedFuture(new VoteResponse(request).term(memberState.currTerm()).voteResult(VoteResponse.RESULT.REJECT_UNEXPECTED_LEADER)); } Step1:为了逻辑的完整性对其请求进行检验,除非有BUG存在,否则是不会出现上述问题的。 if (request.getTerm() < memberState.currTerm()) { // @1 return CompletableFuture.completedFuture(new VoteResponse(request).term(memberState.currTerm()).voteResult(VoteResponse.RESULT.REJECT_EXPIRED_VOTE_TERM)); } else if (request.getTerm() == memberState.currTerm()) { // @2 if (memberState.currVoteFor() == null) { //let it go } else if (memberState.currVoteFor().equals(request.getLeaderId())) { //repeat just let it go } else { if (memberState.getLeaderId() != null) { return CompletableFuture.completedFuture(new VoteResponse(request).term(memberState.currTerm()).voteResult(VoteResponse.RESULT.REJECT_ALREADY__HAS_LEADER)); } else { return CompletableFuture.completedFuture(new VoteResponse(request).term(memberState.currTerm()).voteResult(VoteResponse.RESULT.REJECT_ALREADY_VOTED)); } } } else { // @3 //stepped down by larger term changeRoleToCandidate(request.getTerm()); needIncreaseTermImmediately = true; //only can handleVote when the term is consistent return CompletableFuture.completedFuture(new VoteResponse(request).term(memberState.currTerm()).voteResult(VoteResponse.RESULT.REJECT_TERM_NOT_READY)); } Step2:判断发起节点、响应节点维护的team进行投票“仲裁”,分如下3种情况讨论: 如果发起投票节点的 term 小于当前节点的 term此种情况下投拒绝票,也就是说在 raft 协议的世界中,谁的 term 越大,越有话语权。 如果发起投票节点的 term 等于当前节点的 term 如果两者的 term 相等,说明两者都处在同一个投票轮次中,地位平等,接下来看该节点是否已经投过票。 如果未投票、或已投票给请求节点,则继续后面的逻辑(请看step3)。 如果该节点已存在的Leader节点,则拒绝并告知已存在Leader节点。 如果该节点还未有Leader节点,但已经投了其他节点的票,则拒绝请求节点,并告知已投票。 如果发起投票节点的 term 大于当前节点的 term拒绝请求节点的投票请求,并告知自身还未准备投票,自身会使用请求节点的投票轮次立即进入到Candidate状态。 if (request.getLedgerEndTerm() < memberState.getLedgerEndTerm()) { return CompletableFuture.completedFuture(new VoteResponse(request).term(memberState.currTerm()).voteResult(VoteResponse.RESULT.REJECT_EXPIRED_LEDGER_TERM)); } else if (request.getLedgerEndTerm() == memberState.getLedgerEndTerm() && request.getLedgerEndIndex() < memberState.getLedgerEndIndex()) { return CompletableFuture.completedFuture(new VoteResponse(request).term(memberState.currTerm()).voteResult(VoteResponse.RESULT.REJECT_SMALL_LEDGER_END_INDEX)); } if (request.getTerm() < memberState.getLedgerEndTerm()) { return CompletableFuture.completedFuture(new VoteResponse(request).term(memberState.getLedgerEndTerm()).voteResult(VoteResponse.RESULT.REJECT_TERM_SMALL_THAN_LEDGER)); } Step3:判断请求节点的 ledgerEndTerm 与当前节点的 ledgerEndTerm,这里主要是判断日志的复制进度。 如果请求节点的 ledgerEndTerm 小于当前节点的 ledgerEndTerm 则拒绝,其原因是请求节点的日志复制进度比当前节点低,这种情况是不能成为主节点的。 如果 ledgerEndTerm 相等,但是 ledgerEndIndex 比当前节点小,则拒绝,原因与上一条相同。 如果请求的 term 小于 ledgerEndTerm 以同样的理由拒绝。 memberState.setCurrVoteFor(request.getLeaderId()); return CompletableFuture.completedFuture(new VoteResponse(request).term(memberState.currTerm()).voteResult(VoteResponse.RESULT.ACCEPT)); Step4:经过层层条件帅选,将宝贵的赞成票投给请求节点。 经过几轮投票,最终一个节点能成功被推举出来,选为主节点。主节点为了维持其领导地位,需要定时向从节点发送心跳包,接下来我们重点看一下心跳包的发送与响应。 2.5 心跳包与心跳包响应 2.5.1 sendHeartbeats Step1:遍历集群中的节点,异步发送心跳包。 CompletableFuture<HeartBeatResponse> future = dLedgerRpcService.heartBeat(heartBeatRequest); future.whenComplete((HeartBeatResponse x, Throwable ex) -> { try { if (ex != null) { throw ex; } switch (DLedgerResponseCode.valueOf(x.getCode())) { case SUCCESS: succNum.incrementAndGet(); break; case EXPIRED_TERM: maxTerm.set(x.getTerm()); break; case INCONSISTENT_LEADER: inconsistLeader.compareAndSet(false, true); break; case TERM_NOT_READY: notReadyNum.incrementAndGet(); break; default: break; } if (memberState.isQuorum(succNum.get()) || memberState.isQuorum(succNum.get() + notReadyNum.get())) { beatLatch.countDown(); } } catch (Throwable t) { logger.error("Parse heartbeat response failed", t); } finally { allNum.incrementAndGet(); if (allNum.get() == memberState.peerSize()) { beatLatch.countDown(); } } }); } Step2:统计心跳包发送响应结果,关键点如下: SUCCESS心跳包成功响应。 EXPIRED_TERM主节点的投票 term 小于从节点的投票轮次。 INCONSISTENT_LEADER从节点已经有了新的主节点。 TERM_NOT_READY从节点未准备好。 这些响应值,我们在处理心跳包时重点探讨。 beatLatch.await(heartBeatTimeIntervalMs, TimeUnit.MILLISECONDS); if (memberState.isQuorum(succNum.get())) { // @1 lastSuccHeartBeatTime = System.currentTimeMillis(); } else { logger.info("[{}] Parse heartbeat responses in cost={} term={} allNum={} succNum={} notReadyNum={} inconsistLeader={} maxTerm={} peerSize={} lastSuccHeartBeatTime={}", memberState.getSelfId(), DLedgerUtils.elapsed(startHeartbeatTimeMs), term, allNum.get(), succNum.get(), notReadyNum.get(), inconsistLeader.get(), maxTerm.get(), memberState.peerSize(), new Timestamp(lastSuccHeartBeatTime)); if (memberState.isQuorum(succNum.get() + notReadyNum.get())) { // @2 lastSendHeartBeatTime = -1; } else if (maxTerm.get() > term) { // @3 changeRoleToCandidate(maxTerm.get()); } else if (inconsistLeader.get()) { // @4 changeRoleToCandidate(term); } else if (DLedgerUtils.elapsed(lastSuccHeartBeatTime) > maxHeartBeatLeak * heartBeatTimeIntervalMs) { changeRoleToCandidate(term); } } 对收集的响应结果做仲裁,其实现关键点: 如果成功的票数大于进群内的半数,则表示集群状态正常,正常按照心跳包间隔发送心跳包(见代码@1)。 如果成功的票数加上未准备的投票的节点数量超过集群内的半数,则立即发送心跳包(见代码@2)。 如果从节点的投票轮次比主节点的大,则使用从节点的投票轮次,或从节点已经有了另外的主节点,节点状态从 Leader 转换为 Candidate。 接下来我们重点看一下心跳包的处理逻辑。 2.5.2 handleHeartBeat if (request.getTerm() < memberState.currTerm()) { return CompletableFuture.completedFuture(new HeartBeatResponse().term(memberState.currTerm()).code(DLedgerResponseCode.EXPIRED_TERM.getCode())); } else if (request.getTerm() == memberState.currTerm()) { if (request.getLeaderId().equals(memberState.getLeaderId())) { lastLeaderHeartBeatTime = System.currentTimeMillis(); return CompletableFuture.completedFuture(new HeartBeatResponse()); } } Step1:如果主节点的 term 小于 从节点的term,发送反馈给主节点,告知主节点的 term 已过时;如果投票轮次相同,并且发送心跳包的节点是该节点的主节点,则返回成功。 下面重点讨论主节点的 term 大于从节点的情况。 synchronized (memberState) { if (request.getTerm() < memberState.currTerm()) { // @1 return CompletableFuture.completedFuture(new HeartBeatResponse().term(memberState.currTerm()).code(DLedgerResponseCode.EXPIRED_TERM.getCode())); } else if (request.getTerm() == memberState.currTerm()) { // @2 if (memberState.getLeaderId() == null) { changeRoleToFollower(request.getTerm(), request.getLeaderId()); return CompletableFuture.completedFuture(new HeartBeatResponse()); } else if (request.getLeaderId().equals(memberState.getLeaderId())) { lastLeaderHeartBeatTime = System.currentTimeMillis(); return CompletableFuture.completedFuture(new HeartBeatResponse()); } else { //this should not happen, but if happened logger.error("[{}][BUG] currTerm {} has leader {}, but received leader {}", memberState.getSelfId(), memberState.currTerm(), memberState.getLeaderId(), request.getLeaderId()); return CompletableFuture.completedFuture(new HeartBeatResponse().code(DLedgerResponseCode.INCONSISTENT_LEADER.getCode())); } } else { //To make it simple, for larger term, do not change to follower immediately //first change to candidate, and notify the state-maintainer thread changeRoleToCandidate(request.getTerm()); needIncreaseTermImmediately = true; //TOOD notify return CompletableFuture.completedFuture(new HeartBeatResponse().code(DLedgerResponseCode.TERM_NOT_READY.getCode())); } } Step2:加锁来处理(这里更多的是从节点第一次收到主节点的心跳包) 代码@1:如果主节的投票轮次小于当前投票轮次,则返回主节点投票轮次过期。 代码@2:如果投票轮次相同。 如果当前节点的主节点字段为空,则使用主节点的ID,并返回成功。 如果当前节点的主节点就是发送心跳包的节点,则更新上一次收到心跳包的时间戳,并返回成功。 如果从节点的主节点与发送心跳包的节点ID不同,说明有另外一个Leaer,按道理来说是不会发送的,如果发生,则返回已存在- 主节点,标记该心跳包处理结束。 代码@3:如果主节点的投票轮次大于从节点的投票轮次,则认为从节点并为准备好,则从节点进入Candidate 状态,并立即发起一次投票。 心跳包的处理就介绍到这里。 RocketMQ 多副本之 Leader 选举的源码分析就介绍到这里了,为了加强对源码的理解,先梳理流程图如下: 原文发布时间为:2019-08-18本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
1、抛出问题 一个新的消费组订阅一个已存在的Topic主题时,消费组是从该Topic的哪条消息开始消费呢? 首先翻阅DefaultMQPushConsumer的API时,setConsumeFromWhere(ConsumeFromWhere consumeFromWhere)API映入眼帘,从字面意思来看是设置消费者从哪里开始消费,正是解开该问题的”钥匙“。ConsumeFromWhere枚举类图如下: CONSUME_FROM_MAX_OFFSET从消费队列最大的偏移量开始消费。 CONSUME_FROM_FIRST_OFFSET从消费队列最小偏移量开始消费。 CONSUME_FROM_TIMESTAMP从指定的时间戳开始消费,默认为消费者启动之前的30分钟处开始消费。可以通过DefaultMQPushConsumer#setConsumeTimestamp。 是不是点小激动,还不快试试。 需求:新的消费组启动时,从队列最后开始消费,即只消费启动后发送到消息服务器后的最新消息。 1.1 环境准备 本示例所用到的Topic路由信息如下: Broker的配置如下(broker.conf) brokerClusterName = DefaultCluster brokerName = broker-a brokerId = 0 deleteWhen = 04 fileReservedTime = 48 brokerRole = ASYNC_MASTER flushDiskType = ASYNC_FLUSH storePathRootDir=E:/SH2019/tmp/rocketmq_home/rocketmq4.5_simple/store storePathCommitLog=E:/SH2019/tmp/rocketmq_home/rocketmq4.5_simple/store/commitlog namesrvAddr=127.0.0.1:9876 autoCreateTopicEnable=false mapedFileSizeCommitLog=10240 mapedFileSizeConsumeQueue=2000 其中重点修改了如下两个参数: mapedFileSizeCommitLog单个commitlog文件的大小,这里使用10M,方便测试用。 mapedFileSizeConsumeQueue单个consumequeue队列长度,这里使用1000,表示一个consumequeue文件中包含1000个条目。 1.2 消息发送者代码 public static void main(String[] args) throws MQClientException, InterruptedException { DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name"); producer.setNamesrvAddr("127.0.0.1:9876"); producer.start(); for (int i = 0; i < 300; i++) { try { Message msg = new Message("TopicTest" ,"TagA" , ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)); SendResult sendResult = producer.send(msg); System.out.printf("%s%n", sendResult); } catch (Exception e) { e.printStackTrace(); Thread.sleep(1000); } } producer.shutdown(); } 通过上述,往TopicTest发送300条消息,发送完毕后,RocketMQ Broker存储结构如下: 1.3 消费端验证代码 public static void main(String[] args) throws InterruptedException, MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("my_consumer_01"); consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET); consumer.subscribe("TopicTest", "*"); consumer.setNamesrvAddr("127.0.0.1:9876"); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) { System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs); return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); consumer.start(); System.out.printf("Consumer Started.%n"); } 执行上述代码后,按照期望,应该是不会消费任何消息,只有等生产者再发送消息后,才会对消息进行消费,事实是这样吗?执行效果如图所示:令人意外的是,竟然从队列的最小偏移量开始消费了,这就“尴尬”了。难不成是RocketMQ的Bug。带着这个疑问,从源码的角度尝试来解读该问题,并指导我们实践。 2、探究CONSUME_FROM_MAX_OFFSET实现原理 对于一个新的消费组,无论是集群模式还是广播模式都不会存储该消费组的消费进度,可以理解为-1,此时就需要根据DefaultMQPushConsumer#consumeFromWhere属性来决定其从何处开始消费,首先我们需要找到其对应的处理入口。我们知道,消息消费者从Broker服务器拉取消息时,需要进行消费队列的负载,即RebalanceImpl。 温馨提示:本文不会详细介绍RocketMQ消息队列负载、消息拉取、消息消费逻辑,只会展示出通往该问题的简短流程,如想详细了解消息消费具体细节,建议购买笔者出版的《RocketMQ技术内幕》书籍。 RebalancePushImpl#computePullFromWhere public long computePullFromWhere(MessageQueue mq) { long result = -1; // @1 final ConsumeFromWhere consumeFromWhere = this.defaultMQPushConsumerImpl.getDefaultMQPushConsumer().getConsumeFromWhere(); final OffsetStore offsetStore = this.defaultMQPushConsumerImpl.getOffsetStore(); switch (consumeFromWhere) { case CONSUME_FROM_LAST_OFFSET_AND_FROM_MIN_WHEN_BOOT_FIRST: case CONSUME_FROM_MIN_OFFSET: case CONSUME_FROM_MAX_OFFSET: case CONSUME_FROM_LAST_OFFSET: { // @2 // 省略部分代码 break; } case CONSUME_FROM_FIRST_OFFSET: { // @3 // 省略部分代码 break; } case CONSUME_FROM_TIMESTAMP: { //@4 // 省略部分代码 break; } default: break; } return result; // @5 } 代码@1:先解释几个局部变量。 result 最终的返回结果,默认为-1。 consumeFromWhere 消息消费者开始消费的策略,即CONSUME_FROM_LAST_OFFSET等。 offsetStore offset存储器,消费组消息偏移量存储实现器。 代码@2:CONSUME_FROM_LAST_OFFSET(从队列的最大偏移量开始消费)的处理逻辑,下文会详细介绍。 代码@3:CONSUME_FROM_FIRST_OFFSET(从队列最小偏移量开始消费)的处理逻辑,下文会详细介绍。 代码@4:CONSUME_FROM_TIMESTAMP(从指定时间戳开始消费)的处理逻辑,下文会详细介绍。 代码@5:返回最后计算的偏移量,从该偏移量出开始消费。 2.1 CONSUME_FROM_LAST_OFFSET计算逻辑 case CONSUME_FROM_LAST_OFFSET: { long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE); // @1 if (lastOffset >= 0) { // @2 result = lastOffset; } // First start,no offset else if (-1 == lastOffset) { // @3 if (mq.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) { result = 0L; } else { try { result = this.mQClientFactory.getMQAdminImpl().maxOffset(mq); } catch (MQClientException e) { // @4 result = -1; } } } else { result = -1; } break; } 代码@1:使用offsetStore从消息消费进度文件中读取消费消费进度,本文将以集群模式为例展开。稍后详细分析。 代码@2:如果返回的偏移量大于等于0,则直接使用该offset,这个也能理解,大于等于0,表示查询到有效的消息消费进度,从该有效进度开始消费,但我们要特别留意lastOffset为0是什么场景,因为返回0,并不会执行CONSUME_FROM_LAST_OFFSET(语义)。 代码@3:如果lastOffset为-1,表示当前并未存储其有效偏移量,可以理解为第一次消费,如果是消费组重试主题,从重试队列偏移量为0开始消费;如果是普通主题,则从队列当前的最大的有效偏移量开始消费,即CONSUME_FROM_LAST_OFFSET语义的实现。 代码@4:如果从远程服务拉取最大偏移量拉取异常或其他情况,则使用-1作为第一次拉取偏移量。 分析,上述执行的现象,虽然设置的是CONSUME_FROM_LAST_OFFSET,但现象是从队列的第一条消息开始消费,根据上述源码的分析,只有从消费组消费进度存储文件中取到的消息偏移量为0时,才会从第一条消息开始消费,故接下来重点分析消息消费进度存储器(OffsetStore)在什么情况下会返回0。 接下来我们将以集群模式来查看一下消息消费进度的查询逻辑,集群模式的消息进度存储管理器实现为:RemoteBrokerOffsetStore,最终Broker端的命令处理类为:ConsumerManageProcessor。 ConsumerManageProcessor#queryConsumerOffset private RemotingCommand queryConsumerOffset(ChannelHandlerContext ctx, RemotingCommand request) throws RemotingCommandException { final RemotingCommand response = RemotingCommand.createResponseCommand(QueryConsumerOffsetResponseHeader.class); final QueryConsumerOffsetResponseHeader responseHeader = (QueryConsumerOffsetResponseHeader) response.readCustomHeader(); final QueryConsumerOffsetRequestHeader requestHeader = (QueryConsumerOffsetRequestHeader) request .decodeCommandCustomHeader(QueryConsumerOffsetRequestHeader.class); long offset = this.brokerController.getConsumerOffsetManager().queryOffset( requestHeader.getConsumerGroup(), requestHeader.getTopic(), requestHeader.getQueueId()); // @1 if (offset >= 0) { // @2 responseHeader.setOffset(offset); response.setCode(ResponseCode.SUCCESS); response.setRemark(null); } else { // @3 long minOffset = this.brokerController.getMessageStore().getMinOffsetInQueue(requestHeader.getTopic(), requestHeader.getQueueId()); // @4 if (minOffset <= 0 && !this.brokerController.getMessageStore().checkInDiskByConsumeOffset( // @5 requestHeader.getTopic(), requestHeader.getQueueId(), 0)) { responseHeader.setOffset(0L); response.setCode(ResponseCode.SUCCESS); response.setRemark(null); } else { // @6 response.setCode(ResponseCode.QUERY_NOT_FOUND); response.setRemark("Not found, V3_0_6_SNAPSHOT maybe this group consumer boot first"); } } return response; } 代码@1:从消费消息进度文件中查询消息消费进度。 代码@2:如果消息消费进度文件中存储该队列的消息进度,其返回的offset必然会大于等于0,则直接返回该偏移量该客户端,客户端从该偏移量开始消费。 代码@3:如果未从消息消费进度文件中查询到其进度,offset为-1。则首先获取该主题、消息队列当前在Broker服务器中的最小偏移量(@4)。如果小于等于0(返回0则表示该队列的文件还未曾删除过)并且其最小偏移量对应的消息存储在内存中而不是存在磁盘中,则返回偏移量0,这就意味着ConsumeFromWhere中定义的三种枚举类型都不会生效,直接从0开始消费,到这里就能解开其谜团了(@5)。 代码@6:如果偏移量小于等于0,但其消息已经存储在磁盘中,此时返回未找到,最终RebalancePushImpl#computePullFromWhere中得到的偏移量为-1。 看到这里,大家应该能回答文章开头处提到的问题了吧? 看到这里,大家应该明白了,为什么设置的CONSUME_FROM_LAST_OFFSET,但消费组是从消息队列的开始处消费了吧,原因就是消息消费进度文件中并没有找到其消息消费进度,并且该队列在Broker端的最小偏移量为0,说的更直白点,consumequeue/topicName/queueNum的第一个消息消费队列文件为00000000000000000000,并且消息其对应的消息缓存在Broker端的内存中(pageCache),其返回给消费端的偏移量为0,故会从0开始消费,而不是从队列的最大偏移量处开始消费。 为了知识体系的完备性,我们顺便来看一下其他两种策略的计算逻辑。 2.2 CONSUME_FROM_FIRST_OFFSET case CONSUME_FROM_FIRST_OFFSET: { long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE); // @1 if (lastOffset >= 0) { // @2 result = lastOffset; } else if (-1 == lastOffset) { // @3 result = 0L; } else { result = -1; // @4 } break; } 从队列的开始偏移量开始消费,其计算逻辑如下:代码@1:首先通过偏移量存储器查询消费队列的消费进度。 代码@2:如果大于等于0,则从当前该偏移量开始消费。 代码@3:如果远程返回-1,表示并没有存储该队列的消息消费进度,从0开始。 代码@4:否则从-1开始消费。 2.4 CONSUME_FROM_TIMESTAMP 从指定时戳后的消息开始消费。 case CONSUME_FROM_TIMESTAMP: { ong lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE); // @1 if (lastOffset >= 0) { // @2 result = lastOffset; } else if (-1 == lastOffset) { // @3 if (mq.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) { try { result = this.mQClientFactory.getMQAdminImpl().maxOffset(mq); } catch (MQClientException e) { result = -1; } } else { try { long timestamp = UtilAll.parseDate(this.defaultMQPushConsumerImpl.getDefaultMQPushConsumer().getConsumeTimestamp(), UtilAll.YYYYMMDDHHMMSS).getTime(); result = this.mQClientFactory.getMQAdminImpl().searchOffset(mq, timestamp); } catch (MQClientException e) { result = -1; } } } else { result = -1; } break; } 其基本套路与CONSUME_FROM_LAST_OFFSET一样:代码@1:首先通过偏移量存储器查询消费队列的消费进度。 代码@2:如果大于等于0,则从当前该偏移量开始消费。 代码@3:如果远程返回-1,表示并没有存储该队列的消息消费进度,如果是重试主题,则从当前队列的最大偏移量开始消费,如果是普通主题,则根据时间戳去Broker端查询,根据查询到的偏移量开始消费。 原理就介绍到这里,下面根据上述理论对其进行验证。 3、猜想与验证 根据上述理论分析我们得知设置CONSUME_FROM_LAST_OFFSET但并不是从消息队列的最大偏移量开始消费的“罪魁祸首”是因为消息消费队列的最小偏移量为0,如果不为0,则就会符合预期,我们来验证一下这个猜想。首先我们删除commitlog目录下的文件,如图所示:其消费队列截图如下:消费端的验证代码如下: public static void main(String[] args) throws InterruptedException, MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("my_consumer_02"); consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET); consumer.subscribe("TopicTest", "*"); consumer.setNamesrvAddr("127.0.0.1:9876"); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) { System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs); return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); consumer.start(); System.out.printf("Consumer Started.%n"); } 运行结果如下:并没有消息存在的消息,符合预期。 4、解决方案 如果在生产环境下,一个新的消费组订阅一个已经存在比较久的topic,设置CONSUME_FROM_MAX_OFFSET是符合预期的,即该主题的consumequeue/{queueNum}/fileName,fileName通常不会是00000000000000000000,如是是上面文件名,想要实现从队列的最后开始消费,该如何做呢?那就走自动创建消费组的路子,执行如下命令: ./mqadmin updateSubGroup -n 127.0.0.1:9876 -c DefaultCluster -g my_consumer_05 //克隆一个订阅了该topic的消费组消费进度 ./mqadmin cloneGroupOffset -n 127.0.0.1:9876 -s my_consumer_01 -d my_consumer_05 -t TopicTest //重置消费进度到当前队列的最大值 ./mqadmin resetOffsetByTime -n 127.0.0.1:9876 -g my_consumer_05 -t TopicTest -s -1 按照上上述命令后,即可实现其目的。 原文发布时间为:2019-07-21本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
RocketMQ 消息轨迹主要包含两篇文章:设计篇与源码分析篇,本节将详细介绍RocketMQ消息轨迹-设计相关。 RocketMQ消息轨迹,主要跟踪消息发送、消息消费的轨迹,即详细记录消息各个处理环节的日志,从设计上至少需要解决如下三个核心问题: 消费轨迹数据格式 记录消息轨迹(消息日志) 消息轨迹数据存储在哪? 1、消息轨迹数据格式 RocketMQ4.5版本消息轨迹主要记录如下信息: traceType跟踪类型,可选值:Pub(消息发送)、SubBefore(消息拉取到客户端,执行业务定义的消费逻辑之前)、SubAfter(消费后)。 timeStamp当前时间戳。 regionIdbroker所在的区域ID,取自BrokerConfig#regionId。 groupName组名称,traceType为Pub时为生产者组的名称;如果traceType为subBefore或subAfter时为消费组名称。 requestIdtraceType为subBefore、subAfter时使用,消费端的请求Id。 topic消息主题。 msgId消息唯一ID。 tags消息tag。 keys消息索引key,根据该key可快速检索消息。 storeHost跟踪类型为PUB时为存储该消息的Broker服务器IP;跟踪类型为subBefore、subAfter时为消费者IP。 bodyLength消息体的长度。 costTime耗时。 msgType消息的类型,可选值:Normal_Msg(普通消息),Trans_Msg_Half(预提交消息),Trans_msg_Commit(提交消息),Delay_Msg(延迟消息)。 offsetMsgId消息偏移量ID,该ID中包含了broker的ip以及偏移量。 success是发送成功。 contextCode消费状态码,可选值:SUCCESS,TIME_OUT,EXCEPTION,RETURNNULL,FAILED。 2、记录消息轨迹 消息中间件的两大核心主题:消息发送、消息消费,其核心载体就是消息,消息轨迹(消息的流转)主要是记录消息是何时发送到哪台Broker,发送耗时多少时间,在什么是被哪个消费者消费。记录消息的轨迹主要是集中在消息发送前后、消息消费前后,可以通过RokcetMQ的Hook机制。通过如下两个接口来定义钩子函数。通过实行上述两个接口,可以实现在消息发送、消息消费前后记录消息轨迹,为了不明显增加消息发送与消息消费的时延,记录消息轨迹最好使用异步发送模式。 3、如何存储消息轨迹数据 消息轨迹需要存储什么消息以及在什么时候记录消息轨迹的问题都以及解决,那接下来就得思考将消息轨迹存储在哪里?存储在数据库中或其他媒介中,都会加重消息中间件,使其依赖外部组件,最佳的选择还是存储在Broker服务器中,将消息轨迹数据也当成一条消息存储到Broker服务器。 既然把消息轨迹当成消息存储在Broker服务器,那存储消息轨迹的Topic如何确定呢?RocketMQ提供了两种方法来定义消息轨迹的Topic。 系统默认Topic如果Broker的traceTopicEnable配置设置为true,表示在该Broker上创建topic名为:RMQ_SYS_TRACE_TOPIC,队列个数为1,默认该值为false,表示该Broker不承载系统自定义用于存储消息轨迹的topic。 自定义Topic在创建消息生产者或消息消费者时,可以通过参数自定义用于记录消息轨迹的Topic名称,不过要注意的是,rokcetmq控制台(rocketmq-console)中只支持配置一个消息轨迹Topic,故自定义Topic,在目前这个阶段或许还不是一个最佳实践,建议使用系统默认的Topic即可。 通常为了避免消息轨迹的数据与正常的业务数据混合在一起,官方建议,在Broker集群中,新增加一台机器,只在这台机器上开启消息轨迹跟踪,这样该集群内的消息轨迹数据只会发送到这一台Broker服务器上,并不会增加集群内原先业务Broker的负载压力。 原文发布时间为:2019-07-14本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
有关RocketMQ ACL的使用请查看上一篇《RocketMQ ACL使用指南》,本文从源码的角度,分析一下RocketMQ ACL的实现原理。 备注:RocketMQ在4.4.0时引入了ACL机制,本文代码基于RocketMQ4.5.0版本。 根据RocketMQ ACL使用手册,我们应该首先看一下Broker服务器在开启ACL机制时如何加载配置文件,并如何工作的。 1、BrokerController#initialAcl Broker端ACL的入口代码为:BrokerController#initialAcl private void initialAcl() { if (!this.brokerConfig.isAclEnable()) { // @1 log.info("The broker dose not enable acl"); return; } List<AccessValidator> accessValidators = ServiceProvider.load(ServiceProvider.ACL_VALIDATOR_ID, AccessValidator.class); // @2 if (accessValidators == null || accessValidators.isEmpty()) { log.info("The broker dose not load the AccessValidator"); return; } for (AccessValidator accessValidator: accessValidators) { // @3 final AccessValidator validator = accessValidator; this.registerServerRPCHook(new RPCHook() { @Override public void doBeforeRequest(String remoteAddr, RemotingCommand request) { //Do not catch the exception validator.validate(validator.parse(request, remoteAddr)); // @4 } @Override public void doAfterResponse(String remoteAddr, RemotingCommand request, RemotingCommand response) { } }); } } 本方法的实现共4个关键点。 代码@1:首先判断Broker是否开启了acl,通过配置参数aclEnable指定,默认为false。 代码@2:使用类似SPI机制,加载配置的AccessValidator,该方法返回一个列表,其实现逻辑时读取META-INF/service/org.apache.rocketmq.acl.AccessValidator文件中配置的访问验证器,默认配置内容如下: 代码@3:遍历配置的访问验证器(AccessValidator),并向Broker处理服务器注册钩子函数,RPCHook的doBeforeRequest方法会在服务端接收到请求,将其请求解码后,执行处理请求之前被调用;RPCHook的doAfterResponse方法会在处理完请求后,将结果返回之前被调用,其调用如图所示: 代码@4:在RPCHook#doBeforeRequest方法中调用AccessValidator#validate, 在真实处理命令之前,先执行ACL的验证逻辑,如果拥有该操作的执行权限,则放行,否则抛出AclException。 接下来,我们将重点放到Broker默认实现的访问验证器:PlainAccessValidator。 2、PlainAccessValidator 2.1 类图 AccessValidator访问验证器接口,主要定义两个接口。 1)AccessResource parse(RemotingCommand request, String remoteAddr)从请求头中解析本次请求对应的访问资源,即本次请求需要的访问权限。2)void validate(AccessResource accessResource)根据本次需要访问的权限,与请求用户拥有的权限进行对比验证,判断是拥有权限,如果没有访问该操作的权限,则抛出异常,否则放行。 PlainAccessValidatorRocketMQ默认提供的基于yml配置格式的访问验证器。 接下来我们重点看一下PlainAccessValidator的parse方法与validate方法的实现细节。在讲解该方法之前,我们首先认识一下RocketMQ封装访问资源的PlainAccessResource。 2.1.2 PlainAccessResource类图 我们对其属性一一做个介绍: private String accessKey访问Key,用户名。 private String secretKey用户密码。 private String whiteRemoteAddress远程IP地址白名单。 private boolean admin是否是管理员角色。 private byte defaultTopicPerm = 1默认topic访问权限,即如果没有配置topic的权限,则Topic默认的访问权限为1,表示为DENY。 private byte defaultGroupPerm = 1默认的消费组访问权限,默认为DENY。 private Map resourcePermMap资源需要的访问权限映射表。 private RemoteAddressStrategy remoteAddressStrategy远程IP地址验证策略。 private int requestCode当前请求的requestCode。 private byte[] content请求头与请求体的内容。 private String signature签名字符串,这是通常的套路,在客户端时,首先将请求参数排序,然后使用secretKey生成签名字符串,服务端重复这个步骤,然后对比签名字符串,如果相同,则认为登录成功,否则失败。 private String secretToken密钥token。 private String recognition目前作用未知,代码中目前未被使用。 2.2 构造方法 public PlainAccessValidator() { aclPlugEngine = new PlainPermissionLoader(); } 构造函数,直接创建PlainPermissionLoader对象,从命名上来看,应该是触发acl规则的加载,即解析plain_acl.yml,接下来会重点探讨,即acl启动流程之配置文件的解析。 2.3 parse方法 该方法的作用就是从请求命令中解析出本次访问所需要的访问权限,最终构建AccessResource对象,为后续的校验权限做准备。 PlainAccessResource accessResource = new PlainAccessResource(); if (remoteAddr != null && remoteAddr.contains(":")) { accessResource.setWhiteRemoteAddress(remoteAddr.split(":")[0]); } else { accessResource.setWhiteRemoteAddress(remoteAddr); } Step1:首先创建PlainAccessResource,从远程地址中提取出远程访问IP地址。 if (request.getExtFields() == null) { throw new AclException("request's extFields value is null"); } accessResource.setRequestCode(request.getCode()); accessResource.setAccessKey(request.getExtFields().get(SessionCredentials.ACCESS_KEY)); accessResource.setSignature(request.getExtFields().get(SessionCredentials.SIGNATURE)); accessResource.setSecretToken(request.getExtFields().get(SessionCredentials.SECURITY_TOKEN)); Step2:如果请求头中的扩展字段为空,则抛出异常,如果不为空,则从请求头中读取requestCode、accessKey(请求用户名)、签名字符串(signature)、secretToken。 try { switch (request.getCode()) { case RequestCode.SEND_MESSAGE: accessResource.addResourceAndPerm(request.getExtFields().get("topic"), Permission.PUB); break; case RequestCode.SEND_MESSAGE_V2: accessResource.addResourceAndPerm(request.getExtFields().get("b"), Permission.PUB); break; case RequestCode.CONSUMER_SEND_MSG_BACK: accessResource.addResourceAndPerm(request.getExtFields().get("originTopic"), Permission.PUB); accessResource.addResourceAndPerm(getRetryTopic(request.getExtFields().get("group")), Permission.SUB); break; case RequestCode.PULL_MESSAGE: accessResource.addResourceAndPerm(request.getExtFields().get("topic"), Permission.SUB); accessResource.addResourceAndPerm(getRetryTopic(request.getExtFields().get("consumerGroup")), Permission.SUB); break; case RequestCode.QUERY_MESSAGE: accessResource.addResourceAndPerm(request.getExtFields().get("topic"), Permission.SUB); break; case RequestCode.HEART_BEAT: HeartbeatData heartbeatData = HeartbeatData.decode(request.getBody(), HeartbeatData.class); for (ConsumerData data : heartbeatData.getConsumerDataSet()) { accessResource.addResourceAndPerm(getRetryTopic(data.getGroupName()), Permission.SUB); for (SubscriptionData subscriptionData : data.getSubscriptionDataSet()) { accessResource.addResourceAndPerm(subscriptionData.getTopic(), Permission.SUB); } } break; case RequestCode.UNREGISTER_CLIENT: final UnregisterClientRequestHeader unregisterClientRequestHeader = (UnregisterClientRequestHeader) request .decodeCommandCustomHeader(UnregisterClientRequestHeader.class); accessResource.addResourceAndPerm(getRetryTopic(unregisterClientRequestHeader.getConsumerGroup()), Permission.SUB); break; case RequestCode.GET_CONSUMER_LIST_BY_GROUP: final GetConsumerListByGroupRequestHeader getConsumerListByGroupRequestHeader = (GetConsumerListByGroupRequestHeader) request .decodeCommandCustomHeader(GetConsumerListByGroupRequestHeader.class); accessResource.addResourceAndPerm(getRetryTopic(getConsumerListByGroupRequestHeader.getConsumerGroup()), Permission.SUB); break; case RequestCode.UPDATE_CONSUMER_OFFSET: final UpdateConsumerOffsetRequestHeader updateConsumerOffsetRequestHeader = (UpdateConsumerOffsetRequestHeader) request .decodeCommandCustomHeader(UpdateConsumerOffsetRequestHeader.class); accessResource.addResourceAndPerm(getRetryTopic(updateConsumerOffsetRequestHeader.getConsumerGroup()), Permission.SUB); accessResource.addResourceAndPerm(updateConsumerOffsetRequestHeader.getTopic(), Permission.SUB); break; default: break; } } catch (Throwable t) { throw new AclException(t.getMessage(), t); } Step3:根据请求命令,设置本次请求需要拥有的权限,上述代码比较简单,就是从请求中得出本次操作的Topic、消息组名称,为了方便区分topic与消费组,消费组使用消费者对应的重试主题,当成资源的Key,从这里也可以看出,当前版本需要进行ACL权限验证的请求命令如下: SEND_MESSAGE SEND_MESSAGE_V2 CONSUMER_SEND_MSG_BACK PULL_MESSAGE QUERY_MESSAGE HEART_BEAT UNREGISTER_CLIENT GET_CONSUMER_LIST_BY_GROUP UPDATE_CONSUMER_OFFSET // Content SortedMap<String, String> map = new TreeMap<String, String>(); for (Map.Entry<String, String> entry : request.getExtFields().entrySet()) { if (!SessionCredentials.SIGNATURE.equals(entry.getKey())) { map.put(entry.getKey(), entry.getValue()); } } accessResource.setContent(AclUtils.combineRequestContent(request, map)); return accessResource; Step4:对扩展字段进行排序,便于生成签名字符串,然后将扩展字段与请求体(body)写入content字段。完成从请求头中解析出本次请求需要验证的权限。 2.4 validate 方法 public void validate(AccessResource accessResource) { aclPlugEngine.validate((PlainAccessResource) accessResource); } 验证权限,即根据本次请求需要的权限与当前用户所拥有的权限进行对比,如果符合,则正常执行;否则抛出AclException。 为了揭开配置文件的解析与验证,我们将目光投入到PlainPermissionLoader。 3、PlainPermissionLoader 该类的主要职责:加载权限,即解析acl主要配置文件plain_acl.yml。 3.1 类图 下面对其核心属性与核心方法一一介绍: DEFAULT_PLAIN_ACL_FILE 默认acl配置文件名称,默认值为conf/plain_acl.yml。 String fileNameacl配置文件名称,默认为DEFAULT_PLAIN_ACL_FILE ,可以通过系统参数-Drocketmq.acl.plain.file=fileName指定。 Map plainAccessResourceMap解析出来的权限配置映射表,以用户名为键。 RemoteAddressStrategyFactory remoteAddressStrategyFactory远程IP解析策略工厂,用于解析白名单IP地址。 boolean isWatchStart是否开启了文件监听,即自动监听plain_acl.yml文件,一旦该文件改变,可在不重启服务器的情况下自动生效。 public PlainPermissionLoader()构造方法。 public void load()加载配置文件。 public void validate(PlainAccessResource plainAccessResource)验证是否有权限访问待访问资源。 3.2 PlainPermissionLoader构造方法 public PlainPermissionLoader() { load(); watch(); } 在构造方法中调用load与watch方法。 3.3 load Map<String, PlainAccessResource> plainAccessResourceMap = new HashMap<>(); List<RemoteAddressStrategy> globalWhiteRemoteAddressStrategy = new ArrayList<>(); String path = fileHome + File.separator + fileName; JSONObject plainAclConfData = AclUtils.getYamlDataObject(path,JSONObject.class); Step1:初始化plainAccessResourceMap(用户配置的访问资源,即权限容器)、globalWhiteRemoteAddressStrategy:全局IP白名单访问策略。配置文件,默认为${ROCKETMQ_HOME}/conf/plain_acl.yml。 JSONArray globalWhiteRemoteAddressesList = plainAclConfData.getJSONArray("globalWhiteRemoteAddresses"); if (globalWhiteRemoteAddressesList != null && !globalWhiteRemoteAddressesList.isEmpty()) { for (int i = 0; i < globalWhiteRemoteAddressesList.size(); i++) { globalWhiteRemoteAddressStrategy.add(remoteAddressStrategyFactory. getRemoteAddressStrategy(globalWhiteRemoteAddressesList.getString(i))); } } Step2:globalWhiteRemoteAddresses:全局白名单,类型为数组。根据配置的规则,使用remoteAddressStrategyFactory获取一个访问策略,下文会重点介绍其配置规则。 JSONArray accounts = plainAclConfData.getJSONArray("accounts"); if (accounts != null && !accounts.isEmpty()) { List<PlainAccessConfig> plainAccessConfigList = accounts.toJavaList(PlainAccessConfig.class); for (PlainAccessConfig plainAccessConfig : plainAccessConfigList) { PlainAccessResource plainAccessResource = buildPlainAccessResource(plainAccessConfig); plainAccessResourceMap.put(plainAccessResource.getAccessKey(),plainAccessResource); } } this.globalWhiteRemoteAddressStrategy = globalWhiteRemoteAddressStrategy; this.plainAccessResourceMap = plainAccessResourceMap; Step3:解析plain_acl.yml文件中的另外一个根元素accounts,用户定义的权限信息。从PlainAccessConfig的定义来看,accounts标签下支持如下标签: accessKey secretKey whiteRemoteAddress admin defaultTopicPerm defaultGroupPerm topicPerms groupPerms上述标签的说明,请参考::《RocketMQ ACL使用指南》 。具体的解析过程比较容易,就不再细说。 load方法主要完成acl配置文件的解析,将用户定义的权限加载到内存中。 3.4 watch private void watch() { try { String watchFilePath = fileHome + fileName; FileWatchService fileWatchService = new FileWatchService(new String[] {watchFilePath}, new FileWatchService.Listener() { @Override public void onChanged(String path) { log.info("The plain acl yml changed, reload the context"); load(); } }); fileWatchService.start(); log.info("Succeed to start AclWatcherService"); this.isWatchStart = true; } catch (Exception e) { log.error("Failed to start AclWatcherService", e); } } 监听器,默认以500ms的频率判断文件的内容是否变化。在文件内容发生变化后调用load()方法,重新加载配置文件。那FileWatchService是如何判断两个文件的内容发生了变化呢? FileWatchService#hash private String hash(String filePath) throws IOException, NoSuchAlgorithmException { Path path = Paths.get(filePath); md.update(Files.readAllBytes(path)); byte[] hash = md.digest(); return UtilAll.bytes2string(hash); } 获取文件md5签名来做对比,这里为什么不在启动时先记录上一次文件的修改时间,然后先判断其修改时间是否变化,再判断其内容是否真正发生变化。 3.5 validate // Check the global white remote addr for (RemoteAddressStrategy remoteAddressStrategy : globalWhiteRemoteAddressStrategy) { if (remoteAddressStrategy.match(plainAccessResource)) { return; } } Step1:首先使用全局白名单对资源进行验证,只要一个规则匹配,则返回,表示认证成功。 if (plainAccessResource.getAccessKey() == null) { throw new AclException(String.format("No accessKey is configured")); } if (!plainAccessResourceMap.containsKey(plainAccessResource.getAccessKey())) { throw new AclException(String.format("No acl config for %s", plainAccessResource.getAccessKey())); } Step2:如果请求信息中,没有设置用户名,则抛出未配置AccessKey异常;如果Broker中并为配置该用户的配置信息,则抛出AclException。 // Check the white addr for accesskey PlainAccessResource ownedAccess = plainAccessResourceMap.get(plainAccessResource.getAccessKey()); if (ownedAccess.getRemoteAddressStrategy().match(plainAccessResource)) { return; } Step3:如果用户配置的白名单与待访问资源规则匹配的话,则直接发认证通过。 // Check the signature String signature = AclUtils.calSignature(plainAccessResource.getContent(), ownedAccess.getSecretKey()); if (!signature.equals(plainAccessResource.getSignature())) { throw new AclException(String.format("Check signature failed for accessKey=%s", plainAccessResource.getAccessKey())); } Step4:验证签名。 checkPerm(plainAccessResource, ownedAccess); Step5:调用checkPerm方法,验证需要的权限与拥有的权限是否匹配。 3.5.1 checkPerm if (Permission.needAdminPerm(needCheckedAccess.getRequestCode()) && !ownedAccess.isAdmin()) { throw new AclException(String.format("Need admin permission for request code=%d, but accessKey=%s is not", needCheckedAccess.getRequestCode(), ownedAccess.getAccessKey())); } Step6:如果当前的请求命令属于必须是Admin用户才能访问的权限,并且当前用户并不是管理员角色,则抛出异常,如下命令需要admin角色才能进行的操作: Map<String, Byte> needCheckedPermMap = needCheckedAccess.getResourcePermMap(); Map<String, Byte> ownedPermMap = ownedAccess.getResourcePermMap(); if (needCheckedPermMap == null) { // If the needCheckedPermMap is null,then return return; } if (ownedPermMap == null && ownedAccess.isAdmin()) { // If the ownedPermMap is null and it is an admin user, then return return; } Step7:如果该请求不需要进行权限验证,则通过认证,如果当前用户的角色是管理员,并且没有配置用户权限,则认证通过,返回。 for (Map.Entry<String, Byte> needCheckedEntry : needCheckedPermMap.entrySet()) { String resource = needCheckedEntry.getKey(); Byte neededPerm = needCheckedEntry.getValue(); boolean isGroup = PlainAccessResource.isRetryTopic(resource); if (ownedPermMap == null || !ownedPermMap.containsKey(resource)) { // Check the default perm byte ownedPerm = isGroup ? ownedAccess.getDefaultGroupPerm() : ownedAccess.getDefaultTopicPerm(); if (!Permission.checkPermission(neededPerm, ownedPerm)) { throw new AclException(String.format("No default permission for %s", PlainAccessResource.printStr(resource, isGroup))); } continue; } if (!Permission.checkPermission(neededPerm, ownedPermMap.get(resource))) { throw new AclException(String.format("No default permission for %s", PlainAccessResource.printStr(resource, isGroup))); } } Step8:遍历需要权限与拥有的权限进行对比,如果配置对应的权限,则判断是否匹配;如果未配置权限,则判断默认权限时是否允许,不允许,则抛出AclException。 验证逻辑就介绍到这里了,下面给出其匹配流程图:上述阐述了从Broker服务器启动、加载acl配置文件流程、动态监听配置文件、服务端权限验证流程,接下来我们看一下客户端关于ACL需要处理的事情。 4、AclClientRPCHook 回顾一下,我们引入ACL机制后,客户端的代码示例如下:其在创建DefaultMQProducer时,注册AclClientRPCHook钩子,会在向服务端发送远程命令前后执行其钩子函数,接下来我们重点分析一下AclClientRPCHook。 4.1 doBeforeRequest public void doBeforeRequest(String remoteAddr, RemotingCommand request) { byte[] total = AclUtils.combineRequestContent(request, parseRequestContent(request, sessionCredentials.getAccessKey(), sessionCredentials.getSecurityToken())); // @1 String signature = AclUtils.calSignature(total, sessionCredentials.getSecretKey()); // @2 request.addExtField(SIGNATURE, signature); // @3 request.addExtField(ACCESS_KEY, sessionCredentials.getAccessKey()); // The SecurityToken value is unneccessary,user can choose this one. if (sessionCredentials.getSecurityToken() != null) { request.addExtField(SECURITY_TOKEN, sessionCredentials.getSecurityToken()); } } 代码@1:将Request请求参数进行排序,并加入accessKey。 代码@2:对排好序的请参数,使用用户配置的密码生成签名,并最近到扩展字段Signature,然后服务端也会按照相同的算法生成Signature,如果相同,则表示签名验证成功(类似于实现登录的效果)。 代码@3:将Signature、AccessKey等加入到请求头的扩展字段中,服务端拿到这些元数据,结合请求头中的信息,根据配置的权限,进行权限校验。 关于ACL客户端生成签名是一种通用套路,就不在细讲了。 源码分析ACL的实现就介绍到这里了,下文将介绍RocketMQ 消息轨迹的使用与实现原理分析。如果大家觉得文章写的还不错的话,期待帮忙点赞,谢谢。 原文发布时间为:2019-07-07本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
1、什么是ACL? ACL是access control list的简称,俗称访问控制列表。访问控制,基本上会涉及到用户、资源、权限、角色等概念,那在RocketMQ中上述会对应哪些对象呢? 用户用户是访问控制的基础要素,也不难理解,RocketMQ ACL必然也会引入用户的概念,即支持用户名、密码。 资源资源,需要保护的对象,在RocketMQ中,消息发送涉及的Topic、消息消费涉及的消费组,应该进行保护,故可以抽象成资源。 权限针对资源,能进行的操作, 角色RocketMQ中,只定义两种角色:是否是管理员。 另外,RocketMQ还支持按照客户端IP进行白名单设置。 2、ACL基本流程图 在讲解如何使用ACL之前,我们先简单看一下RocketMQ ACL的请求流程:对于上述具体的实现,将在后续文章中重点讲解,本文的目的只是希望给读者一个大概的了解。 3、如何配置ACL 3.1 acl配置文件 acl默认的配置文件名:plain_acl.yml,需要放在${ROCKETMQ_HOME}/store/config目录下。下面对其配置项一一介绍。 3.1.1 globalWhiteRemoteAddresses 全局白名单,其类型为数组,即支持多个配置。其支持的配置格式如下: 空表示不设置白名单,该条规则默认返回false。 "*"表示全部匹配,该条规则直接返回true,将会阻断其他规则的判断,请慎重使用。 192.168.0.{100,101}多地址配置模式,ip地址的最后一组,使用{},大括号中多个ip地址,用英文逗号(,)隔开。 192.168.1.100,192.168.2.100直接使用,分隔,配置多个ip地址。 192.168..或192.168.100-200.10-20每个IP段使用 "*" 或"-"表示范围。 3.1.2 accounts 配置用户信息,该类型为数组类型。拥有accessKey、secretKey、whiteRemoteAddress、admin、defaultTopicPerm、defaultGroupPerm、topicPerms、groupPerms子元素。 3.1.2.1 accessKey 登录用户名,长度必须大于6个字符。 3.1.2.2 secretKey 登录密码。长度必须大于6个字符。 3.1.2.3 whiteRemoteAddress 用户级别的IP地址白名单。其类型为一个字符串,其配置规则与globalWhiteRemoteAddresses,但只能配置一条规则。 3.1.2.4 admin boolean类型,设置是否是admin。如下权限只有admin=true时才有权限执行。 UPDATE_AND_CREATE_TOPIC更新或创建主题。 UPDATE_BROKER_CONFIG更新Broker配置。 DELETE_TOPIC_IN_BROKER删除主题。 UPDATE_AND_CREATE_SUBSCRIPTIONGROUP更新或创建订阅组信息。 DELETE_SUBSCRIPTIONGROUP删除订阅组信息。 3.1.2.5 defaultTopicPerm 默认topic权限。该值默认为DENY(拒绝)。 3.1.2.6 defaultGroupPerm 默认消费组权限,该值默认为DENY(拒绝),建议值为SUB。 3.1.2.7 topicPerms 设置topic的权限。其类型为数组,其可选择值在下节介绍。 3.1.2.8 groupPerms 设置消费组的权限。其类型为数组,其可选择值在下节介绍。可以为每一消费组配置不一样的权限。 3.2 RocketMQ ACL权限可选值 DENY拒绝。 PUB拥有发送权限。 SUB拥有订阅权限。 3.3、权限验证流程 上面定义了全局白名单、用户级别的白名单,用户级别的权限,为了更好的配置ACL权限规则,下面给出权限匹配逻辑。 4、使用示例 4.1 Broker端安装 首先,需要在broker.conf文件中,增加参数aclEnable=true。并拷贝distribution/conf/plain_acl.yml文件到${ROCKETMQ_HOME}/conf目录。 broker.conf的配置文件如下: brokerClusterName = DefaultCluster brokerName = broker-b brokerId = 0 deleteWhen = 04 fileReservedTime = 48 brokerRole = ASYNC_MASTER flushDiskType = ASYNC_FLUSH listenPort=10915 storePathRootDir=E:/SH2019/tmp/rocketmq_home/rocketmq4.5MB/store storePathCommitLog=E:/SH2019/tmp/rocketmq_home/rocketmq4.5MB/store/commitlog namesrvAddr=127.0.0.1:9876 autoCreateTopicEnable=false aclEnable=true plain_acl.yml文件内容如下: globalWhiteRemoteAddresses: accounts: - accessKey: RocketMQ secretKey: 12345678 whiteRemoteAddress: admin: false defaultTopicPerm: DENY defaultGroupPerm: SUB topicPerms: - TopicTest=PUB groupPerms: # the group should convert to retry topic - oms_consumer_group=DENY - accessKey: admin secretKey: 12345678 whiteRemoteAddress: # if it is admin, it could access all resources admin: true 从上面的配置可知,用户RocketMQ只能发送TopicTest的消息,其他topic无权限发送;拒绝oms_consumer_group消费组的消息消费,其他消费组默认可消费。 4.2 消息发送端示例 public class AclProducer { public static void main(String[] args) throws MQClientException, InterruptedException { DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name", getAclRPCHook()); producer.setNamesrvAddr("127.0.0.1:9876"); producer.start(); for (int i = 0; i < 1; i++) { try { Message msg = new Message("TopicTest3" ,"TagA" , ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)); SendResult sendResult = producer.send(msg); System.out.printf("%s%n", sendResult); } catch (Exception e) { e.printStackTrace(); Thread.sleep(1000); } } producer.shutdown(); } static RPCHook getAclRPCHook() { return new AclClientRPCHook(new SessionCredentials("rocketmq","12345678")); } } 运行效果如图所示: 4.3 消息消费端示例 public class AclConsumer { public static void main(String[] args) throws InterruptedException, MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_4", getAclRPCHook(),new AllocateMessageQueueAveragely()); consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET); consumer.subscribe("TopicTest", "*"); consumer.setNamesrvAddr("127.0.0.1:9876"); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) { System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs); return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); consumer.start(); System.out.printf("Consumer Started.%n"); } static RPCHook getAclRPCHook() { return new AclClientRPCHook(new SessionCredentials("rocketmq","12345678")); } } 发现并不没有消费消息,符合预期。 关于RocketMQ ACL的使用就介绍到这里了,下一篇将介绍RocketMQ ACL实现原理。 原文发布时间为:2019-06-30本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
温馨提示:建议参考代码RocketMQ4.4版本,4.5版本引入了多副本机制,实现了主从自动切换,本文并不关心主从切换功能。 1、初识主从同步 主从同步基本实现过程如下图所示: RocketMQ 的主从同步机制如下: A. 首先启动Master并在指定端口监听;B. 客户端启动,主动连接Master,建立TCP连接;C. 客户端以每隔5s的间隔时间向服务端拉取消息,如果是第一次拉取的话,先获取本地commitlog文件中最大的偏移量,以该偏移量向服务端拉取消息;D. 服务端解析请求,并返回一批数据给客户端;E. 客户端收到一批消息后,将消息写入本地commitlog文件中,然后向Master汇报拉取进度,并更新下一次待拉取偏移量;F. 然后重复第3步; RocketMQ主从同步一个重要的特征:主从同步不具备主从切换功能,即当主节点宕机后,从不会接管消息发送,但可以提供消息读取。 温馨提示:本文并不会详细分析RocketMQ主从同步的实现细节,如大家对其感兴趣,可以查阅笔者所著的《RocketMQ技术内幕》或查看笔者博文:https://blog.csdn.net/prestigeding/article/details/79600792 2、提出问题 主,从服务器都在运行过程中,消息消费者是从主拉取消息还是从从拉取? RocketMQ主从同步架构中,如果主服务器宕机,从服务器会接管消息消费,此时消息消费进度如何保持,当主服务器恢复后,消息消费者是从主拉取消息还是从从服务器拉取,主从服务器之间的消息消费进度如何同步? 接下来带着上述问题,一起来探究其实现原理。 3、原理探究 3.1 RocketMQ主从读写分离机制 RocketMQ的主从同步,在默认情况下RocketMQ会优先选择从主服务器进行拉取消息,并不是通常意义的上的读写分离,那什么时候会从拉取呢? 温馨提示:本节同样不会详细整个流程,只会点出其关键点,如果想详细了解消息拉取、消息消费等核心流程,建议大家查阅笔者所著的《RocketMQ技术内幕》。 在RocketMQ中判断是从主拉取,还是从从拉取的核心代码如下: DefaultMessageStore#getMessage long diff = maxOffsetPy - maxPhyOffsetPulling; // @1 long memory = (long) (StoreUtil.TOTAL_PHYSICAL_MEMORY_SIZE * (this.messageStoreConfig.getAccessMessageInMemoryMaxRatio() / 100.0)); // @2 getResult.setSuggestPullingFromSlave(diff > memory); // @3 代码@1:首先介绍一下几个局部变量的含义: maxOffsetPy当前最大的物理偏移量。返回的偏移量为已存入到操作系统的PageCache中的内容。 maxPhyOffsetPulling本次消息拉取最大物理偏移量,按照消息顺序拉取的基本原则,可以基本预测下次开始拉取的物理偏移量将大于该值,并且就在其附近。 diffmaxOffsetPy与maxPhyOffsetPulling之间的间隔,getMessage通常用于消息消费时,即这个间隔可以理解为目前未处理的消息总大小。 代码@2:获取RocketMQ消息存储在PageCache中的总大小,如果当RocketMQ容量超过该阔值,将会将被置换出内存,如果要访问不在PageCache中的消息,则需要从磁盘读取。 StoreUtil.TOTAL_PHYSICAL_MEMORY_SIZE返回当前系统的总物理内存。参数 accessMessageInMemoryMaxRatio设置消息存储在内存中的阀值,默认为40。 结合代码@2这两个参数的含义,算出RocketMQ消息能映射到内存中最大值为40% * (机器物理内存)。 代码@3:设置下次拉起是否从从拉取标记,触发下次从从服务器拉取的条件为:当前所有可用消息数据(所有commitlog)文件的大小已经超过了其阔值,默认为物理内存的40%。 那GetResult的suggestPullingFromSlave属性在哪里使用呢? PullMessageProcessor#processRequest if (getMessageResult.isSuggestPullingFromSlave()) { // @1 responseHeader.setSuggestWhichBrokerId(subscriptionGroupConfig.getWhichBrokerWhenConsumeSlowly()); } else { responseHeader.setSuggestWhichBrokerId(MixAll.MASTER_ID); } switch (this.brokerController.getMessageStoreConfig().getBrokerRole()) { // @2 case ASYNC_MASTER: case SYNC_MASTER: break; case SLAVE: if (!this.brokerController.getBrokerConfig().isSlaveReadEnable()) { response.setCode(ResponseCode.PULL_RETRY_IMMEDIATELY); responseHeader.setSuggestWhichBrokerId(MixAll.MASTER_ID); } break; } if (this.brokerController.getBrokerConfig().isSlaveReadEnable()) { // @3 // consume too slow ,redirect to another machine if (getMessageResult.isSuggestPullingFromSlave()) { responseHeader.setSuggestWhichBrokerId(subscriptionGroupConfig.getWhichBrokerWhenConsumeSlowly()); } // consume ok else { responseHeader.setSuggestWhichBrokerId(subscriptionGroupConfig.getBrokerId()); } } else { responseHeader.setSuggestWhichBrokerId(MixAll.MASTER_ID); } 代码@1:如果从commitlog文件查找消息时,发现消息堆积太多,默认超过物理内存的40%后,会建议从从服务器读取。 代码@2:如果当前服务器的角色为从服务器:并且slaveReadEnable=true,则忽略代码@1设置的值,下次拉取切换为从主拉取。 代码@3:如果slaveReadEnable=true(从允许读),并且建议从从服务器读取,则从消息消费组建议当消息消费缓慢时建议的拉取brokerId,由订阅组配置属性whichBrokerWhenConsumeSlowly决定;如果消息消费速度正常,则使用订阅组建议的brokerId拉取消息进行消费,默认为主服务器。如果不允许从可读,则固定使用从主拉取。 温馨提示:请注意broker服务参数slaveReadEnable,与订阅组配置信息:whichBrokerWhenConsumeSlowly、brokerId的值,在生产环境中,可以通过updateSubGroup命令动态改变订阅组的配置信息。 如果订阅组的配置保持默认值的话,拉取消息请求发送到从服务器后,下一次消息拉取,无论是否开启slaveReadEnable,下一次拉取,还是会发往主服务器。 上面的步骤,在消息拉取命令的返回字段中,会将下次建议拉取Broker返回给客户端,根据其值从指定的broker拉取。 消息拉取实现PullAPIWrapper在处理拉取结果时会将服务端建议的brokerId更新到broker拉取缓存表中。在发起拉取请求之前,首先根据如下代码,选择待拉取消息的Broker。 3.2 消息消费进度同步机制 从上面内容可知,主从同步引入的主要目的就是消息堆积的内容默认超过物理内存的40%,则消息读取则由从服务器来接管,实现消息的读写分离,避免主服务IO抖动严重。那问题来了,主服务器宕机后,从服务器接管消息消费后,那消息消费进度存储在哪里?当主服务器恢复正常后,消息是从主服务器拉取还是从从服务器拉取?主服务器如何得知最新的消息消费进度呢? RocketMQ消息消费进度管理(集群模式):集群模式下消息消费进度存储文件位于服务端${ROCKETMQ_HOME}/store/config/consumerOffset.json。消息消费者从服务器拉取一批消息后提交到消费组特定的线程池中处理消息,当消息消费成功后会向Broker发送ACK消息,告知消费端已成功消费到哪条消息,Broker收到消息消费进度反馈后,首先存储在内存中,然后定时持久化到consumeOffset.json文件中。备注:关于消息消费进度管理更多的实现细节,建议查阅笔者所著的《RocketMQ技术内幕》。 我们先看一下客户端向服务端反馈消息消费进度时如何选择Broker。因为主服务的brokerId为0,默认情况下当主服务器存活的时候,优先会选择主服务器,只有当主服务器宕机的情况下,才会选择从服务器。 既然集群模式下消息消费进度存储在Broker端,当主服务器正常时,消息消费进度文件存储在主服务器,那提出如下两个问题:1)消息消费端在主服务器存活的情况下,会优先向主服务器反馈消息消费进度,那从服务器是如何同步消息消费进度的。2)当主服务器宕机后则消息消费端会向从服务器反馈消息消费进度,此时消息消费进度如何存储,当主服务器恢复正常后,主服务器如何得知最新的消息消费进度。 为了解开上述两个疑问,我们优先来看一下Broker服务器在收到提交消息消费进度反馈命令后的处理逻辑: 客户端定时向Broker端发送更新消息消费进度的请求,其入口为:RemoteBrokerOffsetStore#updateConsumeOffsetToBroker,该方法中一个非常关键的点是:选择broker的逻辑,如下所示:如果主服务器存活,则选择主服务器,如果主服务器宕机,则选择从服务器。也就是说,不管消息是从主服务器拉取的还是从从服务器拉取的,提交消息消费进度请求,优先选择主服务器。服务端就是接收其偏移量,更新到服务端的内存中,然后定时持久化到${ROCKETMQ_HOME}/store/config/consumerOffset.json。 经过上面的分析,我们来讨论一下这个场景: 消息消费者首先从主服务器拉取消息,并向其提交消息消费进度,如果当主服务器宕机后,从服务器会接管消息拉取服务,此时消息消费进度存储在从服务器,主从服务器的消息消费进度会出现不一致?那当主服务器恢复正常后,两者之间的消息消费进度如何同步? 3.2.1 从服务定时同步主服务器进度 如果Broker角色为从服务器,会通过定时任务调用syncAll,从主服务器定时同步topic路由信息、消息消费进度、延迟队列处理进度、消费组订阅信息。 那问题来了,如果主服务器启动后,从服务器马上从主服务器同步消息消息进度,那岂不是又要重新消费? 其实在绝大部分情况下,就算从服务从主服务器同步了很久之前的消费进度,只要消息者没有重新启动,就不需要重新消费,在这种情况下,RocketMQ提供了两种机制来确保不丢失消息消费进度。 第一种,消息消费者在内存中存在最新的消息消费进度,继续以该进度去服务器拉取消息后,消息处理完后,会定时向Broker服务器反馈消息消费进度,在上面也提到过,在反馈消息消费进度时,会优先选择主服务器,此时主服务器的消息消费进度就立马更新了,从服务器此时只需定时同步主服务器的消息消费进度即可。 第二种是,消息消费者在向主服务器拉取消息时,如果是是主服务器,在处理消息拉取时,也会更新消息消费进度。 3.2.2 主服务器消息拉取时更新消息消费进度 主服务器在处理消息拉取命令时,会触发消息消费进度的更新,其代码入口为:PullMessageProcessor#processRequest boolean storeOffsetEnable = brokerAllowSuspend; // @1 storeOffsetEnable = storeOffsetEnable && hasCommitOffsetFlag; storeOffsetEnable = storeOffsetEnable && this.brokerController.getMessageStoreConfig().getBrokerRole() != BrokerRole.SLAVE; // @2 if (storeOffsetEnable) { this.brokerController.getConsumerOffsetManager().commitOffset(RemotingHelper.parseChannelRemoteAddr(channel), requestHeader.getConsumerGroup(), requestHeader.getTopic(), requestHeader.getQueueId(), requestHeader.getCommitOffset()); } 代码@1:首先介绍几个局部变量的含义: brokerAllowSuspend:broker是否允许挂起,在消息拉取时,该值默认为true。 hasCommitOffsetFlag:消息消费者在内存中是否缓存了消息消费进度,如果缓存了,该标记设置为true。 如果Broker的角色为主服务器,并且上面两个变量都为true,则首先使用commitOffset更新消息消费进度。 看到这里,主从同步消息消费进度的相关问题,应该就有了答案了。 4、总结 上述实现原理的讲解有点枯燥无味,我们先来回答如下几个问题: 1、主,从服务器都在运行过程中,消息消费者是从主拉取消息还是从从拉取?答:默认情况下,RocketMQ消息消费者从主服务器拉取,当主服务器积压的消息超过了物理内存的40%,则建议从从服务器拉取。但如果slaveReadEnable为false,表示从服务器不可读,从服务器也不会接管消息拉取。 2、当消息消费者向从服务器拉取消息后,会一直从从服务器拉取?答:不是的。分如下情况:1)如果从服务器的slaveReadEnable设置为false,则下次拉取,从主服务器拉取。2)如果从服务器允许读取并且从服务器积压的消息未超过其物理内存的40%,下次拉取使用的Broker为订阅组的brokerId指定的Broker服务器,该值默认为0,代表主服务器。3)如果从服务器允许读取并且从服务器积压的消息超过了其物理内存的40%,下次拉取使用的Broker为订阅组的whichBrokerWhenConsumeSlowly指定的Broker服务器,该值默认为1,代表从服务器。 3、主从服务消息消费进是如何同步的?答:消息消费进度的同步时单向的,从服务器开启一个定时任务,定时从主服务器同步消息消费进度;无论消息消费者是从主服务器拉的消息还是从从服务器拉取的消息,在向Broker反馈消息消费进度时,优先向主服务器汇报;消息消费者向主服务器拉取消息时,如果消息消费者内存中存在消息消费进度时,主会尝试跟新消息消费进度。 读写分离的正确使用姿势:1、主从Broker服务器的slaveReadEnable设置为true。2、通过updateSubGroup命令更新消息组whichBrokerWhenConsumeSlowly、brokerId,特别是其brokerId不要设置为0,不然从从服务器拉取一次后,下一次拉取就会从主去拉取。 原文发布时间为:2019-06-26本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
index aliases,索引别名,有点类似名称映射,一个索引别名可以映射多个真实索引,索引别名在定义时还支持filter,构成同一个索引,不同的视图。 思考:一个索引别名可以映射成多个索引,那如果向一个别名添加一个文档时,会在该别名下对应的所有索引下都创建一个文档? 1、如何创建索引别名 POST /_aliases { "actions" : [ { "remove" : { "index" : "test1", "alias" : "alias1" } }, { "add" : { "index" : "test2", "alias" : "alias1" } } ] } 索引创建API,支持add、remove操作,当前Restfull java客户端未封装该方法。 为索引创建别名,也可以在创建索引API中指定: PUT test { "aliases" : { "alias_1" : {}, "alias_2" : { "filter" : { "term" : {"user" : "kimchy" } }, "routing" : "kimchy" } } } 2、Filtered Aliases 带有过滤器的别名提供了创建相同索引的不同“视图”的简单方法。过滤器可以使用查询DSL定义,并应用于所有搜索、计数、按查询删除以及类似于此别名的操作。 其使用示例如下,假设存储该索引: PUT /test1 { "mappings": { "_doc": { "properties": { "user" : { "type": "keyword" } } } } } 为别名设置过滤器的使用方法如下: POST /_aliases { "actions" : [ { "add" : { "index" : "China_Provice_Index", "alias" : "shanghai_index", "filter" : { "term" : { "provice" : "shanghai" } } }, "add" : { "index" : "China_Provice_Index", "alias" : "guangzhou_index", "filter" : { "term" : { "provice" : "guangzhou" } } } } ] } 通过为China_Provice_Index(中国各省份人才数据库索引)创建别名,shanghai_index、guangzhou_index,这样从两个别名进行数据查询,只会查出各自省份的数据,是不是有点类似于”多租户“,也即通过索引别名并指定过滤器,能为同一个索引提供不同的视图。 3、Routing 在创建别名时可以指定路由值。 POST /_aliases { "actions" : [ { "add" : { "index" : "test", "alias" : "alias1", "routing" : "1" } } ] } 使用别名alias1查询内容时,会自动使用该值进行路由。也可以通过search_routing、index_routing分别来指定查询、索引时的路由值,注意,index_routing只能指定一个值。 4、Write Index 如果一个别名只映射了一个真实索引,则可以使用别名进行index api(即索引文档,写文档),但如果一个别名同一时间映射了多个索引,默认是不能直接使用别名进行索引文档,因为ES不知道文档该发往哪个索引。 可以使用is_write_index属性为一个别名下的其中一个索引指定为写索引,此时则可以直接使用别名进行index api的调用。例如: POST /_aliases { "actions" : [ { "add" : { "index" : "test", "alias" : "alias1", "is_write_index" : true } }, { "add" : { "index" : "test2", "alias" : "alias1" } } ] } es index aliases,索引别名就介绍到这里了。 原文发布时间为:2019-03-14本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注自中间件兴趣圈。
索引的配置项按是否可以更改分为static属性与动态配置,所谓的静态配置即索引创建后不能修改。 1、索引静态配置 index.number_of_shards索引分片的数量。在ES层面可以通过es.index.max_number_of_shards属性设置索引最大的分片数,默认为1024,index.number_of_shards的默认值为Math.min(es.index.max_number_of_shards,5),故通常默认值为5。 index.shard.check_on_startup分片在打开之前是否应该检查该分片是否损坏。当检测到损坏时,它将阻止分片被打开。可选值:false:不检测;checksum:只检查物理结构;true:检查物理和逻辑损坏,相对比较耗CPU;fix:类同与false,7.0版本后将废弃。默认值:false。 index.codec数据存储的压缩算法,默认值为LZ4,可选择值best_compression ,比LZ4可以获得更好的压缩比(即占据较小的磁盘空间,但存储性能比LZ4低)。 index.routing_partition_size路由分区数,如果设置了该参数,其路由算法为: (hash(_routing) + hash(_id) % index.routing_parttion_size ) % number_of_shards。如果该值不设置,则路由算法为 hash(_routing) % number_of_shardings,_routing默认值为_id。 2、索引动态配置 index.number_of_replicas索引复制分片的个数,默认值1,该值必须大于等于0,索引创建后该值可以变更。 index.auto_expand_replicas副本数是否自动扩展,可设置(e.g0-5)或(0-all)。 index.refresh_interval执行刷新操作的频率,该操作使对索引的最新更改对搜索可见。默认为1s。可以设置为-1以禁用刷新。 index.max_result_window控制分页搜索总记录数,from + size的大小不能超过该值,默认为10000。 index.max_inner_result_window从from+ size的最大值,用于控制top aggregations,默认为100。内部命中和顶部命中聚合占用堆内存,并且时间与 from + size成正比,这限制了内存。 index.max_rescore_window在rescore的搜索中,rescore请求的window_size的最大值。 index.max_docvalue_fields_search一次查询最多包含开启doc_values字段的个数,默认为100。 index.max_script_fields查询中允许的最大script_fields数量。默认为32。 index.max_ngram_diffNGramTokenizer和NGramTokenFilter的min_gram和max_gram之间允许的最大差异。默认为1。 index.max_shingle_diff对于ShingleTokenFilter, max_shingle_size和min_shingle_size之间允许的最大差异。默认为3。 index.blocks.read_only索引数据、索引元数据是否只读,如果设置为true,则不能修改索引数据,也不能修改索引元数据。 index.blocks.read_only_allow_delete与index.blocks.read_only基本类似,唯一的区别是允许删除动作。 index.blocks.read设置为true以禁用对索引数据的读取操作。 index.blocks.write设置为true以禁用对索引数据的写操作。(针对索引数据,而不是索引元数据) index.blocks.metadata设置为true,表示不允许对索引元数据进行读与写。 index.max_refresh_listeners索引的每个分片上当刷新索引时最大的可用监听器数量。这些侦听器用于实现refresh=wait_for。 index.highlight.max_analyzed_offset高亮显示请求分析的最大字符数。此设置仅适用于在没有偏移量或term vectors的文本字段时。默认情况下,该设置在6中未设置。x,默认值为-1。 index.max_terms_count可以在terms查询中使用的术语的最大数量。默认为65536。 index.routing.allocation.enableAllocation机制,其主要解决的是如何将索引在ES集群中在哪些节点上分配分片(例如在Node1是创建的主分片,在其他节点上创建复制分片)。 举个例子,如果集群中新增加了一个节点,集群的节点由原来的3个变成了4可选值: 1. all 所有类型的分片都可以重新分配,默认。 2. primaries 只允许分配主分片。 3. new_primaries 只允许分配新创建的主分片。 4. none 所有的分片都不允许分配。 index.routing.rebalance.enable索引的分片重新平衡机制。可选值如下: all默认值,允许对所有分片进行再平衡。 primaries只允许对主分片进行再平衡。 replicas只允许对复制分片进行再平衡。 none不允许对任何分片进行再平衡 index.gc_deletes文档删除后(删除后版本号)还可以存活的周期,默认为60s。 index.max_regex_length用于正在表达式查询(regex query)正在表达式长度,默认为1000。 index.default_pipeline默认的管道聚合器。 3、Analysis 分析模块相关配置参数,该部分中已在 字段类型映射(mapping中详细介绍 4、Index Shard Allocation 索引分片分配相关参数。这部分内容将在Cluster(集群模块详细介绍)。 5、Mapping 字段映射相关参数,详情请参考: Elasticsearch Mapping parameters(主要参数一览) 6、Merging 后台分片合并进程相关配置参数。 index.merge.scheduler.max_thread_count用于单个分片节点合并的最大线程数量,默认值为:Math.max(1, Math.min(4, Runtime.getRuntime().availableProcessors() / 2)),如果是非SSD盘,该值建议设置为1。 7、Similarities 相似性相关配置,这个后续可能会以专题介绍,暂不深究。 8、Show Log 慢查询日志相关配置。 8.1 Search Show Log 首先ES提供在查询阶段(query)和数据获取阶段(fetch)设置阔值,超过该阔值则记录日志。支持如下参数: index.search.slowlog.threshold.query.warn: 10s index.search.slowlog.threshold.query.info: 5s index.search.slowlog.threshold.query.debug: 2s index.search.slowlog.threshold.query.trace: 500ms 上述参数定义查询阶段的阔值,分别表示,如果执行时间超过10s,打出警告日志,超过5s输出info级别日志。 index.search.slowlog.threshold.fetch.warn: 1s index.search.slowlog.threshold.fetch.info: 800ms index.search.slowlog.threshold.fetch.debug: 500ms index.search.slowlog.threshold.fetch.trace: 200ms 上述参数定义查询获取数据(fetch)的阔值,分别表示,如果执行时间超过1s,打出警告日志,超过800ms输出info级别日志。 index.search.slowlog.level: info 定义日志输出级别为info,也就是hdebug,trace级别的日志不输出。 注意:上述日志级别为分片级日志。 上述参数定义了日志输出级别,那接下来还需要在log4j文件中定义日志输出器,日志输出文件路径等,其相关配置如下: appender.index_search_slowlog_rolling.type = RollingFile appender.index_search_slowlog_rolling.name = index_search_slowlog_rolling appender.index_search_slowlog_rolling.fileName = ${sys:es.logs}_index_search_slowlog.log appender.index_search_slowlog_rolling.layout.type = PatternLayout appender.index_search_slowlog_rolling.layout.pattern = [%d{ISO8601}][%-5p][%-25c] [%node_name]%marker %.10000m%n appender.index_search_slowlog_rolling.filePattern = ${sys:es.logs}_index_search_slowlog-%d{yyyy-MM-dd}.log appender.index_search_slowlog_rolling.policies.type = Policies // 文件切割方案,属于log4j的语法 appender.index_search_slowlog_rolling.policies.time.type = TimeBasedTriggeringPolicy // 基于时间切割,log4j还支持按大小切割,其类为SizeBasedTriggeringPolicy。 appender.index_search_slowlog_rolling.policies.time.interval = 1 // 1小时切割成一个文件 appender.index_search_slowlog_rolling.policies.time.modulate = true // 是否修正时间范围, 如果设置为true,则从0时开始计数 logger.index_search_slowlog_rolling.name = index.search.slowlog logger.index_search_slowlog_rolling.level = trace logger.index_search_slowlog_rolling.appenderRef.index_search_slowlog_rolling.ref = index_search_slowlog_rolling logger.index_search_slowlog_rolling.additivity = false 8.2 Index Show Log 索引慢日志。 index.indexing.slowlog.threshold.index.warn: 10s index.indexing.slowlog.threshold.index.info: 5s index.indexing.slowlog.threshold.index.debug: 2s index.indexing.slowlog.threshold.index.trace: 500ms index.indexing.slowlog.level: info index.indexing.slowlog.source: 1000 index.indexing.slowlog.source参数用来控制记录文档_souce字段字符的个数,默认为1000,表示只记录_souce字段的前1000个字符,可以设置true,表示输出_souce字段全部内容,设置为false,表示不记录_souce字段的内容。 默认情况下,会对_souce字段的输出进行格式化,通常使用一行输出,如果想阻止格式化,可以通过index.indexing.slowlog.reformat设置为false来避免。 同样通过上述属性定义好阔值,接下来将在logg4j配置文件中定义日志的输出。 appender.index_indexing_slowlog_rolling.type = RollingFile appender.index_indexing_slowlog_rolling.name = index_indexing_slowlog_rolling appender.index_indexing_slowlog_rolling.fileName = ${sys:es.logs}_index_indexing_slowlog.log appender.index_indexing_slowlog_rolling.layout.type = PatternLayout appender.index_indexing_slowlog_rolling.layout.pattern = [%d{ISO8601}][%-5p][%-25c] [%node_name]%marker %.-10000m%n appender.index_indexing_slowlog_rolling.filePattern = ${sys:es.logs}_index_indexing_slowlog-%d{yyyy-MM-dd}.log appender.index_indexing_slowlog_rolling.policies.type = Policies appender.index_indexing_slowlog_rolling.policies.time.type = TimeBasedTriggeringPolicy appender.index_indexing_slowlog_rolling.policies.time.interval = 1 appender.index_indexing_slowlog_rolling.policies.time.modulate = true logger.index_indexing_slowlog.name = index.indexing.slowlog.index logger.index_indexing_slowlog.level = trace logger.index_indexing_slowlog.appenderRef.index_indexing_slowlog_rolling.ref = index_indexing_slowlog_rolling logger.index_indexing_slowlog.additivity = false 9、store 存储模块,其主要参数为:index.store.type,表示存储类型,该参数为静态参数,在索引创建时指定,无法更改。其可选值: fs默认文件系统实现,根据当前操作系统选择最佳存储方式。 simplefs简单的FS类型,使用随机访问文件实现文件系统存储(映射到Lucene SimpleFsDirectory)。并发性能很差(多线程会出现瓶颈)。当需要索引持久性时,通常最好使用niofs。 niofs基于NIOS实现的文件系统,该类型使用NIO在文件系统上存储碎片索引(映射到Lucene NIOFSDirectory)。它允许多个线程同时从同一个文件中读取数据。 mmapfs基于文件内存映射机制实现的文件系统实现,该方式将文件映射到内存(MMap)来存储文件系统上的碎片索引(映射到Lucene MMapDirectory)。内存映射使用进程中与被映射文件大小相同的部分虚拟内存地址空间。 可以通过node.store.allow_mmapfs属性来禁用基于内存映射机制,如果节点所在的操作系统没有大量的虚拟内存,则可以使用该属性明确禁止使用该文件实现。 10、Translog 由于Lucene提交的开销太大,不能每个单独变更就提交(刷写到磁盘),所以每个分片复制都有一个事务日志,称为translog。所有索引(index)和删除(delete)操作都是在被内部Lucene索引处理之后(但在它们被确认之前[返回客户端])写入translog的。在发生崩溃的情况下,当分片恢复时,可以从translog中恢复最近已确认但尚未包含在上一次Lucene提交中的事务。 Translog日志有点类似于关系型数据库mysql的redo日志。 Translog相关配置参数(索引级别): index.translog.durabilitytranslog刷盘方式,可选值:request、async。request,即每请求一次刷盘,也就是客户端发起一个增删改操作时,会在主分片与复制分片全部刷盘成功后,才会返回成功,是ES的默认模式。async:异步刷盘模式,此模式刷盘频率由index.translog.sync_interval设置,其默认值为5s,该模式会存在数据丢失的可能。 index.translog.sync_interval如果index.translog.durability设置为async,用该值来设置刷盘的频率,默认为5s。 index.translog.flush_threshold_sizees强制刷新的另外一个维度,如果translog的大小达到该值,则强制将未刷盘的数据强制刷新到Lucene中(类比一下关系型数据库的数据文件),默认512mb。 index.translog.retention.size保存跨日志文件的总大小。也就是一translog日志文件flush后,并不马上删除,而是保留一段时间,但最新的translog文件已存储的内容与待删除的文件的间隔不超过该参数设置的值,默认为512M。 index.translog.retention.age保存translog文件的最大持续时间,默认为12 h。 关于ES的配置属性就先介绍到这里,后续还会对Analysis、Index Shard Allocation、Similarities这三个模块进行更加详细的说明。 原文发布时间为:2019-04-20本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
管道聚合处理来自其他聚合而不是文档集的输出,将信息添加到输出树中。 注:关于脚本聚合目前在本文中暂时不会涉及。 主要有如下两种管道聚合方式: parent sibling 下面一一介绍ES定义的管道聚合。 1、Avg Bucket Aggregation 同级管道聚合,它计算同级聚合中指定度量的平均值。同级聚合必须是多桶聚合,针对的是度量聚合(metric Aggregation)。示例如下: { "avg_bucket": { "buckets_path": "the_sum" // @1 } } buckets_path:指定聚合的名称,支持多级嵌套聚合。其他参数: gap_policy当管道聚合遇到不存在的值,有点类似于term等聚合的(missing)时所采取的策略,可选择值为:skip、insert_zeros。 skip:此选项将丢失的数据视为bucket不存在。它将跳过桶并使用下一个可用值继续计算。 insert_zeros:默认使用0代替。 format用于格式化聚合桶的输出(key)。 示例如下: POST /_search { "size": 0, "aggs": { "sales_per_month": { // @1 "date_histogram": { "field": "date", "interval": "month" }, "aggs": { // @2 "sales": { "sum": { "field": "price" } } } }, "avg_monthly_sales": { // @3 "avg_bucket": { "buckets_path": "sales_per_month>sales" } } } } 代码@1:首先定义第一级聚合(按月)直方图聚合。代码@2:定义第二级聚合,在按月聚合的基础上,对每个月的文档求sum。代码@3:对上面的聚合求平均值。 其返回结果如下: { ... // 省略 "aggregations": { "sales_per_month": { "buckets": [ { "key_as_string": "2015/01/01 00:00:00", "key": 1420070400000, "doc_count": 3, "sales": { "value": 550.0 } }, { "key_as_string": "2015/02/01 00:00:00", "key": 1422748800000, "doc_count": 2, "sales": { "value": 60.0 } } ] }, "avg_monthly_sales": { // 这是对二级聚合的结果再进行一次求平均值聚合。 "value": 328.33333333333333 } } } 对应的JAVA示例如下: public static void test_pipeline_avg_buncket_aggregation() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.terms("seller_agg") .field("sellerId") .subAggregation(AggregationBuilders.sum("seller_num_agg") .field("num") ) ; sourceBuilder.aggregation(aggregationBuild); // 添加 avg bucket pipeline sourceBuilder.aggregation(new AvgBucketPipelineAggregationBuilder("seller_num_agg_av", "seller_agg>seller_num_agg")); sourceBuilder.size(0); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 2、Percentiles Bucket Aggregation 同级管道聚合,百分位管道聚合。其JAVA示例如下: public static void test_Percentiles_buncket_aggregation() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.terms("seller_agg") .field("sellerId") .subAggregation(AggregationBuilders.sum("seller_num_agg") .field("num") ) ; sourceBuilder.aggregation(aggregationBuild); // 添加 avg bucket pipeline sourceBuilder.aggregation(new PercentilesBucketPipelineAggregationBuilder("seller_num_agg_av", "seller_agg>seller_num_agg")); sourceBuilder.size(0); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 其返回值如下: { ... // 省略其他属性 "aggregations":{ "lterms#seller_agg":{ "doc_count_error_upper_bound":0, "sum_other_doc_count":12, "buckets":[ { "key":45, "doc_count":567, "sum#seller_num_agg":{ "value":911 } }, { "key":31, "doc_count":324, "sum#seller_num_agg":{ "value":353 } } // 省略其他桶的显示 ] }, "percentiles_bucket#seller_num_agg_av":{ "values":{ "1.0":5, "5.0":5, "25.0":10, "50.0":20, "75.0":290, "95.0":911, "99.0":911 } } } } 3、Cumulative Sum Aggregation 累积管道聚合,就是就是依次将每个管道的sum聚合进行累加。 其语法(restfull)如下: { "cumulative_sum": { "buckets_path": "the_sum" } } 支持的参数说明: buckets_path桶聚合名称,作为管道聚合的输入信息。 format格式化key。 使用示例如下: POST /sales/_search { "size": 0, "aggs" : { "sales_per_month" : { "date_histogram" : { "field" : "date", "interval" : "month" }, "aggs": { "sales": { "sum": { "field": "price" } }, "cumulative_sales": { "cumulative_sum": { "buckets_path": "sales" } } } } } } 其返回结果如下: { "took": 11, "timed_out": false, "_shards": ..., "hits": ..., "aggregations": { "sales_per_month": { "buckets": [ { "key_as_string": "2015/01/01 00:00:00", "key": 1420070400000, "doc_count": 3, "sales": { "value": 550.0 }, "cumulative_sales": { "value": 550.0 } }, { "key_as_string": "2015/02/01 00:00:00", "key": 1422748800000, "doc_count": 2, "sales": { "value": 60.0 }, "cumulative_sales": { "value": 610.0 } }, { "key_as_string": "2015/03/01 00:00:00", "key": 1425168000000, "doc_count": 2, "sales": { "value": 375.0 }, "cumulative_sales": { "value": 985.0 } } ] } } } 从结果可知,cumulative_sales的值等于上一个cumulative_sales + 当前桶的sum聚合。 对应的JAVA示例如下: { "aggregations":{ "date_histogram#createTime_histogram":{ "buckets":{ "2015-12-01 00:00:00":{ "key_as_string":"2015-12-01 00:00:00", "key":1448928000000, "doc_count":6, "sum#seller_num_agg":{ "value":16 }, "simple_value#Cumulative_Seller_num_agg":{ "value":16 } }, "2016-01-01 00:00:00":{ "key_as_string":"2016-03-01 00:00:00", "key":1456790400000, "doc_count":10, "sum#seller_num_agg":{ "value":11 }, "simple_value#Cumulative_Seller_num_agg":{ "value":31 } } // ... 忽略 } } } } 4、Bucket Sort Aggregation 一种父管道聚合,它对其父多桶聚合的桶进行排序。并可以指定多个排序字段。每个bucket可以根据它的_key、_count或子聚合进行排序。此外,可以设置from和size的参数,以便截断结果桶。 使用语法如下: { "bucket_sort": { "sort": [ {"sort_field_1": {"order": "asc"}}, {"sort_field_2": {"order": "desc"}}, "sort_field_3" ], "from": 1, "size": 3 } } 支持的参数说明如下: sort定义排序结构。 from用与对父聚合的桶进行截取,该值之前的所有桶将忽略,也就是不参与排序,默认为0。 size返回的桶数。默认为父聚合的所有桶。 gap_policy当管道聚合遇到不存在的值,有点类似于term等聚合的(missing)时所采取的策略,可选择值为:skip、insert_zeros。 skip:此选项将丢失的数据视为bucket不存在。它将跳过桶并使用下一个可用值继续计算。 insert_zeros:默认使用0代替。 官方示例如下: POST /sales/_search { "size": 0, "aggs" : { "sales_per_month" : { "date_histogram" : { "field" : "date", "interval" : "month" }, "aggs": { "total_sales": { "sum": { "field": "price" } }, "sales_bucket_sort": { "bucket_sort": { "sort": [ {"total_sales": {"order": "desc"}} ], "size": 3 } } } } } } 对应的JAVA示例如下: public static void test_bucket_sort_Aggregation() { RestHighLevelClient client = EsClient.getClient(); try { //构建日期直方图聚合 时间间隔,示例中按月统计 DateHistogramInterval interval = new DateHistogramInterval("1M"); SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.dateHistogram("createTime_histogram") .field("createTime") .dateHistogramInterval(interval) .keyed(true) .subAggregation(AggregationBuilders.sum("seller_num_agg") .field("num") ) .subAggregation(new BucketSortPipelineAggregationBuilder("seller_num_agg_sort", Arrays.asList( new FieldSortBuilder("seller_num_agg").order(SortOrder.ASC))) .from(0) .size(3)) // BucketSortPipelineAggregationBuilder(String name, List<FieldSortBuilder> sorts) .subAggregation(new CumulativeSumPipelineAggregationBuilder("Cumulative_Seller_num_agg", "seller_num_agg")) // .format("yyyy-MM-dd") // 对key的格式化 ; sourceBuilder.aggregation(aggregationBuild); sourceBuilder.size(0); sourceBuilder.query( QueryBuilders.termQuery("sellerId", 24) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 返回值: { "aggregations":{ "date_histogram#createTime_histogram":{ "buckets":{ "2016-04-01 00:00:00":{ "key_as_string":"2016-04-01 00:00:00", "key":1459468800000, "doc_count":2, "sum#seller_num_agg":{ "value":2 }, "simple_value#Cumulative_Seller_num_agg":{ "value":2 } }, "2017-05-01 00:00:00":{ "key_as_string":"2017-05-01 00:00:00", "key":1493596800000, "doc_count":3, "sum#seller_num_agg":{ "value":3 }, "simple_value#Cumulative_Seller_num_agg":{ "value":5 } }, "2017-02-01 00:00:00":{ "key_as_string":"2017-02-01 00:00:00", "key":1485907200000, "doc_count":4, "sum#seller_num_agg":{ "value":4 }, "simple_value#Cumulative_Seller_num_agg":{ "value":9 } } } } } 5、Max Bucket Aggregation 与 avg类似。 6、Min Bucket Aggregation 与 avg类似。 7、Sum Bucket Aggregation 与 avg类似。 8、Stats Bucket Aggregation 与 avg类似。 本节详细介绍了ES Pipeline Aggregation 管道聚合的使用方法,重点介绍了Avg Bucket Aggregation、Percentiles Bucket Aggregation、Cumulative Sum Aggregation、Bucket Sort Aggregation、Max Bucket Aggregation、Min Bucket Aggregation、Sum Bucket Aggregation、Stats Bucket Aggregation。 原文发布时间为:2019-03-18本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
本章将介绍elasticsearch最重要的桶聚合terms aggregation。 1、Terms Aggregation 多值聚合,根据库中的文档动态构建桶。基于词根的聚合,如果聚合字段是text的话,会对一个一个的词根进行聚合,通常不会在text类型的字段上使用聚合,对标关系型数据中的(Group By)。 官方示例如下: GET /_search { "aggs" : { "genres" : { "terms" : { "field" : "genre" } } } } 返回结果如下: { ... "aggregations" : { "genres" : { "doc_count_error_upper_bound": 0, // @1 "sum_other_doc_count": 0, // @2 "buckets" : [ // @3 { "key" : "electronic", "doc_count" : 6 }, { "key" : "rock", "doc_count" : 3 }, { "key" : "jazz", "doc_count" : 2 } ] } } } 返回结果@1:该值表示未进入最终术语列表的术语的最大潜在文档计数,下文还会详细分析。返回结果@2:当有很多词根时,Elasticsearch只返回最上面的项;这个数字是所有不属于响应的bucket的文档计数之和,其搜索过程在下文会讲到。返回结果@3:返回的结果,默认情况下,返回doc_count排名最前的10个,受size参数的影响,下面会详细介绍。 1.1 Terms 聚合支持如下常用参数: size可以通过size返回top size的文档,该术语聚合针对顶层术语(不包含嵌套词根),其搜索过程是将请求向所有分片节点发送请求,每个分片节点返回size条数据,然后聚合所有分片的结果(会对各分片返回的同样词根的数数值进行相加),最终从中挑选size条记录返回给客户端。从这个过程也可以看出,其结果并不是准确的,而是一个近似值。 Shard Size为了提高该聚合的精确度,可以通过shard_size参数设置协调节点向各个分片请求的词根个数,然后在协调节点进行聚合,最后只返回size个词根给到客户端,shard_size >= size,如果shard_size设置小于size,ES会自动将其设置为size,默认情况下shard_size建议设置为(1.5 * size + 10)。 1.2 Calculating Document Count Error 为了阐述返回结果中的doc_count_error_upper_bound、sum_other_doc_count代表什么意思,我们通过如下例子来说明Term Aggregations的工作机制。 例如在三个分片上关于产品的初始聚合信息如下:现在统计size=5的term Aggregations,协调节点向Shard A、B、C分别请求前5条聚合信息,如下图所示: 根据这些返回的结果,在协调节点上聚合,最终得出如下响应结果: { ... "aggregations" : { "products" : { "doc_count_error_upper_bound" : 46, "sum_other_doc_count" : 79, "buckets" : [ { "key" : "Product A", "doc_count" : 100 }, { "key" : "Product Z", "doc_count" : 52 } { "key" : "Product C", "doc_count" : 50 } { "key" : "Product G", "doc_count" : 45 } ... ] } } } 那doc_count_error_upper_bound、sum_other_doc_count又分别代表什么呢? doc_count_error_upper_bound该值表示未进入最终术语列表的术语的最大潜在文档计数。这是根据从每个碎片返回的上一项的文档计数之和计算的(协调节点根据每个分片节点返回的最后一条数据相加得来的)。这意味着在最坏的情况下,没有返回的词根的最大文档个数为46个,在此次聚合结果中排名第4。 sum_other_doc_count未纳入本次聚合结果中的文档总数量,这个容易理解。 1.3 Per bucket Document Count Error 每个桶的错误文档数量,可以通过参数show_term_doc_count_error=true来展示每个文档未被纳入结果集的数量。 其使用示例如下: GET /_search { "aggs" : { "products" : { "terms" : { "field" : "product", "size" : 5, "show_term_doc_count_error": true } } } } 对应的返回值: { ... "aggregations" : { "products" : { "doc_count_error_upper_bound" : 46, "sum_other_doc_count" : 79, "buckets" : [ { "key" : "Product A", "doc_count" : 100, "doc_count_error_upper_bound" : 0 }, { "key" : "Product Z", "doc_count" : 52, "doc_count_error_upper_bound" : 2 } ... ] } } } 1.4 order 可以设置桶的排序,默认是按照桶的doc_count降序排序的。order的可选值: "order" : { "_count" : "asc" } "order" : { "_key" : "asc" } 支持子聚合的结果作为排序字段。 GET /_search { "aggs" : { "genres" : { "terms" : { "field" : "genre", "order" : { "max_play_count" : "desc" } // "order" : { "playback_stats.max" : "desc" } }, "aggs" : { "max_play_count" : { "max" : { "field" : "play_count" } } // "playback_stats" : { "stats" : { "field" : "play_count" } } } } } } "order" : { "playback_stats.max" : "desc" }其中键的书写规则如下:用 > 分隔聚合名称,用.分开METRIC类型的聚合。 1.5 Minimum document count 通过指定min_doc_count来过滤匹配文档数量小于该值的桶。 1.6 Filtering values(值过滤) 对值使用正则表达式进行过滤,示例如下: GET /_search { "aggs" : { "tags" : { "terms" : { "field" : "tags", "include" : ".*sport.*", // include 包含 "exclude" : "water_.*" // exclude 排除 } } } } 精确值匹配 "JapaneseCars" : { "terms" : { "field" : "make", "include" : ["mazda", "honda"] } } 分区过滤: GET /_search { "size": 0, "aggs": { "expired_sessions": { "terms": { "field": "account_id", "include": { "partition": 0, // @1 "num_partitions": 20 // @2 }, "size": 10000, "order": { "last_access": "asc" } }, "aggs": { "last_access": { "max": { "field": "access_date" } } } } } } 分区的意思就是将值分成多个组,没一个请求只处理其中一个组,其中参数 @1表示请求的分组ID,num_partitions表示总共的分组数。 1.7 Multi-field terms aggregation 多字段词根聚合。terms aggregation不支持从同一文档中的多个字段收集词根。因为terms aggregation本身并不收集所有的词根,而是使用全局序数来生成字段中所有惟一值的列表。全局序数会带来重要的性能提升,而这在多个字段中是不可能实现的。 有两种方法可以用于跨多个字段执行term aggregation: script使用脚本方式,目前暂不探讨其脚本的使用。 copy_to field使用copy_to在映射中聚合多个字段。 1.8 Collect mode 收集模式,ES支持两种收集模式: depth_first:深度优先,默认值。 breadth_first:广度优先。 首先我们先学习一下树的基本知识(深度遍历与广度遍历),例如有如下一颗二叉树:深度遍历:深度遍历是从一个节点开始,先遍历完该节点所有的子节点,然后再返回遍历它的兄弟节点,通常深度遍历分为中序遍历、前序遍历,后序遍历。 中序遍历(遍历左子树–>访问根–>遍历右子树):D B E A F C G 前序遍历(访问根–>遍历左子树–>遍历右子树):A B D E C F G 后序遍历(遍历左子树–>遍历右子树–>访问根):D E B F G C A 广度遍历(一层一层遍历):A B C D E F G 广度优先聚合与深度优先聚合的构建流程(聚合流程)与其遍历顺序一致。 下面我们以官方的示例来进一步说明: 例如现在有一个电影的文档,其索引中的数据如下:现在要统计出演电视剧最多的演员(前3),并且和这些演员合作次数最多的演员。其聚合语法如下: GET /_search { "aggs" : { "actors" : { "terms" : { "field" : "actors", "size" : 3, “shard_size” : 50 "collect_mode" : "breadth_first" }, "aggs" : { "costars" : { // 子聚合 "terms" : { "field" : "actors", "size" : 5 } } } } } } 对应的JAVA示例如下: public static void test_term_aggregation_collect_mode() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("movies_index"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.terms("actors_agg") .field("actors") .size(3) .shardSize(50) .collectMode(SubAggCollectionMode.BREADTH_FIRST) .subAggregation(AggregationBuilders.terms("costars_agg") .field("actors") .size(3)) ; sourceBuilder.aggregation(aggregationBuild); sourceBuilder.size(0); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 返回结果如下: { ... // 省略 "aggregations":{ "sterms#actors_agg":{ "doc_count_error_upper_bound":0, "sum_other_doc_count":30, "buckets":[ { "key":"赵丽颖", "doc_count":3, "sterms#costars_agg":{ "doc_count_error_upper_bound":0, "sum_other_doc_count":19, "buckets":[ { "key":"赵丽颖", "doc_count":3 }, { "key":"俞灏明", "doc_count":1 }, { "key":"冯绍峰", "doc_count":1 } ] } }, { "key":"李亚鹏", "doc_count":2, "sterms#costars_agg":{ "doc_count_error_upper_bound":0, "sum_other_doc_count":8, "buckets":[ { "key":"李亚鹏", "doc_count":2 }, { "key":"吕丽萍", "doc_count":1 }, { "key":"周杰", "doc_count":1 } ] } }, { "key":"俞灏明", "doc_count":1, "sterms#costars_agg":{ "doc_count_error_upper_bound":0, "sum_other_doc_count":5, "buckets":[ { "key":"俞灏明", "doc_count":1 }, { "key":"刘凡菲", "doc_count":1 }, { "key":"孟瑞", "doc_count":1 } ] } } ] } } } 深度遍历优先的执行路径: 开始对整个电影库进行搜索,从文档中得出第一个影员,例如赵丽颖,然后立马执行子聚合,首先刷选出有赵丽颖参与的文档集中的词根,并聚合其数量,排名前3的组成一个聚合结果,生成类似于: { "key":"赵丽颖", "doc_count":3, "sterms#costars_agg":{ "doc_count_error_upper_bound":0, "sum_other_doc_count":19, "buckets":[ { "key":"赵丽颖", "doc_count":3 }, { "key":"俞灏明", "doc_count":1 }, { "key":"冯绍峰", "doc_count":1 } ] } } 然后再返回上一层聚合,再对上一层的下一个词根执行类似的聚合,最后进行排序,在第一层进行裁剪(刷选)前size个文档返回个客户端。 广度遍历优先的执行路径: 首先执行第一层聚合,也就是针对所有文档中的actors字段进行聚合,得到文档集中所有的演员,然后按doc_count排序,进行裁剪,刷选前3个演员,然后只针对这3个演员进行第二层聚合。 看上去广度遍历优先会非常高效,其实这里掩藏了一个实现细节,就是广度优先,会缓存裁剪后剩余的所有文档,也就是本例中与这3个演员的所有文档集在内存中,然后基于这些内存执行第二层聚合,故如果第一层每个桶如果包含的文档数量巨大,则会耗费很大的内存,容易触发OOM异常,故广度优先的使用场景是子聚合所需要处理的数据很少的情况下会非常高效。 参考知识:http://www.cnblogs.com/bonelee/p/7832738.html 1.9 execution hint 执行提示,类似于MySQL数据库的hint功能。 Term Aggregation聚合通常基于如下两种实现方式: 通过直接使用字段值来聚合每个桶的数据(map)只有当很少的文档匹配查询时,才应该考虑映射。否则,基于序号的执行模式会快得多。默认情况下,map只在脚本上运行聚合时使用,因为它们没有序号。 通过使用字段的全局序号并为每个全局序号分配一个bucket (global_ordinals)keyword类型的字段默认使用global_ordinals机制,它使用全局序号动态分配bucket,因此内存使用与属于聚合范围的文档的值的数量是线性的。 默认情况下,ES会自动选择,但也可以通过参数execution_hint进行人工干预,可选值:global_ordinals、map。 1.10 Missing value missing定义了应该如何处理缺少值的文档。默认情况下,它们将被忽略,但也可以将它们视为具有一个值。Terms Aggregation聚合就介绍到这里了。 2、 Significant Terms Aggregation 返回集合中出现的有趣或不寻常的项的聚合。 首先从官方示例开始学习。 官方示例的索引结构大概如下(类似一个全国犯罪事件索引库)核心字段:force:接案警局名称。crime_type:犯罪类型。 2.1 Single-Set analysis 单一结果集分析,通常前台集合(foreground set)通常通过一组查询条件指定。请看示例: GET /_search { "query" : { // @1 "terms" : {"force" : "上海交通警局" }, "aggregations" : { "significant_crime_types" : { "significant_terms" : { "field" : "crime_type" } // @2 } } } 代码@1:定义一个查询,该例中查询警局为“ShangHai Transport Police”所有犯罪记录,当成我们关注(感兴趣的集合,也就是Significant Terms Aggregation中的(foreground set)。 代码@2:对crime_type犯罪类型进行significant_terms. 返回结果如下: { ... "aggregations" : { "significant_crime_types" : { "doc_count": 47347, // @1 "bg_count": 5064554, // @2 "buckets" : [ // @3 { "key": "自行车盗窃案", "doc_count": 3640, // @4 "score": 0.371235374214817, "bg_count": 66799 // @5 } , { "key": "小汽车盗窃案", "doc_count": 6640, "score": 0.371235374214815, "bg_count": 66799 } ... ] } } } 代码@1:doc_count:符合查询条件的总文档数量,此例表示上海交通警局总共的犯罪记录数。代码@2:bg_count:这是Significant Terms中的background set,应该是该索引当前总共的文档个数。代码@3:是significant_terms针对犯罪类型的聚合结果。代码@4:表示上海交通警局总共发生的自行车盗窃案的总记录数。代码@5:表示整个索引库中所有警局发生的自行车盗窃案的总记录数。 从这里的结果,我们可以得出如下结论: 整体自行车犯罪率= 66799/5064554,约等于1%。 上海交通警局自行车盗窃犯罪率(上海交通警局自行车犯罪总记录数除以上海交通警局的总犯罪记录)=3640/47347约等于7%。 使用这种查询来找出异常数据,但它只给了我们一个用于比较的子集。要发现所有其他警察部队的异常情况,我们必须对每个不同的警察部队重复查询。 如何解决该问题呢?请看下文。 2.2 Multi-set analysis 多结果集对比分析,其思路是通过term aggregation产生多个桶(多个数据集合),然后再使用子聚合针对这些分组再进行一次聚合。 跨多个类别执行分析的一种更简单的方法是使用父级聚合来分割准备分析的数据。使用父聚合进行分割的示例: GET /_search { "aggregations": { "forces": { "terms": {"field": "force"}, // @1 "aggregations": { "significant_crime_types": { // @2 "significant_terms": {"field": "crime_type"} } } } } } 代码@1:首先对字段force进行term聚合,统计各个警局的犯罪记录总数。代码@2:然后子聚合是对犯罪类型进行significant_terms聚合。 我们先来看一下返回结果: { ... "aggregations": { "forces": { "doc_count_error_upper_bound": 1375, "sum_other_doc_count": 7879845, "buckets": [ { "key": "广州交通警局", "doc_count": 894038, "significant_crime_types": { "doc_count": 894038, // @1 "bg_count": 5064554, // @2 "buckets": [ // @3 { "key": "抢劫", // @4 "doc_count": 27617, // @5 "score": 0.0599, "bg_count": 53182 // @6 } ... } }// 省略其他警局的数据。 ] } } } 主要针对significant_crime_types的结果集做一次解释:结果@1:"广州交通警局"总处理犯案记录总数为894038。结果@2:索引库总处理犯案记录总数为5064554。结果@3:"广州交通警局"各个犯案类型的聚合数据。结果@4:犯罪类型(crime_type)为“抢劫”类型的聚合数据。结果@5:"广州交通警局" “抢劫”类案的处理条数为27617。结果@6:索引库总处理犯罪类型为“抢劫”的总数为53182 。 2.3 Significant聚合的分数如何计算 如果术语在子集中(foreground set)出现的频率和在背景中(background sets)出现的频率有显著差异,则认为该术语是重要的。 2.4 Custom background sets 定制background sets集合。通常情况下,ES的Significant聚合使用整个索引库的内容当成background sets(背景集合),可以通过background_filter参数来指定,其使用示例如下: 2.5 Significant Terms Aggregation限制 聚合字段必须是索引的 不支持浮点类型字段聚合。 由于Significant Terms Aggregation聚合的background sets是整个索引文档,故如果用作foreground set的查询返回结果也是整个文档集合(match_all)的话,该聚合则失去意义。 如果有相当于match_all查询没有查询条件提供索引的一个子集significant_terms聚合不应该被用作最顶部的聚合——在这个场景中前景是完全一样的背景设定,所以没有文档频率的差异来的观察和合理建议。 与Terms Aggregation一样,其结果是近似值,可以通过size、shard_size来控制其精度。 另一个需要考虑的问题是,significant_terms聚合在切分级别上生成许多候选结果,只有在合并所有切分的统计信息之后,才会在reduce节点上对这些结果进行修剪。因此,就RAM而言,将大型子聚合嵌入到一个重要的_terms聚合(稍后将丢弃许多候选项)下是低效且昂贵的。在这种情况下,最好执行两个搜索——第一个搜索提供一个合理的重要术语列表,然后将这个术语短列表添加到第二个查询中,以返回并获取所需的子聚合。 Significant Terms Aggregation支持Terms Aggregation定义的参数,诸如size、sharding_size、missing、collect_mode、execution_hint、min_doc_count等参数。 原文发布时间为:2019-03-14本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
本篇将开始介绍Elasticsearch Bucket聚合(桶聚合)。 Buket Aggregations(桶聚合)不像metrics Aggregations(度量聚合)那样计算字段上的度量,而是创建文档桶,每个文件桶有效地定义一个文档集。除了bucket本身之外,bucket聚合还计算并返回“落入”每个bucket的文档的数量。 与度量聚合相反,桶聚合可以嵌套子聚合。这些子聚合将为它们的“父”桶聚合创建的桶进行聚合。 ES Bucket Aggregations对标关系型数据库的(group by)。 首先我们来介绍桶聚合两个常用参数intervals、time_zone的含义。 1、Intervals 定义桶的间隔,其可选值如下: seconds1, 5, 10, 30的倍数。 minutes1, 5, 10, 30的倍数。 hours1, 3, 12的倍数。 days1,7的倍数。 months1, 3的倍数。 years1, 5, 10, 20, 50, 100的倍数。 2、Time Zone 对于日期类型,可以使用time_zone来指定时区,可选值可以是相对ISO 8601 utc的相对值,例如+01:00或-08:00,也可以是时区ID,例如America/Los_Angeles。 3、Histogram Aggregation 直方图聚合,Date Histogram Aggregation是其特例。 动态将文档中的值按照特定的间隔构建桶,并计算落在该桶的数量,文档中的值根据如下函数进行近似匹配: bucket_key = Math.floor((value - offset) / interval) * interval + offset,其中interval必须是正小数(包含正整数),offset为[0,interval)。 主要支持的参数如下: keyed响应结果返回组织方式(数组或对象),具体示例请参考日期类直方图聚合。 doc_count匹配的文档数量。 offset 偏移量更改每个bucket(桶)的开始时间,例如将offset设置为"10",则上例中返回的一个桶的key为:[10,30),如果offset设置为5,则第一个桶的key为[15,30)。 order默认按照key的升序进行排序,可以通过order字段来指定排序,其值为BucketOrder。 其取值: BucketOrder.count(boolean asc)按匹配文档格式升序/降序排序。 BucketOrder.key(boolean asc)按key的升序或降序排序。 BucketOrder.aggregation通过定义一个子聚合进行排序。 BucketOrder.compound(List< BucketOrder> orders)创建一个桶排序策略,该策略根据多个条件对桶进行排序。 min_doc_count表示只显示匹配的文档大于等于min_doc_count的桶。 具体JAVA的示例将在Date Histogram Aggregation中详细介绍。 4、Date Histogram Aggregation 日期字段直方图聚合。 4.1 interval 取值 milliseconds (ms)毫秒,固定长度,支持倍数,通常使用1000的倍数。 seconds (s)秒 minutes (m)分钟。所有的分钟从00秒开始 1m,表示在指定时区的第一分钟00s到下一分钟00s之间的时间段。{n}m,表示时间间隔,等于n 60 1000 毫秒。 hours (h)小时,其分钟与秒都从00开始。 1小时(1h)是指定时区内第一个小时的00:00分钟到下一个小时的00:00分钟之间的时间间隔,用来补偿其间的任何闰秒,从而使经过该小时的分钟数和秒数在开始和结束时相同。 {n}h,表示时间间隔,等于 n 60 60 * 1000 毫秒的时间间隔。 days (d) 一天(1d)是在指定的时区内,从一天的开始到第二天的开始的时间间隔。 {n}d,表示时间间隔,等于n 24 60 60 1000毫秒。 weeks (w) 1周(1w)为开始日:of_week:hour:minute:second与一周的同一天及下一周的时间在指定时区的间隔。 不支持 {n}w。 months (M) 一个月(1M)是本月开始之间的时间间隔的一天与次月的同一天。 不支持{n}M quarters (q)季度,不支持{n}q。 years (y)年, 不支持{n}y。 4.2 示例 { "aggs" : { "sales_over_time" : { "date_histogram" : { "field" : "date", "interval" : "month" } } } } 对应的JAVA示例如下: /** * 日期直方图聚合 */ public static void test_Date_Histogram_Aggregation() { RestHighLevelClient client = EsClient.getClient(); try { //构建日期直方图聚合 时间间隔,示例中按月统计 DateHistogramInterval interval = new DateHistogramInterval("1M"); SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.dateHistogram("createTime_histogram") .field("createTime") .dateHistogramInterval(interval) // .format("yyyy-MM-dd") // 对key的格式化 ; sourceBuilder.aggregation(aggregationBuild); sourceBuilder.size(0); sourceBuilder.query( QueryBuilders.termQuery("sellerId", 24) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 对应的返回值: { ... //省略常规响应 "aggregations":{ "date_histogram#createTime_histogram":{ "buckets":[ "key_as_string":"2015-12-01 00:00:00", "key":1448928000000, "doc_count":6 }, { "key_as_string":"2016-01-01 00:00:00", "key":1451606400000, "doc_count":4 } ] } } } 其相应的参数已在上面详述,在此不重复介绍。 4.3 Date Histogram聚合支持的常用参数 除Histogram Aggregation罗列的参数后,还额外支持如下参数: timeZone 时区指定。 offset 偏移量更改每个bucket(桶)的开始时间,例如将offset设置为"1h",则上例中返回的一个桶的开始时间:"2015-12-01 00:00:00",则更改为"2015-12-01 01:00:00" format key格式化,将key使用format格式化后的值设置为key_as_string字段。 keyed 返回结果格式化,默认为false,则buckets返回值为数组,如果keyed=true,则对应的返回结果如下: "aggregations":{ "date_histogram#createTime_histogram":{ "buckets":{ "2015-12-01 00:00:00":{ "key_as_string":"2015-12-01 00:00:00", "key":1448928000000, "doc_count":6 }, "2016-01-01 00:00:00":{ "key_as_string":"2016-01-01 00:00:00", "key":1451606400000, "doc_count":4 } } } } } 5、Date Range Aggregation 日期范围聚合,每个范围定义[from,to),from,to可支持date mesh格式。其使用示例如下,其他与 Date Histogram类似。 /** * 日期范围聚合 */ public static void test_Date_range_Aggregation() { RestHighLevelClient client = EsClient.getClient(); try { //构建日期直方图聚合 时间间隔,示例中按月统计 SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.dateRange("createTime_date_range") .field("createTime") .format("yyyy-MM-dd") .addRange("quarter_01", "2016-01", "2016-03") .addRange("quarter_02", "2016-03", "2016-06") .addRange("quarter_03", "2016-06", "2016-09") .addRange("quarter_04", "2016-09", "2016-12") // .format("yyyy-MM-dd") // 对key的格式化 ; sourceBuilder.aggregation(aggregationBuild); sourceBuilder.size(0); sourceBuilder.query( QueryBuilders.termQuery("sellerId", 24) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 6、Filter Aggregation 聚合中支持首先根据过滤上下文对所有文档进行刷选,然后再进行聚合计算,例如: POST /sales/_search?size=0 { "aggs" : { "t_shirts" : { "filter" : { "term": { "type": "t-shirt" } }, "aggs" : { "avg_price" : { "avg" : { "field" : "price" } } } } } } 其对应的JAVA代码如下: /** * 日期范围聚合 */ public static void test_filter_Aggregation() { RestHighLevelClient client = EsClient.getClient(); try { //构建日期直方图聚合 时间间隔,示例中按月统计 SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.filter("t_shirts", QueryBuilders.termQuery("status", "1")) .subAggregation(AggregationBuilders.avg("avg").field("num")) ; sourceBuilder.aggregation(aggregationBuild); sourceBuilder.size(0); sourceBuilder.query( QueryBuilders.termQuery("sellerId", 24) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 其返回结果如下: { ... //省略 "aggregations":{ "filter#t_shirts":{ "doc_count":2, "avg#avg":{ "value":1 } } } } { ... //省略 "aggregations":{ "filter#t_shirts":{ "doc_count":2, "avg#avg":{ "value":1 } } } } 7、Filters Aggregation 定义一个多桶聚合,其中每个桶与一个过滤器相关联。每个bucket将收集与其关联过滤器匹配的所有文档。 public static void test_filters_aggregation() { RestHighLevelClient client = EsClient.getClient(); try { //构建日期直方图聚合 时间间隔,示例中按月统计 SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.filters("create_filters", QueryBuilders.termQuery("status", 1), QueryBuilders.termQuery("buyerId", 1)) .subAggregation(AggregationBuilders.avg("avg").field("num")) ; sourceBuilder.aggregation(aggregationBuild); sourceBuilder.size(0); sourceBuilder.query( QueryBuilders.termQuery("sellerId", 24) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 其返回结果: { ... // 省略 "aggregations":{ "filters#create_filters":{ "buckets":[ { "doc_count":2, "avg#avg":{ "value":1 } }, { "doc_count":0, "avg#avg":{ "value":null } } ] } } } 温馨提示,每一个filter代表一个桶(聚合)。 8、Global Aggregation 全局聚合,会忽略所有的查询条件,具体从下述例子进行说明: POST /sales/_search?size=0 { "query" : { "match" : { "type" : "t-shirt" } }, "aggs" : { "all_products" : { "global" : {}, "aggs" : { "avg_price" : { "avg" : { "field" : "price" } } } }, "t_shirts": { "avg" : { "field" : "price" } } } } 其聚合的文档集不是匹配该查询的文档"query" : {"match" : { "type" : "t-shirt" } },而是针对所有的文档进行聚合。 对应的JAVA实例如下: public static void test_global_aggregation() { RestHighLevelClient client = EsClient.getClient(); try { //构建日期直方图聚合 时间间隔,示例中按月统计 SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.global("all_producers") .subAggregation(AggregationBuilders .avg("num_avg_aggregation") .field("num")) ; sourceBuilder.aggregation(aggregationBuild); sourceBuilder.size(0); sourceBuilder.query( QueryBuilders.termQuery("sellerId", 24) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 对应的返回值如下: { "took":151, "timed_out":false, "_shards":{ "total":5, "successful":5, "skipped":0, "failed":0 }, "hits":{ "total":39, // @1 "max_score":0, "hits":[ ] }, "aggregations":{ "global#all_producers":{ "doc_count":1286, // @2 "avg#num_avg_aggregation":{ "value":1.3157076205287714 } } } } 结果@1:表示符合查询条件的总个数。结构@2:表示参与聚合的文档数量,等于当前库中文档总数。 9、IP Range Aggregation ip类型特有的范围聚合,与其他聚合使用类似,就不重复介绍了。 10、Missing Aggregation 统计缺少某个字段的文档个数。JAVA示例如下: AggregationBuilder aggregationBuild = AggregationBuilders.missing("missing_num_count") .field("num"); 11、Range Aggregation 基于多桶值源的聚合,允许用户定义一组范围——每个范围表示一个桶。在聚合过程中,将根据每个bucket范围和相关/匹配文档的“bucket”检查从每个文档中提取的值。注意,此聚合包含from值,并排除每个范围的to值。 GET /_search { "aggs" : { "price_ranges" : { "range" : { "field" : "price", "ranges" : [ { "to" : 100.0 }, { "from" : 100.0, "to" : 200.0 }, { "from" : 200.0 } ] } } } } 对应的JAVA示例如下: public static void test_range_aggregation() { RestHighLevelClient client = EsClient.getClient(); try { //构建日期直方图聚合 时间间隔,示例中按月统计 SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.range("num_range_aggregation") .field("num") .addRange(0, 5) .addRange(5,10) .addUnboundedFrom(10) ; sourceBuilder.aggregation(aggregationBuild); sourceBuilder.size(0); sourceBuilder.query( QueryBuilders.termQuery("sellerId", 24) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 其返回结果如下: { // 省略 "aggregations":{ "range#num_range_aggregation":{ "buckets":[ { "key":"0.0-5.0", "from":0, "to":5, "doc_count":38 }, { "key":"5.0-10.0", "from":5, "to":10, "doc_count":0 }, { "key":"10.0-*", "from":10, "doc_count":1 } ] } } } Range Aggregations支持嵌套聚合,使用subAggregations来支持嵌套聚合,根据官网示例如下: GET /_search { "aggs" : { "price_ranges" : { "range" : { // @1 "field" : "price", "ranges" : [ { "to" : 100 }, { "from" : 100, "to" : 200 }, { "from" : 200 } ] }, "aggs" : { // @2 "price_stats" : { "stats" : { "field" : "price" } } } } } } 首先通过@1定义范围聚合,然后对每个桶中 的文档再执行子聚合@2,其返回结果如下: { ... "aggregations": { "price_ranges": { "buckets": [ { "key": "*-100.0", "to": 100.0, "doc_count": 2, "price_stats": { "count": 2, "min": 10.0, "max": 50.0, "avg": 30.0, "sum": 60.0 } }, { "key": "100.0-200.0", "from": 100.0, "to": 200.0, "doc_count": 2, "price_stats": { "count": 2, "min": 150.0, "max": 175.0, "avg": 162.5, "sum": 325.0 } }, { "key": "200.0-*", "from": 200.0, "doc_count": 3, "price_stats": { "count": 3, "min": 200.0, "max": 200.0, "avg": 200.0, "sum": 600.0 } } ] } } } 本文详细介绍了ES 桶聚合,并给出JAVA示例,下一篇将重点关注ES桶聚合之term聚合。 原文发布时间为:2019-03-10本文作者:丁威,《RocketMQ技术内幕》作者。本文来自 中间件兴趣圈,了解相关信息可以关注 中间件兴趣圈。
从本篇将开始进入ES系列的聚合部分(Aggregations)。 本篇重点介绍Elasticsearch Metric Aggregations(度量聚合)。 Metric聚合,主要针对数值类型的字段,类似于关系型数据库中的sum、avg、max、min等聚合类型。 本例基于如下索引进行试验: public static void createMapping_agregations() { RestHighLevelClient client = EsClient.getClient(); try { CreateIndexRequest request = new CreateIndexRequest("aggregations_index02"); XContentBuilder jsonBuilder = XContentFactory.jsonBuilder() .startObject() .startObject("properties") .startObject("orderId") .field("type", "integer") .endObject() .startObject("orderNo") .field("type", "keyword") .endObject() .startObject("totalPrice") .field("type", "double") .endObject() .startObject("sellerId") .field("type", "integer") .endObject() .startObject("sellerName") .field("type", "keyword") .endObject() .startObject("buyerId") .field("type", "integer") .endObject() .startObject("buyerName") .field("type", "keyword") .endObject() .startObject("createTime") .field("type", "date") .field("format", "yyyy-MM-dd HH:mm:ss") .endObject() .startObject("status") .field("type", "integer") .endObject() .startObject("reciveAddressId") .field("type", "integer") .endObject() .startObject("reciveName") .field("type", "keyword") .endObject() .startObject("phone") .field("type", "keyword") .endObject() .startObject("skuId") .field("type", "integer") .endObject() .startObject("skuNo") .field("type", "keyword") .endObject() .startObject("goodsId") .field("type", "integer") .endObject() .startObject("goodsName") .field("type", "keyword") .endObject() .startObject("num") .field("type", "integer") .endObject() .endObject() .endObject(); request.mapping("_doc", jsonBuilder); System.out.println(client.indices().create(request, RequestOptions.DEFAULT)); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 对应的SQL表结构如下: CREATE TABLE `es_order_tmp` ( `orderId` int(11) NOT NULL DEFAULT '0' COMMENT '主键', `orderNo` varchar(30) DEFAULT NULL COMMENT '订单编号', `totalPrice` decimal(10,2) DEFAULT NULL COMMENT '订单总价,跟支付中心返回金额相等,包括了雅豆,余额,第三方支付的金额。运费包含在内,优惠券抵扣的金额不含在内', `sellerId` int(11) DEFAULT NULL COMMENT '商家ID', `selerName` varchar(50) DEFAULT NULL COMMENT '商家名称', `buyerId` int(11) DEFAULT NULL COMMENT '创建者,购买者', `buyerName` varchar(255) DEFAULT NULL COMMENT '业主姓名', `createTime` varchar(22) DEFAULT NULL, `status` int(11) DEFAULT NULL COMMENT '订单状态,0:待付款,1:待发货,2:待收货,3:待评价,4:订单完成,5:订单取消,6:退款处理中,7:拒绝退货,8:同意退货,9:退款成功,10:退款关闭,11:订单支付超时,12:半支付状态', `reciveAddressId` int(11) DEFAULT NULL COMMENT '收货地址ID', `reciveName` varchar(50) DEFAULT NULL, `phone` varchar(30) DEFAULT NULL COMMENT '联系号码', `skuId` int(11) DEFAULT NULL COMMENT '货品ID', `skuNo` varchar(100) DEFAULT NULL COMMENT 'SKU编号', `goodsId` int(11) DEFAULT NULL COMMENT '商品ID', `goodsName` varchar(100) DEFAULT NULL COMMENT '商品名称', `num` int(11) DEFAULT NULL COMMENT '数量' ) ENGINE=InnoDB DEFAULT CHARSET=utf8; avg 平均值 POST /exams/_search?size=0 { "aggs" : { "avg_grade" : { "avg" : { "field" : "grade" } } } } 对字段grade取平均值。 对应的java示例如下: public static void testMatchQuery() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder avg = AggregationBuilders.avg("avg-aggregation").field("num").missing(0); // @1 sourceBuilder.aggregation(avg); sourceBuilder.size(0); sourceBuilder.query( QueryBuilders.termQuery("sellerId", 24) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 其中代码@1:missing(0)表示如果文档中没有取平均值的字段时,则使用该值进行计算,本例中使用0参与计算。 其返回结果如下: { "took":2, "timed_out":false, "_shards":{ "total":5, "successful":5, "skipped":0, "failed":0 }, "hits":{ "total":39, "max_score":0, "hits":[ ] }, "aggregations":{ "avg#avg-aggregation":{ "value":1.2820512820512822 } } } Weighted Avg Aggregation 加权平均聚合 加权平均算法,∑(value * weight) / ∑(weight)。 加权平均(weghted_avg)支持的参数列表: value提供值的字段或脚本的配置。例如定义计算哪个字段的平均值,该值支持如下子参数: field用来定义平均值的字段名称。 missing用来定义如果匹配到的文档没有avg字段,使用该值来参与计算。 weight用来定义权重的对象,其可选属性如下: field定义权重来源的字段。 missing如果文档缺失权重来源字段,以该值来代表该文档的权重值。 format数值类型格式化。 value_type用来指定value的类型,例如ValueType.DATE、ValueType.IP等。 示例如下: POST /exams/_search { "size": 0, "aggs" : { "weighted_grade": { "weighted_avg": { "value": { "field": "grade" }, "weight": { "field": "weight" // @2 } } } } } 从文档中抽取属性为weight的字段的值来当权重值。其JAVA示例如下: public static void test_weight_avg_aggregation() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); WeightedAvgAggregationBuilder avg = AggregationBuilders.weightedAvg("avg-aggregation") .value( (new MultiValuesSourceFieldConfig.Builder()) .setFieldName("num") .setMissing(0) .build() ) .weight( (new MultiValuesSourceFieldConfig.Builder()) .setFieldName("num") .setMissing(1) .build() ) // .valueType(ValueType.LONG) ; avg.toString(); sourceBuilder.aggregation(avg); sourceBuilder.size(0); sourceBuilder.query( QueryBuilders.termQuery("sellerId", 24) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } Cardinality Aggregation 基数聚合,先distinct,再聚合,类似关系型数据库(count(distinct))。 示例如下: POST /sales/_search?size=0 { "aggs" : { "type_count" : { "cardinality" : { "field" : "type" } } } } 对应的JAVA示例如下: public static void test_Cardinality_Aggregation() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.cardinality("buyerid_count").field("buyerId"); sourceBuilder.aggregation(aggregationBuild); sourceBuilder.size(0); sourceBuilder.query( QueryBuilders.termQuery("sellerId", 24) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 返回结果如下: { "took":30, "timed_out":false, "_shards":{ "total":5, "successful":5, "skipped":0, "failed":0 }, "hits":{ "total":39, "max_score":0, "hits":[ ] }, "aggregations":{ "cardinality#type_count":{ "value":11 } } } 上述实现与SQL:SELECT COUNT(DISTINCT buyerId) from es_order_tmp where sellerId=24; 效果类似,表示购买了商家id为24的买家个数。 其核心参数如下: precision_threshold精确度控制。在此计数之下,期望计数接近准确。在这个值之上,计数可能会变得更加模糊(不准确)。支持的最大值是40000,超过此值的阈值与40000的阈值具有相同的效果。默认值是3000。 上述示例中返回的11是精确值,如果改写成下面的代码,结果将变的不准确: field("buyerId").precisionThreshold(5) 其返回结果如下: { "took":5, "timed_out":false, "_shards":{ "total":5, "successful":5, "skipped":0, "failed":0 }, "hits":{ "total":39, "max_score":0, "hits":[ ] }, "aggregations":{ "cardinality#buyerid_count":{ "value":9 } } } Pre-computed hashes一个比较好的实践是需要对字符串类型的字段进行基数聚合的话,可以提前索引该字符串的hash值,通过对hash值的聚合,提高效率。 Missing Valuemissing参数定义了应该如何处理缺少值的文档。默认情况下,它们将被忽略,但也可以将它们视为具有一个值,通过missing value来设置。 Extended Stats Aggregation stats聚合的扩展版本,示例如下: GET /exams/_search { "size": 0, "aggs" : { "grades_stats" : { "extended_stats" : { "field" : "grade" } } } } 对应的JAVA示例如下: public static void test_Extended_Stats_Aggregation() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.extendedStats("extended_stats") .field("num") ; sourceBuilder.aggregation(aggregationBuild); sourceBuilder.size(0); sourceBuilder.query( QueryBuilders.termQuery("sellerId", 24) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 返回的结果如下: { "took":13, "timed_out":false, "_shards":{ "total":5, "successful":5, "skipped":0, "failed":0 }, "hits":{ "total":39, "max_score":0, "hits":[ ] }, "aggregations":{ "extended_stats#extended_stats":{ "count":39, // @1 "min":1, // @2 "max":11, // @3 "avg":1.2820512820512822, // @4 "sum":50, // @5 "sum_of_squares":162, // @6 "variance":2.5101906640368177, // @7 "std_deviation":1.5843581236692725, // @8 "std_deviation_bounds":{ // @9 "upper":4.450767529389827, "lower":-1.886664965287263 } } } } 将所能支持的聚合类型都返回。@1:返回符合条件的总条数。@2:该属性在符合条件中的最小值。@3:该属性在符合条件中的最大值。@4:该属性在符合条件的文档中的平均值。@5:该属性在符合条件的文档中的sum求和。@6-9:暂未理解其含义。 同样支持missing属性。 max Aggregation 求最大值,与avg Aggregation聚合类似,不再重复介绍。 min Aggregation 求最小值,与avg Aggregation聚合类似,不再重复介绍。 Percentiles Aggregation 百分位计算,ES提供的另外一种近似度量方式。主要用于展现以具体百分比下观察到的数值,例如,第95个百分位上的数值,是高于 95% 的数据总和。百分位聚合通常用来找出异常,适用与使用统计学中正态分布来观察问题。官方文档:https://www.elastic.co/guide/cn/elasticsearch/guide/current/percentiles.html 例如: GET latency/_search { "size": 0, "aggs" : { "load_time_outlier" : { "percentiles" : { "field" : "load_time" } } } } load_time,在官方文档中的字段含义为字段加载时间,其返回值如下: { ... "aggregations": { "load_time_outlier": { "values" : { "1.0": 5.0, "5.0": 25.0, "25.0": 165.0, "50.0": 445.0, "75.0": 725.0, "95.0": 945.0, "99.0": 985.0 } } } } 默认的百分比key为[ 1, 5, 25, 50, 75, 95, 99 ]。按照官方的解读,可以这样理解上述返回结果:"1.0": 5.0;表示(100-1)%的数据都大于5.0;也表示1%的数据小于5.0。 "5.0": 25.0 表示,95%的请求的加载时间大于等于25。"99.0": 985.0 表示1%的请求的加载时间大于985.0。 percentile用来定义其百分比,例如percents:[10,50,95,99] keyed默认情况下,keyed参数为true,其结果的返回格式如上: "values" : { "1.0": 5.0, "5.0": 25.0, "25.0": 165.0, "50.0": 445.0, "75.0": 725.0, "95.0": 945.0, "99.0": 985.0 } 如果设置keyed=false,则返回值的格式如下: "aggregations": { "load_time_outlier": { "values": [ { "key": 1.0, "value": 5.0 }, { "key": 5.0, "value": 25.0 }, ... ] } } 百分位使用场景百分位通常使用近似统计。 计算百分位数有许多不同的算法。简单实现只是将所有值存储在一个排序数组中。要找到第50个百分位,只需找到my_array[count(my_array) * 0.5]处的值。 显然,这种简单的实现没有伸缩性——排序数组随数据集中值的数量线性增长。为了计算es集群中可能存在的数十亿个值的百分位数,兼顾性能的需求,故ES通常使用计算近似百分位数。近似百分位通常使用TDigest 算法。 在使用近似百分位时,通常需要考虑这些: 准确度与q(1-q)成正比。这意味着极端百分位数(如99%)比不那么极端的百分位数(如中位数)更准确 对于较小的值集,百分位数是非常准确的(如果数据足够小,可能是100%准确)。 当桶中值的数量增加时,算法开始近似百分位数。它有效地以准确性换取内存节省。准确的不准确程度很难一概而论,因为它取决于您的数据分布和聚合的数据量。 Compression近似算法必须平衡内存利用率和估计精度。这个平衡可以使用参数compression来控制。 TDigest算法使用许多“节点”来近似百分位数——可用节点越多,与数据量成比例的准确性(和大内存占用)就越高。压缩参数将节点的最大数量限制为20 * compression。因此,通过增加压缩值,可以以增加内存为代价来提高百分位数的准确性。较大的压缩值也会使算法变慢,因为底层树数据结构的大小会增加,从而导致更昂贵的操作。默认压缩值是100。一个“节点”使用大约32字节的内存,因此在最坏的情况下(大量数据按顺序到达),默认设置将产生大约64KB(32 20 100)大小的TDigest。实际上,数据往往更随机,TDigest使用的内存更少。 HDR Histogram(直方图) HDR直方图(High Dynamic Range Histogram,高动态范围直方图)是一种替代实现,在计算延迟度量的百分位数时非常有用,因为它比t-digest实现更快,但需要更大的内存占用。此实现维护一个固定的最坏情况百分比错误(指定为有效数字的数量)。这意味着如果数据记录值从1微秒到1小时(3600000000毫秒)直方图设置为3位有效数字,它将维持一个价值1微秒的分辨率值1毫秒,3.6秒(或更好的)最大跟踪值(1小时)。 GET latency/_search { "size": 0, "aggs" : { "load_time_outlier" : { "percentiles" : { "field" : "load_time", "percents" : [95, 99, 99.9], "hdr": { "number_of_significant_value_digits" : 3 } } } } } hdr 通过hdr属性指定直方图相关的参数。 number_of_significant_value_digits指定以有效位数为单位的直方图值的分辨率。 注意:hdr直方图只支持正值,如果传递负值,则会出错。如果值的范围是未知的,那么使用HDRHistogram也不是一个好主意,因为这可能会导致内存的大量使用。 Missing valuemissing参数定义了应该如何处理缺少值的文档。默认情况下,它们将被忽略,但也可以将它们视为具有一个值。 Percentiles Aggregation示例(Java Demo): public static void test_Percentiles_Aggregation() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("aggregations_index02"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); AggregationBuilder aggregationBuild = AggregationBuilders.percentiles("percentiles") .field("load_time") .percentiles(75,90,99.9) .compression(100) .method(PercentilesMethod.HDR) .numberOfSignificantValueDigits(3) ; sourceBuilder.aggregation(aggregationBuild); sourceBuilder.size(0); sourceBuilder.query( QueryBuilders.termQuery("sellerId", 24) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } Percentile Ranks Aggregation 百分位范围表示观察值低于某一值的百分比。例如,如果一个值大于或等于观察值的95%,那么它就属于第95百分位。假设您的数据包含网站加载时间。您可能有一个服务协议,95%的页面加载完全在500ms内完成,99%的页面加载完全在600ms内完成。 示例: GET latency/_search { "size": 0, "aggs" : { "load_time_ranks" : { // @1 "percentile_ranks" : { // @2 "field" : "load_time", // @3 "values" : [500, 600] // @4 } } } } 代码@1:聚合的名称。代码@2:聚合的类型,这里使用percentile_ranks。代码@3:用于聚合的字段。代码@5:设置观察值。 其他的使用与1.7 Percentiles Aggregation类似,就不单独给出JAVA示例了。 Stats Aggregation 返回的统计信息包括:min、max、sum、count和avg。其示例如下: POST /exams/_search?size=0 { "aggs" : { "grades_stats" : { "stats" : { "field" : "grade" } } } } 对应的返回结果为: { ... "aggregations": { "grades_stats": { "count": 2, "min": 50.0, "max": 100.0, "avg": 75.0, "sum": 150.0 } } } 因为与avg的使用类似,故JAVA示例就不重复给出。 Sum Aggregation 求和聚合。类似于关系型数据库的sum函数,其使用与avg类似,故只是简单罗列一下restful的使用方式: POST /sales/_search?size=0 { "query" : { "constant_score" : { "filter" : { "match" : { "type" : "hat" } } } }, "aggs" : { "hat_prices" : { "sum" : { "field" : "price" } } } } value count aggregation 值个数聚合,主要是统计一个字段有多少个不同的值,例如关系型数据库中一张用户表user中一个性别字段sex,其取值为0,1,2,那不管这个表有多少行数据,sex的value count最多为3。 示例如下: POST /sales/_search?size=0 { "aggs" : { "types_count" : { "value_count" : { "field" : "type" } } } } 其响应结果如下: { ... "aggregations": { "types_count": { "value": 7 } } } median absolute deviation aggregation 中位绝对偏差聚合。由于这部分内容与统计学关系密切,但这并不是我的特长,故对该统计的含义做深入解读,在实际场景中,我们只需要知道ES提供了中位数偏差统计的功能,如果有这方面的需求,我们知道如何使用ES的中位数统计即可。 官方场景:假设我们收集了商品评价数据(1星到5星之间的数值)。在实际使用过程中通常会使用平均值来展示商品的整体评价等级。中位绝对偏差聚合可以帮助我们了解评审之间的差异有多大。 在这个例子中,我们有一个平均评级为3星的产品。让我们看看它的评级的绝对偏差中值,以确定它们的变化有多大。按照我的理解,中位绝对偏差聚合 ,聚合的数据来源于(原始数据 - 所有原始数值的平均值 的绝对值进行聚合)。例如评论原始数据如下:1、2、5、5、4、3、5、5、5、5其平均值:4那中位数绝对偏差值聚合的数据为:3、2、1、1、0、1、1、1、1、1 其Restfull示例如下: GET reviews/_search { "size": 0, "aggs": { "review_average": { // @1 "avg": { "field": "rating" } }, "review_variability": { // @2 "median_absolute_deviation": { "field": "rating" } } } } 该聚合包含两部分。代码@1:针对字段rating使用AVG进行聚合(平均聚合,求出中位数)代码@2:针对字段rating进行中位数绝对偏差聚合。 备注:在es high rest api中未封装(median absolute deviation aggregation)聚合。 ES 关于 Metric聚合就介绍到这里了,接下来将重点分析Es Buket聚合。 原文发布时间为:2019-03-10本文作者:丁威,《RocketMQ技术内幕》作者。本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
本文有点长,看完可能需要点耐心,本文详细介绍了es三种分页方式、排序、from、size、source filter、dov values fields、post filter、高亮显示、rescoring、search type、scroll、preference、preference、explain、version、index boost、min_score、names query、Inner hits、field collapsing、Search After。 大家可以根据关键字,选择对应感兴趣的内容进行阅读。上述内容基本都给出了JAVA使用示例。 本节将详细介绍Elasticsearch Search API的查询主体,定制化查询条件的实现主体。 1、query 搜索请求体中查询条件使用Elasticsearch DSL查询语法来定义。通过使用query来定义查询体。 GET /_search { "query" : { "term" : { "user" : "kimchy" } } } 2、From / Size ElasticSearch的一种分页语法。通过使用from和size参数来对结果集进行分页。from设置第一条数据的偏移量。size设置本批数据返回的条数(针对每个分片生效),由于Elasticsearch天生就是分布式的,通过设置主分片个数来进行数据水平切分,一个查询请求通常需要从多个后台节点(分片)进行数据汇聚,故此方式会遇到分布式数据库一个通用的问题:深度分页。Elasticsearch提供了另外一种分页方式,滚动API(Scroll),后续会详细分析。注意:from + size 不能超过index.max_result_window配置项的值,其默认值为10000。 3、sort (排序) 与传统关系型数据库类似,elasticsearch支持根据一个或多个字段进行排序,同时支持asc升序或desc降序。另外Elasticsearch可以按照_score(基于得分)的排序,默认值。如果使用了排序,每个文档的排序值(字段为sort)也会作为响应的一部分返回。 3.1 排序顺序 Elasticsearch提供了两种排序顺序,SortOrder.ASC(asc)升序、SortOrder.DESC(desc)降序,如果排序类型为_score,其默认排序顺序为降序(desc), 如果排序类型为字段,则默认排序顺序为升序(asc)。 3.2 排序模型选型 Elasticsearch支持按数组或多值字段进行排序。模式选项控制选择的数组值,以便对它所属的文档进行排序。模式选项可以有以下值: min 使用数组中最小的值参与排序。 max 使用数组中最大的值参与排序。 sum 使用数组中的总和参与排序。 avg 使用数组中的平均值参与排序。 median 使用数组中的中位数参与排序。 其示例如下: PUT /my_index/_doc/1?refresh { "product": "chocolate", "price": [20, 4] } POST /_search { "query" : { "term" : { "product" : "chocolate" } }, "sort" : [ {"price" : {"order" : "asc", "mode" : "avg"}} // @1 ] } 如果是一个数组类型的值,参与排序,通常会对该数组元素进行一些计算得出一个最终参与排序的值,例如取平均数、最大值、最小值,求和等运算。es通过排序模型mode来指定。 3.3 嵌套字段排序 Elasticsearch还支持在一个或多个嵌套对象内部的字段进行排序。一个嵌套查询提包含如下选项(参数): path定义要排序的嵌套对象。排序字段必须是这个嵌套对象中的一个直接字段(非嵌套字段),并且排序字段必须存在。 filter定义过滤上下文,定义排序环境中的过滤上下文。 max_children排序是要考虑根文档下子属性文档的最大个数,默认为无限制。 nested排序体支持嵌套。 "sort" : [ { "parent.child.age" : { // @1 "mode" : "min", "order" : "asc", "nested": { // @2 "path": "parent", "filter": { "range": {"parent.age": {"gte": 21}} }, "nested": { // @3 "path": "parent.child", "filter": { "match": {"parent.child.name": "matt"} } } } } } ] 代码@1:排序字段名,支持级联表示字段名。代码@2:通过nested属性定义排序嵌套语法提,其中path指定当前的嵌套对象,filter定义过滤上下文,@3内部可以再通过nested属性再次嵌套定义。 3.4 missing values 由于es的索引,类型下的字段可以在索引文档时动态增加,那如果有些文档没有包含排序字段,这部分文档的顺序如何确定呢?es通过missing属性来确定,其可选值为: _last默认值,排在最后。 _first排在最前。 3.5 ignoring unmapped fields 默认情况下,如果排序字段为未映射的字段将抛出异常。可通过unmapped_type来忽略该异常,该参数指定一个类型,也就是告诉ES如果未找该字段名的映射,就认为该字段是一个unmapped_type指定的类型,所有文档都未存该字段的值。 3.6 Geo sorting 地图类型排序,该部分将在后续专题介绍geo类型时讲解。 4、字段过滤(_source与stored_fields) 默认情况下,对命中的结果会返回_source字段下的所有内容。字段过滤机制允许用户按需要返回_source字段里面部分字段。其过滤设置机制已在在《Elasticsearch Document Get API详解、原理与示例》中已详细介绍,在这里就不重复介绍了。 5、 Doc Value Fields 使用方式如下: GET /_search { "query" : { "match_all": {} }, "docvalue_fields" : [ { "field": "my_date_field", "format": "epoch_millis" } ] } 通过使用docvalue_fields指定需要转换的字段与格式,doc value fields对于在映射文件中定义stored=false的字段同样生效。字段支持用通配符,例如"field":"myfield*"。docvalue_fields中指定的字段并不会改变_souce字段中的值,而是使用fields返回值进行额外返回。 java实例代码片段如下(完整的Demo示例将在文末给出): SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query(QueryBuilders.termQuery("user", "dingw")) .sort(new FieldSortBuilder("post_date").order(SortOrder.DESC)) .docValueField("post_date", "epoch_millis") 其返回结果如下: { "took":88, "timed_out":false, "_shards":{ "total":5, "successful":5, "skipped":0, "failed":0 }, "hits":{ "total":2, "max_score":null, "hits":[ { "_index":"twitter", "_type":"_doc", "_id":"11", "_score":null, "_source":{ "post_date":"2009-11-19T14:12:12", "message":"test bulk update", "user":"dingw" }, "fields":{ "post_date":[ "1258639932000" ] }, "sort":[ 1258639932000 ] }, { "_index":"twitter", "_type":"_doc", "_id":"12", "_score":null, "_source":{ "post_date":"2009-11-18T14:12:12", "message":"test bulk", "user":"dingw" }, "fields":{ "post_date":[ "1258553532000" ] }, "sort":[ 1258553532000 ] } ] } } 6、Post Filter post filter对查询条件命中后的文档再做一次筛选。 GET /shirts/_search { "query": { "bool": { "filter": { "term": { "brand": "gucci" } // @1 } } }, "post_filter": { // @2 "term": { "color": "red" } } } 首先根据@1条件对索引进行检索,然后得到匹配的文档后,再利用@2过滤条件对结果再一次筛选。 7、Highlighting(查询结果高亮显示) 7.1 Es支持的高亮分析器 用于对查询结果中对查询关键字进行高亮显示,以指明查询条件在查询结果中匹配的部分处以另外的颜色突出显示。 注意:高亮显示器在提取要高亮显示的术语时不能反映查询的布尔逻辑。因此对于一些复杂的布尔查询(例如嵌套的布尔查询,或使用minimum_should_match等查询)可能高亮显示会出现一些误差。 高亮显示需要字段的实际内容。如果字段没有存储(映射没有将store设置为true),则从_source中提取相关字段。Elasticsearch支持三种高亮显示工具,通过为每个字段指定type来使用。 unified highlighter使用Lucene unified高亮显示器。首先将文本分解成句子并使用BM25算法对单个句子进行评分,就好像它们是语料库中的文档一样。支持精确的短语和多术语(模糊、前缀、正则表达式)高亮显示。这是es默认的高亮显示器。 plain highlighter使用standard Lucene highlighter(Lucene标准高亮显示器)。plain highlighter最适合单个字段的匹配高亮显示需求。为了准确地反映查询逻辑,它在内存中创建一个很小的索引,并通过Lucene的查询执行计划重新运行原来的查询条件,以便获取当前文档的更低级别的匹配信息。如果需要对多个字段进行高亮显示,建议还是使用unified highlighter或term_vector fields。 plain highlighter高亮方式是个实时分析处理高亮器。即用户在查询的时候,搜索引擎查询到了目标数据docid后,将需要高亮的字段数据提取到内存,再调用该字段的分析器进行处理,分析器对文本进行分析处理,分析完成后采用相似度算法计算得分最高的前n组并高亮段返回数据。假设用户搜索的都是比较大的文档同时需要进行高亮。按照一页查询40条(每条数据20k)的方式进行显示,即使相似度计算以及搜索排序不耗时,整个查询也会被高亮拖累到接近两秒。highlighter高亮器是实时分析高亮器,这种实时分析机制会让ES占用较少的IO资源同时也占用较少的存储空间(词库较全的话相比fvh方式能节省一半的存储空间),其实时计算高亮是采用cpu资源来缓解io压力,在高亮字段较短(比如高亮文章的标题)时候速度较快,同时因io访问的次数少,io压力较小,有利于提高系统吞吐量。 参考资料:https://blog.csdn.net/kjsoftware/article/details/76293204 fast vector highlighter使用lucene fast vector highlingter,基于词向量,该高亮处理器必须开启 term_vector=with_positions_offsets(存储词向量,即位置与偏移量)。 为解决大文本字段上高亮速度性能的问题,lucene高亮模块提供了基于向量的高亮方式 fast-vector-highlighter(也称为fvh)。fast-vector-highlighter(fvh)高亮显示器利用建索引时候保存好的词向量来直接计算高亮段落,在高亮过程中比plain高亮方式少了实时分析过程,取而代之的是直接从磁盘中将分词结果直接读取到内存中进行计算。故要使用fvh的前置条件就是在建索引时候,需要配置存储词向量,词向量需要包含词位置信息、词偏移量信息。 注意:fvh高亮器不支持span查询。如果您需要对span查询的支持,请尝试其他高亮显示,例如unified highlighter。 fvh在高亮时候的逻辑如下:1.分析高亮查询语法,提取表达式中的高亮词集合2.从磁盘上读取该文档字段下的词向量集合3.遍历词向量集合,提取自表达式中出现的词向量4.根据提取到目标词向量读取词频信息,根据词频获取每个位置信息、偏移量5.通过相似度算法获取得分较高的前n组高亮信息6.读取字段内容(多字段用空格隔开),根据提取的词向量直接定位截取高亮字段参考资料:https://blog.csdn.net/kjsoftware/article/details/76293204 7.2 Offsets Strategy 获取偏移量策略。高亮显示要解决的一个核心就是高亮显示的词根以及该词根的位置(位置与偏移量)。 ES中提供了3中获取偏移量信息(Offsets)的策略: The postings list如果将index_options设置为offsets,unified highlighter将使用该信息突出显示文档,而无需重新分析文本。它直接对索引重新运行原始查询,并从索引中提取匹配偏移量。如果字段很大,这一点很重要,因为它不需要重新分析需要高亮显示的文本。比term_vector方式占用更少的磁盘空间。 Term vectors如果在字段映射中将term_vector设置为with_positions_offset,unified highlighter将自动使用term_vector来高亮显示字段。它特别适用于大字段(> 1MB)和高亮显示多词根查询(如前缀或通配符),因为它可以访问每个文档的术语字典。fast vector highlighter高亮器必须将字段映射term_vector设置为with_positions_offset时才能生效。 Plain highlighting当没有其他选择时,统一使用这种模式。它在内存中创建一个很小的索引,并通过Lucene的查询执行计划重新运行原来的查询条件,以访问当前文档上的低级匹配信息。对于每个需要突出显示的字段和文档,都要重复此操作。Plain highlighting高亮显示器就是这种模式。 注意:对于大型文本,Plain highlighting显示器可能需要大量的时间消耗和内存。为了防止这种情况,在下一个Elasticsearch中,对要分析的文本字符的最大数量将限制在100万。6.x版本默认无限制,但是可以使用索引设置参数index.highlight.max_analyzed_offset为特定索引设置。 7.3 高亮显示配置项 高亮显示的全局配置会被字段级别的覆盖。 boundary_chars设置边界字符串集合,默认包含:.,!? tn boundary_max_scan扫描边界字符。默认为20 boundary_scanner指定如何分解高亮显示的片段,可选值为chars、sentence、word chars字符。使用由bordery_chars指定的字符作为高亮显示边界。通过boundary_max_scan控制扫描边界字符的距离。该扫描方式只适用于fast vector highlighter。 sentence句子,使用Java的BreakIterator确定的下一个句子边界处的突出显示片段。您可以使用boundary_scanner_locale指定要使用的区域设置。unified highlighter高亮器默认行为。 word单词,由Java的BreakIterator确定的下一个单词边界处高亮显示的片段。 boundary_scanner_locale区域设置。该参数采用语言标记的形式,例如。“en - us”、“- fr”、“ja-JP”。更多信息可以在Locale语言标记文档中找到。默认值是local . root。 encoder指示代码段是否应该编码为HTML:默认(无编码)或HTML (HTML-转义代码段文本,然后插入高亮标记)。 fields指定要检索高亮显示的字段,支持通配符。例如,您可以指定comment_*来获得以comment_开头的所有文本和关键字字段的高亮显示。 注意:当您使用通配符时,只会匹配text、keyword类型字段。 force_source是否强制从_source高亮显示,默认为false。其实默认情况就是根据源字段内容(_source)内容高亮显示,即使字段是单独存储的。 fragmenter指定如何在高亮显示代码片段中拆分文本:可选值为simple、span。仅适用于Plain highlighting。默认为span。 simple将文本分成大小相同的片段。 span将文本分割成大小相同的片段,但尽量避免在突出显示的术语之间分割文本。这在查询短语时很有用。 fragment_offset控制开始高亮显示的margin(空白),仅适用于fast vector highlighter。 fragment_size高亮显示的片段,默认100。 highlight_query高亮显示匹配搜索查询以外的查询。如果您使用rescore查询,这尤其有用,因为默认情况下高亮显示并不会考虑这些查询。通常,应该将搜索查询包含在highlight_query中。 matched_fields组合多个字段上的匹配项以突出显示单个字段。对于以不同方式分析相同字符串的多个字段,这是最直观的。所有matched_fields必须将term_vector设置为with_positions_offset,但是只加载匹配项组合到的字段,所以建议该字段store设置为true。只适用于fast vector highlighter荧光笔。 no_match_size如果没有要高亮显示的匹配片段,则希望从字段开头返回的文本数量。默认值为0(不返回任何内容)。 number_of_fragments返回的高亮显示片段的最大数量。如果片段的数量设置为0,则不返回片段。默认为5。 order该值默认为none,按照字段的顺序返回高亮文档,可以设置为score(按相关性排序)。 phrase_limit控制要考虑的文档中匹配短语的数量。防止fast vector highlighter分析太多的短语和消耗太多的内存。在使用matched_fields时,将考虑每个匹配字段的phrase_limit短语。提高限制会增加查询时间并消耗更多内存。只支持fast vector highlighter。默认为256。 pre_tags用于高亮显示HTML标签,与post_tags一起使用,默认用高亮显示文本。 post_tags用于高亮显示HTML标签,与pre_tags一起使用,默认用高亮显示文本。 require_field_match默认情况下,只有包含查询匹配的字段才会高亮显示。将require_field_match设置为false以突出显示所有字段。默认值为true。 tags_schema定义高亮显示样式,例如。 type指定高亮显示器,可选值:unified、plain、fvh。默认值为unified。 7.4 高亮显示demo public static void testSearch_highlighting() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("map_highlighting_01"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query( // QueryBuilders.matchAllQuery() QueryBuilders.termQuery("context", "身份证") ); HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("context"); sourceBuilder.highlighter(highlightBuilder); searchRequest.source(sourceBuilder); System.out.println(client.search(searchRequest, RequestOptions.DEFAULT)); } catch (Exception e) { // TODO: handle exception } } 其返回值如下: { "took":2, "timed_out":false, "_shards":{ "total":5, "successful":5, "skipped":0, "failed":0 }, "hits":{ "total":1, "max_score":0.2876821, "hits":[ { "_index":"map_highlighting_01", "_type":"_doc", "_id":"erYsbmcBeEynCj5VqVTI", "_score":0.2876821, "_source":{ "context":"城中西路可以受理外地二代身份证的办理。" }, "highlight":{ // @1 "context":[ "城中西路可以受理外地二代<em>身份证</em>的办理。" ] } } ] } } 这里主要对highlight再做一次说明,其中每一个字段返回的内容是对应原始数据的子集,最多fragmentSize个待关键字的匹配条目,通常,在页面上显示文本时,应该用该字段取代原始值,这样才能有高亮显示的效果。 8、Rescoring 重打分机制。一个查询首先使用高效的算法查找文档,然后对返回结果的top n 文档运用另外的查询算法,通常这些算法效率低效但能提供匹配精度。 resoring查询与原始查询分数的合计方式如下: total 两个评分相加 multiply 将原始分数乘以rescore查询分数。用于函数查询重定向。 avg取平均数 max取最大值 min取最小值。 9、Search Type 查询类型,可选值:QUERY_THEN_FETCH、QUERY_AND_FETCH、DFS_QUERY_THEN_FETCH。默认值:query_then_fetch。 QUERY_THEN_FETCH:首先根据路由算法向相关分片(多个)发送请求,此时只返回documentId与一些必要信息(例如用于排序等),然后对各个分片的结果进行汇聚,排序,然后选取客户端指定需要获取的数据条数(top n),然后根据documentId再向各个分片请求具体的文档信息。 QUERY_AND_FETCH:在5.4.x版本开始废弃,是直接向各个分片节点请求数据,每个分片返回客户端请求数量的文档信息,然后汇聚全部返回给客户端,返回的数据为客户端请求数量size * (路由后的分片数量)。 DFS_QUERY_THEN_FETCH:在开始向各个节点发送请求之前,会进行一次词频、相关性的计算,后续流程与QUERY_THEN_FETCH相同,可以看出,该查询类型的文档相关性会更高,但性能比QUERY_THEN_FETCH要差。 10、scroll 滚动查询。es另外一种分页方式。虽然搜索请求返回结果的单个“页面”,但scroll API可以用于从单个搜索请求检索大量结果(甚至所有结果),这与在传统数据库上使用游标的方式非常相似。scroll api不用于实时用户请求,而是用于处理大量数据,例如为了将一个索引的内容重新索引到具有不同配置的新索引中。 10.1 如何使用scroll API scroll API使用分为两步: 1、第一步,首先通过scroll参数,指定该滚动查询(类似于数据库的游标的存活时间) POST /twitter/_search?scroll=1m { "size": 100, "query": { "match" : { "title" : "elasticsearch" } } } 该方法会返回一个重要的参数:scrollId。 2、第二步,使用该scrollId去es服务器拉取下一批(下一页数据) POST /_search/scroll { "scroll" : "1m", "scroll_id" : "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ==" } 循环第三步,可以循环批量处理数据。 3、第三步,清除scrollId,类似于清除数据库游标,快速释放资源。 DELETE /_search/scroll { "scroll_id" : "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ==" } 下面给出scoll api的java版本示例程序: public static void testScoll() { RestHighLevelClient client = EsClient.getClient(); String scrollId = null; try { System.out.println("step 1 start "); // step 1 start SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("map_highlighting_01"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query( QueryBuilders.termQuery("context", "身份证") ); searchRequest.source(sourceBuilder); searchRequest.scroll(TimeValue.timeValueMinutes(1)); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); scrollId = result.getScrollId(); // step 1 end // step 2 start if(!StringUtils.isEmpty(scrollId)) { System.out.println("step 2 start "); SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId); scrollRequest.scroll(TimeValue.timeValueMinutes(1)); while(true) { //循环遍历 SearchResponse scollResponse = client.scroll(scrollRequest, RequestOptions.DEFAULT); if(scollResponse.getHits().getHits() == null || scollResponse.getHits().getHits().length < 1) { break; } scrollId = scollResponse.getScrollId(); // 处理文档 scrollRequest.scrollId(scrollId); } // step 2 end } System.out.println(result); } catch (Exception e) { e.printStackTrace(); } finally { if(!StringUtils.isEmpty(scrollId)) { System.out.println("step 3 start "); // step 3 start ClearScrollRequest clearScrollRequest = new ClearScrollRequest(); clearScrollRequest.addScrollId(scrollId); try { client.clearScroll(clearScrollRequest, RequestOptions.DEFAULT); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } // step 3 end } } } 这里重点阐述一下第一次查询时,不仅返回scrollId,也会返回第一批数据。 10.2 Keeping the search context alive scroll参数(传递给搜索请求和每个滚动请求)告诉Elasticsearch它应该保持搜索上下文活动多长时间。它的值(例如1m,参见Time unitsedit)不需要足够长的时间来处理所有数据——它只需要足够长的时间来处理前一批结果。每个scroll请求(带有scroll参数)设置一个新的过期时间。如果scroll请求没有传入scroll,那么搜索上下文将作为scroll请求的一部分被释放。scroll其内部实现类似于快照,当第一次收到一个scroll请求时,就会为该搜索上下文所匹配的结果创建一个快照,随后文档的变化并不会反映到该API的结果。 10.3 sliced scroll 对于返回大量文档的scroll查询,可以将滚动分割为多个可以独立使用的片,通过slice指定。 例如: GET /twitter/_search?scroll=1m // @1 { "slice": { // @11 "id": 0, // @12 "max": 2 // @13 }, "query": { "match" : { "title" : "elasticsearch" } } } GET /twitter/_search?scroll=1m // @2 { "slice": { "id": 1, "max": 2 }, "query": { "match" : { "title" : "elasticsearch" } } } @1,@2两个并列的查询,按分片去查询。@11:通过slice定义分片查询。@12:该分片查询的ID。@13:本次查询总片数。 这个机制非常适合多线程处理数据。 具体分片机制是,首先将请求转发到各分片节点,然后在每个节点使用匹配到的文档(hashcode(_uid)%slice片数),然后各分片节点返回数据到协调节点。也就是默认情况下,分片是根据文档的_uid,为了提高分片过程,可以通过如下方式进行优化,并指定分片字段。 分片字段类型为数值型。 字段的doc_values设置为true。 每个文档中都索引了该字段。 该字段值只在创建时赋值,并不会更新。 字段的基数应该很高(相当于数据库索引选择度),这样能确保每个片返回的数据相当,数据分布较均匀。 注意,默认slice片数最大为1024,可以通过索引设置项index.max_slices_per_scroll来改变默认值。 例如: GET /twitter/_search?scroll=1m { "slice": { "field": "date", "id": 0, "max": 10 }, "query": { "match" : { "title" : "elasticsearch" } } } 11、preference 查询选择副本分片的倾向性(即在一个复制组中选择副本的分片值。默认情况下,Elasticsearch以未指定的顺序从可用的碎片副本中进行选择,副本之间的路由将在集群章节更加详细的介绍 。可以通过该字段指定分片倾向与选择哪个副本。 preference可选值: _primary只在节点上执行,在6.1.0版本后废弃,将在7.x版本移除。 _primary_first优先在主节点上执行。在6.1.0版本后废弃,将在7.x版本移除。 _replica操作只在副本分片上执行,如果有多个副本,其顺序随机。在6.1.0版本后废弃,将在7.x版本移除。 _replica_first优先在副本分片上执行,如果有多个副本,其顺序随机。在6.1.0版本后废弃,将在7.x版本移除。 _only_local操作将只在分配给本地节点的分片上执行。_only_local选项保证只在本地节点上使用碎片副本,这对于故障排除有时很有用。所有其他选项不能完全保证在搜索中使用任何特定的碎片副本,而且在索引更改时,这可能意味着如果在处于不同刷新状态的不同碎片副本上执行重复搜索,则可能产生不同的结果。 _local优先在本地分片上执行。 _prefer_nodes:abc,xyz优先在指定节点ID的分片上执行,示例中的节点ID为abc、xyz。 _shards:2,3将操作限制到指定的分片上执行。(这里是2和3)这个首选项可以与其他首选项组合,但必须首先出现:_shards:2,3|_local。 _only_nodes:abc,xyz,...根据节点ID进行限制。 Custom (string) value自定义字符串,其路由为 hashcode(该值)%赋值组内节点数。例如在web应用中通常以sessionId为倾向值。 12、explain 是否解释各分数是如何计算的。 GET /_search { "explain": true, "query" : { "term" : { "user" : "kimchy" } } } 13、version 如果设置为true,则返回每个命中文档的当前版本号。 GET /_search { "version": true, "query" : { "term" : { "user" : "kimchy" } } } 14、Index Boost 当搜索多个索引时,允许为每个索引配置不同的boost级别。当来自一个索引的点击率比来自另一个索引的点击率更重要时,该属性则非常方便。 使用示例如下: GET /_search { "indices_boost" : [ { "alias1" : 1.4 }, { "index*" : 1.3 } ] } 15、min_score 指定返回文档的最小评分,如果文档的评分低于该值,则不返回。 GET /_search { "min_score": 0.5, "query" : { "term" : { "user" : "kimchy" } } } 16、Named Queries 每个过滤器和查询都可以在其顶级定义中接受_name。搜索响应中每个匹配文档中会增加matched_queries结构体,记录该文档匹配的查询名称。查询和筛选器的标记只对bool查询有意义。 java示例如下: public static void testNamesQuery() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("esdemo"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query( QueryBuilders.boolQuery() .should(QueryBuilders.termQuery("context", "fox").queryName("q1")) .should(QueryBuilders.termQuery("context", "brown").queryName("q2")) ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 返回结果如下: { "took":4, "timed_out":false, "_shards":{ "total":5, "successful":5, "skipped":0, "failed":0 }, "hits":{ "total":2, "max_score":0.5753642, "hits":[ { "_index":"esdemo", "_type":"matchquerydemo", "_id":"2", "_score":0.5753642, "_source":{ "context":"My quick brown as fox eats rabbits on a regular basis.", "title":"Keeping pets healthy" }, "matched_queries":[ "q1", "q2" ] }, { "_index":"esdemo", "_type":"matchquerydemo", "_id":"1", "_score":0.39556286, "_source":{ "context":"Brown rabbits are commonly seen brown.", "title":"Quick brown rabbits" }, "matched_queries":[ "q2" ] } ] } } 正如上面所说,每个匹配文档中都包含matched_queries,表明该文档匹配的是哪个查询条件。 17、Inner hits 用于定义内部嵌套层的返回规则,其inner hits支持如下选项: from 用于内部匹配的分页。 size 用于内部匹配的分页,size。 sort 排序策略。 name 为内部嵌套层定义的名称。 该部分示例将在下节重点阐述。 18、field collapsing(字段折叠) 允许根据字段值折叠搜索结果。折叠是通过在每个折叠键上只选择排序最高的文档来完成的。有点类似于聚合分组,其效果类似于按字段进行分组,默认命中的文档列表第一层由该字段的第一条信息,也可以通过允许根据字段值折叠搜索结果。折叠是通过在每个折叠键上只选择排序最高的文档来完成的。例如,下面的查询为每个用户检索最佳tweet,并按喜欢的数量对它们进行排序。 下面首先通过示例进行展示field collapsing的使用。 1)首先查询发的推特内容中包含elasticsearch的推文: GET /twitter/_search { "query": { "match": { "message": "elasticsearch" } }, "collapse" : { "field" : "user" }, "sort": ["likes"] } 返回结果: { "took":8, "timed_out":false, "_shards":{ "total":5, "successful":5, "skipped":0, "failed":0 }, "hits":{ "total":5, "max_score":null, "hits":[ { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"OYnecmcB-IBeb8B-bF2X", "_score":null, "_source":{ "message":"to be a elasticsearch", "user":"user2", "likes":3 }, "sort":[ 3 ] }, { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"OonecmcB-IBeb8B-bF2q", "_score":null, "_source":{ "message":"to be elasticsearch", "user":"user2", "likes":3 }, "sort":[ 3 ] }, { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"OInecmcB-IBeb8B-bF2G", "_score":null, "_source":{ "message":"elasticsearch is very high", "user":"user1", "likes":3 }, "sort":[ 3 ] }, { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"O4njcmcB-IBeb8B-Rl2H", "_score":null, "_source":{ "message":"elasticsearch is high db", "user":"user1", "likes":1 }, "sort":[ 1 ] }, { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"N4necmcB-IBeb8B-bF0n", "_score":null, "_source":{ "message":"very likes elasticsearch", "user":"user1", "likes":1 }, "sort":[ 1 ] } ] } } 首先上述会列出所有用户的推特,如果只想每个用户只显示一条推文,并且点赞率最高,或者每个用户只显示2条推文呢?这个时候,按字段折叠就闪亮登场了。java demo如下: public static void search_field_collapsing() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("mapping_field_collapsing_twitter"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query( QueryBuilders.matchQuery("message","elasticsearch") ); sourceBuilder.sort("likes", SortOrder.DESC); CollapseBuilder collapseBuilder = new CollapseBuilder("user"); sourceBuilder.collapse(collapseBuilder); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 其结果如下: { "took":22, "timed_out":false, "_shards":{ "total":5, "successful":5, "skipped":0, "failed":0 }, "hits":{ "total":5, "max_score":null, "hits":[ { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"OYnecmcB-IBeb8B-bF2X", "_score":null, "_source":{ "message":"to be a elasticsearch", "user":"user2", "likes":3 }, "fields":{ "user":[ "user2" ] }, "sort":[ 3 ] }, { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"OInecmcB-IBeb8B-bF2G", "_score":null, "_source":{ "message":"elasticsearch is very high", "user":"user1", "likes":3 }, "fields":{ "user":[ "user1" ] }, "sort":[ 3 ] } ] } } 上面的示例只返回了每个用户的第一条数据,如果需要每个用户返回2条数据呢?可以通过inner_hit来设置。 public static void search_field_collapsing() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("mapping_field_collapsing_twitter"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query( QueryBuilders.matchQuery("message","elasticsearch") ); sourceBuilder.sort("likes", SortOrder.DESC); CollapseBuilder collapseBuilder = new CollapseBuilder("user"); InnerHitBuilder collapseHitBuilder = new InnerHitBuilder("collapse_inner_hit"); collapseHitBuilder.setSize(2); collapseBuilder.setInnerHits(collapseHitBuilder); sourceBuilder.collapse(collapseBuilder); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 返回结果如下: { "took":42, "timed_out":false, "_shards":{ "total":5, "successful":5, "skipped":0, "failed":0 }, "hits":{ "total":5, "max_score":null, "hits":[ { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"OYnecmcB-IBeb8B-bF2X", "_score":null, "_source":{ "message":"to be a elasticsearch", "user":"user2", "likes":3 }, "fields":{ "user":[ "user2" ] }, "sort":[ 3 ], "inner_hits":{ "collapse_inner_hit":{ "hits":{ "total":2, "max_score":0.19363807, "hits":[ { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"OonecmcB-IBeb8B-bF2q", "_score":0.19363807, "_source":{ "message":"to be elasticsearch", "user":"user2", "likes":3 } }, { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"OYnecmcB-IBeb8B-bF2X", "_score":0.17225473, "_source":{ "message":"to be a elasticsearch", "user":"user2", "likes":3 } } ] } } } }, { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"OInecmcB-IBeb8B-bF2G", "_score":null, "_source":{ "message":"elasticsearch is very high", "user":"user1", "likes":3 }, "fields":{ "user":[ "user1" ] }, "sort":[ 3 ], "inner_hits":{ "collapse_inner_hit":{ "hits":{ "total":3, "max_score":0.2876821, "hits":[ { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"O4njcmcB-IBeb8B-Rl2H", "_score":0.2876821, "_source":{ "message":"elasticsearch is high db", "user":"user1", "likes":1 } }, { "_index":"mapping_field_collapsing_twitter", "_type":"_doc", "_id":"N4necmcB-IBeb8B-bF0n", "_score":0.2876821, "_source":{ "message":"very likes elasticsearch", "user":"user1", "likes":1 } } ] } } } } ] } } 此时,返回结果是两级,第一级,还是每个用户第一条消息,然后再内部中嵌套inner_hits。 19、Search After Elasticsearch支持的第三种分页获取方式,该方法不支持跳转页面。 ElasticSearch支持的分页方式目前已知:1、通过from和size,当时当达到深度分页时,成本变的非常高昂,故es提供了索引参数:index.max_result_window来控制(from + size)的最大值,默认为10000,超过该值后将报错。2、通过scroll滚动API,该方式类似于快照的工作方式,不具备实时性,并且滚动上下文的存储需要耗费一定的性能。本节将介绍第3种分页方式,search after,基于上一页查询的结果进行下一页数据的查询。其 基本思想是选择一组字段(排序字段,能做到全局唯一),es的排序查询响应结果中会返回sort数组,包含本排序字段的最大值,下一页查询将该组字段当成查询条件,es在此数据的基础下返回下一批合适的数据。 java示例如下: public static void search_search_after() { RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("mapping_search_after"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query( QueryBuilders.termQuery("user","user2") ); sourceBuilder.size(1); sourceBuilder.sort("id", SortOrder.ASC); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); if(hasHit(result)) { // 如果本次匹配到数据 // 省略处理数据逻辑 // 继续下一批查询 // result.getHits(). int length = result.getHits().getHits().length; SearchHit aLastHit = result.getHits().getHits()[length - 1]; //开始下一轮查询 sourceBuilder.searchAfter(aLastHit.getSortValues()); result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } private static boolean hasHit(SearchResponse result) { return !( result.getHits() == null || result.getHits().getHits() == null || result.getHits().getHits().length < 1 ); } 本文详细介绍了es三种分页方式、排序、from、size、source filter、dov values fields、post filter、高亮显示、rescoring、search type、scroll、preference、preference、explain、version、index boost、min_score、names query、Inner hits、field collapsing、Search After。 原文发布时间为:2019-03-12本文作者:丁威本文来自中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
1、现象 最近收到很多RocketMQ使用者,反馈生产环境中在消息发送过程中偶尔会出现如下4个错误信息之一:1)[REJECTREQUEST]system busy, start flow control for a while2)too many requests and system thread pool busy, RejectedExecutionException3)[PC_SYNCHRONIZED]broker busy, start flow control for a while4)[PCBUSY_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: %sms, size of queue: %d 2、原理解读 在进行消息中间件的选型时,如果待选中间件在功能上、性能上都能满足业务的情况下,建议把中间件的实现语言这个因素也考虑进去,毕竟选择一门用自己擅长的语言实现的中间件会更具掌控性。在出现异常的情况下,我们可以根据自己的经验提取错误信息关键字system busy,在RocketMQ源码中直接搜索,得到抛出上述错误信息的代码如下: 其代码入口为:org.apache.rocketmq.remoting.netty.NettyRemotingAbstract#processRequestCommand。从图中可以看出,抛出上述错误的关键原因是:pair.getObject1().rejectRequest()和抛出RejectedExecutionException异常。 备注:本文偏实战,源码只是作为分析的重点证据,故本文只会点出关键源码,并不会详细跟踪其整个实现流程,如果想详细了解其实现,可以查阅笔者编著的《RocketMQ技术内幕》。 2.1 RocketMQ 网络处理机制概述 RocketMQ的网络设计非常值得我们学习与借鉴,首先在客户端端将不同的请求定义不同的请求命令CODE,服务端会将客户端请求进行分类,每个命令或每类请求命令定义一个处理器(NettyRequestProcessor),然后每一个NettyRequestProcessor绑定到一个单独的线程池,进行命令处理,不同类型的请求将使用不同的线程池进行处理,实现线程隔离。 为了方便下文的描述,我们先简单的认识一下NettyRequestProcessor、Pair、RequestCode。其核心关键点如下: NettyRequestProcessorRocketMQ 服务端请求处理器,例如SendMessageProcessor是消息发送处理器、PullMessageProcessor是消息拉取命令处理器。 RequestCode请求CODE,用来区分请求的类型,例如SEND_MESSAGE:表示该请求为消息发送,PULL_MESSAGE:消息拉取请求。 Pair用来封装NettyRequestProcessor与ExecuteService的绑定关系。在RocketMQ的网络处理模型中,会为每一个NettyRequestProcessor与特定的线程池绑定,所有该NettyRequestProcessor的处理逻辑都在该线程池中运行。 2.2 pair.getObject1().rejectRequest() 由于读者朋友提出的问题,都是发生在消息发送过程中,故本文重点关注SendMessageProcessor#rejectRequest方法。 SendMessageProcessor#rejectRequest public boolean rejectRequest() { return this.brokerController.getMessageStore().isOSPageCacheBusy() || // @1 this.brokerController.getMessageStore().isTransientStorePoolDeficient(); // @2 } 拒绝请求的条件有两个,只要其中任意一个满足,则返回true。 代码@1:Os PageCache busy,判断操作系统PageCache是否繁忙,如果忙,则返回true。想必看到这里大家肯定与我一样好奇,RocketMQ是如何判断pageCache是否繁忙呢?下面会重点分析。 代码@2:transientStorePool是否不足。 2.2.1 isOSPageCacheBusy() DefaultMessageStore#isOSPageCacheBusy() public boolean isOSPageCacheBusy() { long begin = this.getCommitLog().getBeginTimeInLock(); // @1 start long diff = this.systemClock.now() - begin; // @1 end return diff < 10000000 && diff > this.messageStoreConfig.getOsPageCacheBusyTimeOutMills(); // @2 } 代码@1:先重点解释begin、diff两个局部变量的含义: begin通俗的一点讲,就是将消息写入Commitlog文件所持有锁的时间,精确说是将消息体追加到内存映射文件(DirectByteBuffer)或pageCache(FileChannel#map)该过程中开始持有锁的时间戳,具体的代码请参考:CommitLog#putMessage。 diff一次消息追加过程中持有锁的总时长,即往内存映射文件或pageCache追加一条消息所耗时间。 代码@2:如果一次消息追加过程的时间超过了Broker配置文件osPageCacheBusyTimeOutMills,则认为pageCache繁忙,osPageCacheBusyTimeOutMills默认值为1000,表示1s。 2.2.2 isTransientStorePoolDeficient() DefaultMessageStore#isTransientStorePoolDeficient public boolean isTransientStorePoolDeficient() { return remainTransientStoreBufferNumbs() == 0; } public int remainTransientStoreBufferNumbs() { return this.transientStorePool.remainBufferNumbs(); } 最终调用TransientStorePool#remainBufferNumbs方法。 public int remainBufferNumbs() { if (storeConfig.isTransientStorePoolEnable()) { return availableBuffers.size(); } return Integer.MAX_VALUE; } 如果启用transientStorePoolEnable机制,返回当前可用的ByteBuffer个数,即整个isTransientStorePoolDeficient方法的用意是是否还存在可用的ByteBuffer,如果不存在,即表示pageCache繁忙。那什么是transientStorePoolEnable机制呢? 2.3 漫谈transientStorePoolEnable机制 Java NIO的内存映射机制,提供了将文件系统中的文件映射到内存机制,实现对文件的操作转换对内存地址的操作,极大的提高了IO特性,但这部分内存并不是常驻内存,可以被置换到交换内存(虚拟内存),RocketMQ为了提高消息发送的性能,引入了内存锁定机制,即将最近需要操作的commitlog文件映射到内存,并提供内存锁定功能,确保这些文件始终存在内存中,该机制的控制参数就是transientStorePoolEnable。 2.3.1 MappedFile 重点关注MappedFile的ByteBuffer writeBuffer、MappedByteBuffer mappedByteBuffer这两个属性的初始化,因为这两个方法是写消息与查消息操作的直接数据结构。 两个关键点如下: ByteBuffer writeBuffer如果开启了transientStorePoolEnable,则使用ByteBuffer.allocateDirect(fileSize),创建(java.nio的内存映射机制)。如果未开启,则为空。 MappedByteBuffer mappedByteBuffer使用FileChannel#map方法创建,即真正意义上的PageCache。 消息写入时:MappedFile#appendMessagesInner 从中可见,在消息写入时,如果writerBuffer不为空,说明开启了transientStorePoolEnable机制,则消息首先写入writerBuffer中,如果其为空,则写入mappedByteBuffer中。 消息拉取(读消息):MappedFile#selectMappedBuffer 消息读取时,是从mappedByteBuffer中读(pageCache)。 大家是不是发现了一个有趣的点,如果开启transientStorePoolEnable机制,是不是有了读写分离的效果,先写入writerBuffer中,读却是从mappedByteBuffer中读取。 为了对transientStorePoolEnable引入意图阐述的更加明白,这里我引入Rocketmq社区贡献者胡宗棠关于此问题的见解。 通常有如下两种方式进行读写: 第一种,Mmap+PageCache的方式,读写消息都走的是pageCache,这样子读写都在pagecache里面不可避免会有锁的问题,在并发的读写操作情况下,会出现缺页中断降低,内存加锁,污染页的回写。 第二种,DirectByteBuffer(堆外内存)+PageCache的两层架构方式,这样子可以实现读写消息分离,写入消息时候写到的是DirectByteBuffer——堆外内存中,读消息走的是PageCache(对于,DirectByteBuffer是两步刷盘,一步是刷到PageCache,还有一步是刷到磁盘文件中),带来的好处就是,避免了内存操作的很多容易堵的地方,降低了时延,比如说缺页中断降低,内存加锁,污染页的回写。 温馨提示:如果想与胡宗棠大神进一步沟通交流,可以关注他的github账号:https://github.com/zongtanghu 不知道大家会不会有另外一个担忧,如果开启了transientStorePoolEnable,内存锁定机制,那是不是随着commitlog文件的不断增加,最终导致内存溢出? 2.3.2 TransientStorePool初始化 从这里可以看出,TransientStorePool默认会初始化5个DirectByteBuffer(对外内存),并提供内存锁定功能,即这部分内存不会被置换,可以通过transientStorePoolSize参数控制。 在消息写入消息时,首先从池子中获取一个DirectByteBuffer进行消息的追加。当5个DirectByteBuffer全部写满消息后,该如何处理呢?从RocketMQ的设计中来看,同一时间,只会对一个commitlog文件进行顺序写,写完一个后,继续创建一个新的commitlog文件。故TransientStorePool的设计思想是循环利用这5个DirectByteBuffer,只需要写入到DirectByteBuffer的内容被提交到PageCache后,即可重复利用。对应的代码如下: TransientStorePool#returnBuffer public void returnBuffer(ByteBuffer byteBuffer) { byteBuffer.position(0); byteBuffer.limit(fileSize); this.availableBuffers.offerFirst(byteBuffer); } 其调用栈如下: 从上面的分析看来,并不会随着消息的不断写入而导致内存溢出。 3、现象解答 3.1 [REJECTREQUEST]system busy 其抛出的源码入口点:NettyRemotingAbstract#processRequestCommand,上面的原理分析部分已经详细介绍其实现原理,总结如下。 在不开启transientStorePoolEnable机制时,如果Broker PageCache繁忙时则抛出上述错误,判断PageCache繁忙的依据就是向PageCache追加消息时,如果持有锁的时间超过1s,则会抛出该错误;在开启transientStorePoolEnable机制时,其判断依据是如果TransientStorePool中不存在可用的堆外内存时抛出该错误。 3.2 too many requests and system thread pool busy, RejectedExecutionException 其抛出的源码入口点:NettyRemotingAbstract#processRequestCommand,其调用地方紧跟3.1,是在向线程池执行任务时,被线程池拒绝执行时抛出的,我们可以顺便看看Broker消息处理发送的线程信息: BrokerController#registerProcessor 该线程池的队列长度默认为10000,我们可以通过sendThreadPoolQueueCapacity来改变默认值。 3.3 [PC_SYNCHRONIZED]broker busy 其抛出的源码入口点:DefaultMessageStore#putMessage,在进行消息追加时,再一次判断PageCache是否繁忙,如果繁忙,则抛出上述错误。 3.4 broker busy, period in queue: %sms, size of queue: %d 其抛出源码的入口点:BrokerFastFailure#cleanExpiredRequest。该方法的调用频率为每隔10s中执行一次,不过有一个执行前提条件就是Broker端要开启快速失败,默认为开启,可以通过参数brokerFastFailureEnable来设置。该方法的实现要点是每隔10s,检测一次,如果检测到PageCache繁忙,并且发送队列中还有排队的任务,则直接不再等待,直接抛出系统繁忙错误,使正在排队的线程快速失败,结束等待。 4、实践建议 经过上面的原理讲解与现象分析,消息发送时抛出system busy、broker busy的原因都是PageCache繁忙,那是不是可以通过调整上述提到的某些参数来避免抛出错误呢?.例如如下参数: osPageCacheBusyTimeOutMills设置PageCache系统超时的时间,默认为1000,表示1s,那是不是可以把增加这个值,例如设置为2000或3000。作者观点:非常不可取。 sendThreadPoolQueueCapacityBroker服务器处理的排队队列,默认为10000,如果队列中积压了10000个请求,则会抛出RejectExecutionException。作者观点:不可取。 brokerFastFailureEnable是否启用快速失败,默认为true,表示当如果发现Broker服务器的PageCache繁忙,如果发现sendThreadPoolQueue队列中不为空,表示还有排队的发送请求在排队等待执行,则直接结束等待,返回broker busy。那如果不开启快速失败,则同样可以避免抛出这个错误。作者观点:非常不可取。 修改上述参数,都不可取,原因是出现system busy、broker busy这个错误,其本质是系统的PageCache繁忙,通俗一点讲就是向PageCache追加消息时,单个消息发送占用的时间超过1s了,如果继续往该Broker服务器发送消息并等待,其TPS根本无法满足,哪还是高性能的消息中间了呀。故才会采用快速失败机制,直接给消息发送者返回错误,消息发送者默认情况会重试2次,将消息发往其他Broker,保证其高可用。 下面根据个人的见解,提出如下解决办法: 4.1 开启transientStorePoolEnable 在broker.config中将transientStorePoolEnable=true。 方案依据:启用“读写”分离,消息发送时消息先追加到DirectByteBuffer(堆外内存)中,然后在异步刷盘机制下,会将DirectByteBuffer中的内容提交到PageCache,然后刷写到磁盘。消息拉取时,直接从PageCache中拉取,实现了读写分离,减轻了PageCaceh的压力,能从根本上解决该问题。 方案缺点:会增加数据丢失的可能性,如果Broker JVM进程异常退出,提交到PageCache中的消息是不会丢失的,但存在堆外内存(DirectByteBuffer)中但还未提交到PageCache中的这部分消息,将会丢失。但通常情况下,RocketMQ进程退出的可能性不大。 4.2 扩容Broker服务器 方案依据: 当Broker服务器自身比较忙的时候,快速失败,并且在接下来的一段时间内会规避该Broker,这样该Broker恢复提供了时间保证,Broker本身的架构是支持分布式水平扩容的,增加Topic的队列数,降低单台Broker服务器的负载,从而避免出现PageCache。 温馨提示:在Broker扩容时候,可以复制集群中任意一台Broker服务下${ROCKETMQ_HOME}/store/config/topics.json到新Broker服务器指定目录,避免在新Broker服务器上为Broker创建队列,然后消息发送者、消息消费者都能动态获取Topic的路由信息。 与之扩容对应的,也可以通过对原有Broker进行升配,例如增加内存、把机械盘换成SSD,但这种情况,通常需要重启Broekr服务器,没有扩容来的方便。 本文就介绍到这里了,如果大家觉得文章对自己有用的话,麻烦帮忙点赞、转发,谢谢。亲爱的读者朋友,还有更好的方案没?欢迎留言与作者互动,共同探讨。 原文发布时间为:2019-06-18本文作者:丁威本文来自云栖社区合作伙伴中间件兴趣圈,了解相关信息可以关注中间件兴趣圈。
1、现象 很多网友会问,为什么明明集群中有多台Broker服务器,autoCreateTopicEnable设置为true,表示开启Topic自动创建,但新创建的Topic的路由信息只包含在其中一台Broker服务器上,这是为什么呢? 期望值:为了消息发送的高可用,希望新创建的Topic在集群中的每台Broker上创建对应的队列,避免Broker的单节点故障。 现象截图如下: 正如上图所示,自动创建的topicTest5的路由信息: topicTest5只在broker-a服务器上创建了队列,并没有在broker-b服务器创建队列,不符合期望。 默认读写队列的个数为4。 我们再来看一下RocketMQ默认topic的路由信息截图如下: 从图中可以默认Topic的路由信息为broker-a、broker-b上各8个队列。 2、思考 默认Topic的路由信息是如何创建的? Topic的路由信息是存储在哪里?Nameserver?broker? RocketMQ Topic默认队列个数是多少呢? 3、原理 3.1 RocketMQ基本路由规则 Broker在启动时向Nameserver注册存储在该服务器上的路由信息,并每隔30s向Nameserver发送心跳包,并更新路由信息。 Nameserver每隔10s扫描路由表,如果检测到Broker服务宕机,则移除对应的路由信息。 消息生产者每隔30s会从Nameserver重新拉取Topic的路由信息并更新本地路由表;在消息发送之前,如果本地路由表中不存在对应主题的路由消息时,会主动向Nameserver拉取该主题的消息。 回到本文的主题:autoCreateTopicEnable,开启自动创建主题,试想一下,如果生产者向一个不存在的主题发送消息时,上面的任何一个步骤都无法获取一个不存在的主题的路由信息,那该如何处理这种情况呢? 在RocketMQ中,如果autoCreateTopicEnable设置为true,消息发送者向NameServer查询主题的路由消息返回空时,会尝试用一个系统默认的主题名称(MixAll.AUTO_CREATE_TOPIC_KEY_TOPIC),此时消息发送者得到的路由信息为: 但问题就来了,默认Topic在集群的每一台Broker上创建8个队列,那问题来了,为啥新创建的Topic只在一个Broker上创建4个队列? 3.2 探究autoCreateTopicEnable机制 3.2.1 默认Topic路由创建时机 温馨提示:本文不会详细跟踪整个创建过程,只会点出源码的关键入口点,如想详细了解NameServer路由消息、消息发送高可用的实现原理,建议查阅笔者的书籍《RocketMQ技术内幕》第二、三章。 Step1:在Broker启动流程中,会构建TopicConfigManager对象,其构造方法中首先会判断是否开启了允许自动创建主题,如果启用了自动创建主题,则向topicConfigTable中添加默认主题的路由信息。 TopicConfigManager构造方法 备注:该topicConfigTable中所有的路由信息,会随着Broker向Nameserver发送心跳包中,Nameserver收到这些信息后,更新对应Topic的路由信息表。 BrokerConfig的defaultTopicQueueNum默认为8。两台Broker服务器都会运行上面的过程,故最终Nameserver中关于默认主题的路由信息中,会包含两个Broker分别各8个队列信息。 Step2:生产者寻找路由信息生产者首先向NameServer查询路由信息,由于是一个不存在的主题,故此时返回的路由信息为空,RocketMQ会使用默认的主题再次寻找,由于开启了自动创建路由信息,NameServer会向生产者返回默认主题的路由信息。然后从返回的路由信息中选择一个队列(默认轮询)。消息发送者从Nameserver获取到默认的Topic的队列信息后,队列的个数会改变吗?答案是会的,其代码如下: MQClientInstance#updateTopicRouteInfoFromNameServer 温馨提示:消息发送者在到默认路由信息时,其队列数量,会选择DefaultMQProducer#defaultTopicQueueNums与Nameserver返回的的队列数取最小值,DefaultMQProducer#defaultTopicQueueNums默认值为4,故自动创建的主题,其队列数量默认为4。 Step3:发送消息 DefaultMQProducerImpl#sendKernelImpl 在消息发送时的请求报文中,设置默认topic名称,消息发送topic名称,使用的队列数量为DefaultMQProducer#defaultTopicQueueNums,即默认为4。 Step4:Broker端收到消息后的处理流程服务端收到消息发送的处理器为:SendMessageProcessor,在处理消息发送时,会调用super.msgCheck方法: AbstractSendMessageProcessor#msgCheck在Broker端,首先会使用TopicConfigManager根据topic查询路由信息,如果Broker端不存在该主题的路由配置(路由信息),此时如果Broker中存在默认主题的路由配置信息,则根据消息发送请求中的队列数量,在Broker创建新Topic的路由信息。这样Broker服务端就会存在主题的路由信息。 在Broker端的topic配置管理器中存在的路由信息,一会向Nameserver发送心跳包,汇报到Nameserver,另一方面会有一个定时任务,定时存储在broker端,具体路径为${ROCKET_HOME}/store/config/topics.json中,这样在Broker关闭后再重启,并不会丢失路由信息。 广大读者朋友,跟踪到这一步的时候,大家应该对启用自动创建主题机制时,新主题是的路由信息是如何创建的,为了方便理解,给出创建主题序列图: 3.2.2 现象分析 经过上面自动创建路由机制的创建流程,我们可以比较容易的分析得出如下结论: 因为开启了自动创建路由信息,消息发送者根据Topic去NameServer无法得到路由信息,但接下来根据默认Topic从NameServer是能拿到路由信息(在每个Broker中,存在8个队列),因为两个Broker在启动时都会向NameServer汇报路由信息。此时消息发送者缓存的路由信息是2个Broker,每个Broker默认4个队列(原因见3.2.1:Step2的分析)。消息发送者然后按照轮询机制,发送第一条消息选择(broker-a的messageQueue:0),向Broker发送消息,Broker服务器在处理消息时,首先会查看自己的路由配置管理器(TopicConfigManager)中的路由信息,此时不存在对应的路由信息,然后尝试查询是否存在默认Topic的路由信息,如果存在,说明启用了autoCreateTopicEnable,则在TopicConfigManager中创建新Topic的路由信息,此时存在与Broker服务端的内存中,然后本次消息发送结束。此时,在NameServer中还不存在新创建的Topic的路由信息。 这里有三个关键点: 启用autoCreateTopicEnable创建主题时,在Broker端创建主题的时机为,消息生产者往Broker端发送消息时才会创建。 然后Broker端会在一个心跳包周期内,将新创建的路由信息发送到NameServer,于此同时,Broker端还会有一个定时任务,定时将内存中的路由信息,持久化到Broker端的磁盘上。 消息发送者会每隔30s向NameServer更新路由信息,如果消息发送端一段时间内未发送消息,就不会有消息发送集群内的第二台Broker,那么NameServer中新创建的Topic的路由信息只会包含Broker-a,然后消息发送者会向NameServer拉取最新的路由信息,此时就会消息发送者原本缓存了2个broker的路由信息,将会变为一个Broker的路由信息,则该Topic的消息永远不会发送到另外一个Broker,就出现了上述现象。 原因就分析到这里了,现在我们还可以的大胆假设,开启autoCreateTopicEnable机制,什么情况会在两个Broker上都创建队列,其实,我们只需要连续快速的发送9条消息,就有可能在2个Broker上都创建队列,验证代码如下: public static void main(String[] args) throws MQClientException, InterruptedException { DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name"); producer.setNamesrvAddr("127.0.0.1:9876"); producer.start(); for (int i = 0; i < 9; i++) { try { Message msg = new Message("TopicTest10" ,"TagA" , ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)); SendResult sendResult = producer.send(msg); System.out.printf("%s%n", sendResult); } catch (Exception e) { e.printStackTrace(); Thread.sleep(1000); } } producer.shutdown(); } 验证结果如图所示: 本文就分析到这里了,大家如果喜欢这篇文章,麻烦大家帮忙点点赞,同时大家也可以给作者留言,告知在使用RocketMQ的过程中遇到的疑难杂症,与作者互动。
本节开始,将详细介绍Search API。1、Search API概述 详细API如下: public final SearchResponse search(SearchRequest searchRequest, RequestOptions options) throws IOException public final void searchAsync(SearchRequest searchRequest, RequestOptions options, ActionListener < SearchResponse> listener) 首先关注一下SearchRequest SearchRequest类图如下: 其关键属性说明如下: private SearchType searchType = SearchType.DEFAULT:搜索类型。 QUERY_THEN_FETCH首先根据路由算法向相关分片(多个)发送请求,此时只返回documentId与一些必要信息(例如用于排序等),然后对各个分片的结果进行汇聚,排序,然后选取客户端指定需要获取的数据条数(top n),然后根documentId再向各个分片请求具体的文档信息。首先根据路由算法向相关分片(多个)发送请求,此时只返回documentId与一些必要信息(例如用于排序等),然后对各个分片的结果进行汇聚,排序,然后选取客户端指定需要获取的数据条数(top n),然后根据documentId再向各个分片请求具体的文档信息。 QUERY_AND_FETCH在5.4.x版本开始废弃,是直接向各个分片节点请求数据,每个分片返回客户端请求数量的文档信息,然后汇聚全部返回给客户端,返回的数据为客户端请求数量size (路由后的分片数量)。在5.4.x版本开始废弃,是直接向各个分片节点请求数据,每个分片返回客户端请求数量的文档信息,然后汇聚全部返回给客户端,返回的数据为客户端请求数量size (路由后的分片数量)。 DFS_QUERY_THEN_FETCH在开始向各个节点发送请求之前,会进行一次词频、相关性的计算,后续流程与QUERY_THEN_FETCH相同,可以看出,该查询类型的文档相关性会更高,但性能比QUERY_THEN_FETCH要差。 private String[] indices:待查询的索引库。 private String routing:路由字段值。 private String preference:复制组内倾向性。 private SearchSourceBuilder source:查询主体(rerquest body),后续会重点讲解。 private Boolean requestCache:是否开启查询缓存。 private Boolean allowPartialSearchResults:是否允许部分成功。 private Scroll scroll:滚动API(用于分页) private int batchedReduceSize = DEFAULT_BATCHED_REDUCE_SIZE:批量归并size:默认为512 private int maxConcurrentShardRequests = 0:建议最大值别超过256,其核心含义待研究。 private int preFilterShardSize = 128,其核心作用待研究。 private String[] types:待查询的类型。 接下来再来重点关注一下查询API几个通用的参数: timeout查询的超时时间。 from查询开始的偏移量,用于分页查询,类似于关系数据库的分页的start。默认值为0。 size批量获取条数,用于分页查询。 search_type查询类型,6.4.0只支持QUERY_THEN_FETCH与DFS_QUERY_THEN_FETCH。 request_cache查询缓存,如果设置为false,取决于index级别的设置,将在索引管理API时详细讲解。 allow_partial_search_results是否允许部分成功,例如一个查询请求,需要向3个分片发出请求,如果只有两个分片成功返回结果,另外一个出现故障,如果设置false,则会返回整体失败,如果设置为true,则会成功部分结果,默认为true。 terminate after一个查询为每个分片最多收集的文档数,当达到该数量是,查询会提前结束。 batched_reduce_size在协调节点上应该立即减少一次请求需要访问的分片数量,如果一次请请求需要汇聚太多节点上的数据,容易造成内存消耗,该值可作为一个保护机制,控制一个请求同一时间并发访问的最大分片数量,默认为512。 注意:search_type, request_cache 和allow_partial_search_results 这三个参数,必须查询url级别的参数(query-string parameters),如果使用Rest low Level API时需要特别留意。 2、URI Search Elasticsearch支持使用URI请求模式来使用Search API,尽管有些参数无法使用,该模式主要还是用于测试,诸如使用CURL查询命令。URI Search示例如下: GET twitter/_search?q=user:kimchy URI Search支持如下参数: q定义查询字符串,其语法映射为DSL查询语法之query_string。 df查询字符串未使用字段前缀时定义的默认字段。 analyzer针对查询字符串使用的分词器。 analyze_wildcard是否分析通配符合前缀查询,默认值为false。 batched_reduce_size 控制协调节点批量发送分片的最大个数,主要是控制协调节点内存的消耗而提供的一种保护机制。 default_oprator默认操作类型,可选值为and、or,默认值为or。 lenient是否支持类型转换异常,默认为fasle,表示如果将一个字符类型传递给一个数字类型,默认为抛出异常,如果设置true,则忽略该异常。 explain类似于执行计划,表示对于每一个命中,包含如果得分是如何算出来的,默认为false。 _source用于对_source字段进行过滤,可以设置false来禁止返回_souce字段,也可以支持通配符,例如obj.*,用于字段过滤。 stored_fields用于字段过滤,已在字段过滤部分详细介绍过。 sort排序,可以类似于关系型数据库的排序语法:fieldName:asc | desc,也可以使用特殊字段_score(表示按分数,默认值)。 track_scores当使用排序时,跟踪返回结果中分数计算过程。 track_total_hits默认值为true,表示在返回结果中返回满足该查询条件的所有记录数。 timeout查询超时时间,默认永不超时。 terminate_after是否开启提前结束查询,主要是控制一次查询,从一个分片中返回的最大文档数量,如果开启,返回结果中会包含一个响应参数terminated_early,指示是否提前结束。 from用于分页,起始记录数。 size用于分页,控制一次查询,从每个分片查询的记录条数。 search_type查询类型,对应SearchType searchType,已在文章开头处介绍。 allow_partial_search_results是否允许部分分片执行失败,默认为true,也可以集群配置参数:search.default_allow_partial_results来设置默认值。 本节主要是对Elasticsearch Search API有一个概要的认识与如何使用URI进行查询,从下一节开始将深入到Search API各个细节中去,以便大家对Search API的运用得心应手。
作者简介:《RocketMQ技术内幕》作者,维护“中间件兴趣圈”微信公众号,关注主流开源中间件。 如果我们所在公司的业务量比较大,在生产环境经常会出现JVM内存溢出的现象,那我们该如何快速响应,快速定位,快速恢复问题呢? 本文将通过一个线上环境JVM内存溢出的案例向大家介绍一下处理思路与分析方法。 案例:架构组接到某项目组反馈,Zabbix监控上显示JMX不可用,请求协助处理。 分析思路: JMX不可用,往往是由于垃圾回收时间停顿时间过长、内存溢出等问题引起的。 线上故障分析的原则是首先要采取措施快速恢复故障对业务的影响,然后才是采集信息、分析定位问题,并最终给出解决办法。 具体分析过程如下。 1、如何快速恢复业务 通常线上的故障会对业务造成重大影响,影响用户体验,故如果线上服务器出现故障,应规避对业务造成影响,但不能简单的重启服务器,因为需要尽可能保留现场,为后续的问题分析打下基础。 那我们如何快速规避对业务的影响,并能保留现场呢? 通常的做法是隔离故障服务器。 通常线上服务器是集群部署,一个好的分布式负载方案会自动剔除故障的机器,从而实现高可用架构,但如果未被剔除,则需要运维人员将故障服务器进行剔除,保留现场进行分析。 发生内存泄露,通常情况下是由于代码的原因造成的,一般无法立即对代码进行修复,很容易会发送连锁反应造成应用服务器一台一台接连宕机,故障面积会慢慢扩大,针对此种情况,应快速定位发生内存泄露的原因,将该服务进行降级,避免对其他服务造成影响。最简单的降级方法是根据F5(Nginx)转发策略,对该功能定向到一个单独的集群,与其他流量进行隔离,确保其他业务不受牵连,给故障排查、解决提供宝贵的缓冲时间。 2、分析解决问题 首先可以通过查看日志,确定是哪种内存溢出,堆内存溢出可发生的地方:Java heap space(堆空间)、perm space(持久代)。 2.1 收集内存溢出Dump文件 收集Dump文件有两种方式: 设置JVM启动参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/jvmdump 在每次发生内存溢出时,JVM会自动将堆转储,dump文件存放在-XX:HeapDumpPath指定的路径下。 使用jmap命令收集 通过jmap -dump:live,format=b,file=/opt/jvm/dump.hprof pid。 2.2 分析Dump文件 在获取Dump文件后,可以使用工具MAT(MemoryAnalyzer)进行分析,该工具大家可以通过百度自行下载。 使用MAT打开Dump文件后,首页截图如下: 工具按钮介绍: :直方图视图,将堆中所有的内存消耗情况统计出来,其如图所示: :内存使用树状结构,以线程为维度,树状形式展开,如图所示: 线程栈,其截图如下: 根据该图,可以明确,堆的总大小为1.9G,被4个线程全部占据,导致其他线程无法再申请资源,抛出堆内存溢出错误。 接下来,我通常的做法是直接去看这个视图(以线程为基本维度,查找线程中占用内存的对象),为后续定位排查提供必要的依据。 从上面的截图中可以得出如下关键信息点: org.apache.ibatis.executor.result.DefaultResultHandler内部持有一个List,其原始为java.util.HashMap,从这个类基本可以看出是与数据库的查询相关,对数据库返回结果的解码并组织成HashMap。 这个List中的元素总共有146033个,初步可以判断出是在一次查询中从数据库中一次查询出了太多数据,造成了内存溢出。 由于SQL查询代码中,是用HashMap来接收数据库中的返回字段,无法一时间看出是那个查询,那我们能不能精确找到是哪一个查询,哪一行代码,甚至与哪一条SQL语句呢? 答案是可以的,我们可以从视图一探究竟。 温馨提示:视图使用技巧:展开技巧:沿着使用率最高的项一层一层进行展开,直至发现具体占用内存的对象。 接下来我们从视图去寻找是哪个方法,哪条SQL语句触发的。 具体方法:首先完全展开一个线程,从展开图的底部向上寻找:其线程的入口(控制层代码) 继续往上查找,要找到SQL语句,应该找到Mybatis处理结果集相关的类,如图所示: 然后展开boundSql即能找到SQL语句: 然后鼠标可以放在SQL属性中,右键,可以将SQL语句复制出来。 由于这里涉及到公司的代码机密,故在这里不贴出具体的SQL语句。 这里根据后面的分析,原来是在做导出功能的时候,没有使用分页对数据进行分页查询,分页写入Excel文件,而是一次将全部数据查询,导致导出功能如果并发数超过4个时,就会将所有内存耗尽。 解决方案: 首先在运维层面将该请求导入到指定的一台服务器上,是导出任务与其他任务进行隔离,避免对其他重要服务造成影响。 项目组对其代码进行修复,可以使用分页查数据,然后分配写入Excel。
作者简介:《RocketMQ技术内幕》作者、中间件兴趣圈微信公众号维护者 ElasticSearch Mapping(映射)目录:elasticsearch使用指南之Elasticsearch Mapping类型映射概述与元字段类型 Elasticsearch使用指南之Elasticsearch Mapping parameters(主要参数一览) Elasticsearch与关系型数据库的另外一个不同就是索引下的的类型中的字段可以动态创建,无需在使用之前先创建好,支持在索引的过程中动态添加。这种机制也得意于Elasticsearch的动态映射机制,能根据字段的值动态猜测字段的类型,从而建立索引映射。本节将重点介绍Elasticsearch动态映射机制。 PUT data/_doc/1 { "count": 5 } 执行上述请求时,索引"data"不必预先创建,该API会首先会自动创建索引data、类型映射_doc,其映射类型下包含字段count,其类型为long。自动为类型映射根据文档的值推测其类型的过程,就是本文要重点描述的机制:动态类型映射机制。动态映射机制包含如下两种映射规则: Dynamic field mappings Dynamic templates 接下来就分别介绍上述两种动态映射规则。 1、Dynamic field mappings 动态字段映射规则。默认情况下,当在文档中发现以前未见过的字段时,Elasticsearch将向类型映射添加新字段。通过将映射参数dynamic 设置为false(忽略新字段)或strict(遇到未知字段时抛出异常),可以在文档和对象级别禁用此行为。 JSON datatype Elasticsearch datatype null 不会自动增加类型映射 true or false boolean floating point number float integer long object object array 根据数组中第一个非空值来判断 string date、double、long、text(带有keyword子字段) 1.1 Date detection 日期类型检测,如果启用了date_detection(默认),那么将检查新字符串字段,以查看它们的内容是否匹配dynamic_date_format中指定的任何日期模式。如果匹配其中任意一种格式,则添加字段映射时,字段的类型为date,并指定日期的format为匹配的模式。例如: PUT my_index/_doc/1 { "create_date": "2015/09/02" } 则会为create_date字段在json中的类型是字符串,但如果date_detection=true,则能映射为date字段。可以在类型_type级别设置是否开启日期类型检测(date_detection),示例如下: PUT my_index { "mappings": { "_doc": { "date_detection": false } } } 1.2 定制日期类型检测格式 可以通过类型级别(_type)级别通过dynamic_date_formats参数来自定义日期检测格式,示例如下: PUT my_index { "mappings": { "_doc": { "dynamic_date_formats": ["MM/dd/yyyy"] } } } PUT my_index/_doc/1 { "create_date": "09/25/2015" } 1.3 numeric detection 数字类型检测。同样如果数字类型的值在JSON中是用字符串表示的话,如果开启日期类型检测,同样在创建映射时会映射为数字类型,而不是字符串类型。 PUT my_index { "mappings": { "_doc": { "numeric_detection": true } } } 默认情况下,numeric_detection为false。 2、Dynamic templates Dynamic field mappings默认情况下是根据elasticsearch支持的数据类型来推测参数值的类型,而动态模板允许您定义自定义映射规则,根据自定义的规则来推测字段值所属的类型,从而添加字段类型映射。 动态映射模板通过如下方式进行定义: "dynamic_templates": [ // @1 { "my_template_name": { // @2 ... match conditions ... // @3 "mapping": { ... } // @4 } }, ... ] 代码@1:在类型映射时通过dynamic_templates属性定义动态映射模板,其类型为数组。 代码@2:定义动态映射模板名称。 代码@3:匹配条件,其定义方式包括:match_mapping_type, match, match_pattern, unmatch, path_match, path_unmatch。 代码@4:匹配@3的字段使用的类型映射定义(映射参数为类型映射中支持的参数) 动态类型映射模板的核心关键是匹配条件与类型映射,接下来按照匹配条件定义方式来重点讲解动态类型模板映射机制。 2.1、match_mapping_type 首先使用json解析器解析字段值的类型,由于JSON不能区分long和integer,也不允许区分double和float,所以它总是选择更广泛的数据类型, 例如5,在使用字段动态映射时,elasticsearch会将字段动态映射为long而不是integer类型,那如何将数字5动态映射为integer类型呢,利用match_mapping_type可以实现上述需求,例如,如果希望将所有整数字段映射为整数而不是long,并将所有字符串字段映射为文本和关键字,可以使用以下模板: PUT my_index { "mappings": { "_doc": { "dynamic_templates": [ { "integers": { "match_mapping_type": "long", "mapping": { "type": "integer" } } }, { "strings": { "match_mapping_type": "string", "mapping": { "type": "text", "fields": { "raw": { "type": "keyword", "ignore_above": 256 } } } } } ] } } } 一言以蔽之,match_mapping_type为字段动态映射(字段类型检测)得出的类型建立一个映射关系,将该类型转换为mapping定义中的类型。 2.2、match and unmatch match参数使用模式匹配字段名,而unmatch使用模式排除匹配匹配的字段。 match、unmatch示例如下: PUT my_index { "mappings": { "_doc": { "dynamic_templates": [ { "longs_as_strings": { "match_mapping_type": "string", // @1 "match": "long_*", // @2 "unmatch": "*_text", // @3 "mapping": { // @4 "type": "long" } } } ] } } } PUT my_index/_doc/1 { "long_num": "5", // @5 "long_text": "foo" // @6 } 代码@1:表示该自动映射模板针对的字段为JSON解析器检测字段的类型为string的新增字段。 代码@2:字段名称以long_开头的字段。 代码@3:排除字段名称以_text的字段。 代码@4:符合long_开头的字段,并且不是以_text结尾的字段,如果JSON检测为string类型的新字段,映射为long。 代码@5:long_num,映射类型为long。 代码@6:long_text虽然也满足long_开头,但是以_text结尾,故该字段不会映射为long,而是保留其JSON检测到的类型string,会映射为text字段和keyword多字段(参考字段动态映射机制)。 2.3、match_pattern 使用正则表达式来匹配字段名称。 "dynamic_templates": [ { "longs_as_strings": { "match_mapping_type": "string", "match_pattern": "regex", // @1 "match": "^profit_\d+$" // @2 "mapping": { "type": "long" } } } ] 代码@1:设置匹配模式为regex代表java正则表达式 代码@2:java正则表达式 2.4、path_match and path_unmatch path_match与path_unmatch的工作方式与match、unmatch一样,只不过path_match是针对字段的全路径,特别是针对嵌套类型(object、nested)。 下面一个示例:将name下的字段除了middle字段为copy到name属性并列的full_name字段中。 PUT my_index { "mappings": { "_doc": { "dynamic_templates": [ { "copy_full_name": { "path_match": "name.*", "path_unmatch": "*.middle", "mapping": { "type": "text", "copy_to": "full_name" } } } ] } } } PUT my_index/_doc/1 { "name": { "first": "Alice", "middle": "Mary", "last": "White" } } 2.5、{name} and {dynamic_type} {name}展位符,表示字段的名称。 {dynamic_type}:JSON解析器解析到的字段类型。 PUT my_index { "mappings": { "_doc": { "dynamic_templates": [ { "named_analyzers": { // @1 "match_mapping_type": "string", "match": "*", "mapping": { "type": "text", "analyzer": "{name}" } } }, { "no_doc_values": { // @2 "match_mapping_type":"*", "mapping": { "type": "{dynamic_type}", "doc_values": false } } } ] } } } PUT my_index/_doc/1 { "english": "Some English text", "count": 5 } 代码@1:映射模板的含义为:对所有匹配到的字符串类型,类型映射为text,对应的分析器的名称与字段名相同,这个在使用时慎重,可能不存在同名的分析器,本例只是一个展示。 代码@2:对于匹配到的任何类型,其映射定义为类型为自动检测的类型,并且禁用doc_values=false。 本节详细介绍了Elasticsearch动态类型映射机制。
作者简介:《RocketMQ技术内幕》作者、中间件兴趣圈微信公众号维护者。 本文将详细介绍Elasticsearch在创建索引映射时可指定的参数,并重点分析其含义。 1、analyzer指定分词器。elasticsearch是一款支持全文检索的分布式存储系统,对于text类型的字段,首先会使用分词器进行分词,然后将分词后的词根一个一个存储在倒排索引中,后续查询主要是针对词根的搜索。 analyzer该参数可以在每个查询、每个字段、每个索引中使用,其优先级如下(越靠前越优先):1)字段上定义的分词器2)索引配置中定义的分词器3)默认分词器(standard) 在查询上下文,分词器的查找优先为:1)full-text query中定义的分词器2)定义类型映射时,字段中search_analyzer 定义的分词器。3)定义字段映射时analyzer定义的分词器4)索引中default_search中定义的分词器。5)索引中默认定义的分词器6)标准分词器(standard)。 2、normalizer规划化,主要针对keyword类型,在索引该字段或查询字段之前,可以先对原始数据进行一些简单的处理,然后再将处理后的结果当成一个词根存入倒排索引中,举例如下: PUT index { "settings": { "analysis": { "normalizer": { "my_normalizer": { // @1 "type": "custom", "char_filter": [], "filter": ["lowercase", "asciifolding"] // @2 } } } }, "mappings": { "_doc": { "properties": { "foo": { "type": "keyword", "normalizer": "my_normalizer" // @3 } } } } } 代码@1:首先在settings中的analysis属性中定义normalizer。 代码@2:设置标准化过滤器,示例中的处理器为小写、asciifolding。 代码@3:在定义映射时,如果字段类型为keyword,可以使用normalizer引用定义好的normalizer。 3、 boost 权重值,可以提升在查询时的权重,对查询相关性有直接的影响,其默认值为1.0。其影响范围为词根查询(team query),对前缀、范围查询、全文索引(match query)不生效。 注意:不建议在创建索引映射时使用boost属性,而是在查询时通过boost参数指定。其主要原因如下:1)无法动态修改字段中定义的boost值,除非使用reindex命令重建索引。2)相反,如果在查询时指定boost值,每一个查询都可以使用不同的boost值,灵活。3)在索引中指定boost值,boost存储在记录中,从而会降低分数计算的质量。 4、coerce是否进行类型“隐式转换”。es最终存储文档的格式是字符串。 例如存在如下字段类型: "number_one": { "type": "integer" } 声明number_one字段的类型为数字类型,那是否允许接收“6”字符串形式的数据呢?因为在JSON中,“6”用来赋给int类型的字段,也是能接受的,默认coerce为true,表示允许这种赋值,但如果coerce设置为false,此时es只能接受不带双引号的数字,如果在coerce=false时,将“6”赋值给number_one时会抛出类型不匹配异常。 可以在创建索引时指定默认的coerce值,示例如下: PUT my_index { "settings": { "index.mapping.coerce": false }, "mappings": { // 省略字段映射定义 } } 5、copy_tocopy_to参数允许您创建自定义的_all字段。换句话说,多个字段的值可以复制到一个字段中例如,first_name和last_name字段可以复制到full_name字段如下: PUT my_index { "mappings": { "_doc": { "properties": { "first_name": { "type": "text", "copy_to": "full_name" }, "last_name": { "type": "text", "copy_to": "full_name" }, "full_name": { "type": "text" } } } } } 表示字段full_name的值来自 first_name + last_name。 关于copy_to重点说明:1)字段的复制是原始值,而不是分词后的词根。2)复制字段不会包含在_souce字段中,但可以使用复制字段进行查询。3)同一个字段可以复制到多个字段,写法如下:"copy_to": [ "field_1", "field_2" ] 6、 doc_values当需要对一个字段进行排序时,es需要提取匹配结果集中的排序字段值集合,然后进行排序。倒排索引的数据结构对检索来说相当高效,但对排序就不那么擅长了。 业界对排序、聚合非常高效的数据存储格式首推列式存储,在elasticsearch中,doc_values就是一种列式存储结构,默认情况下绝大多数数据类型都是开启的,即在索引时会将字段的值(或分词后的词根序列)加入到倒排索引中,同时也会该字段的值加入doc_values中,所有该类型的索引下该字段的值用一列存储。 doc_values的使用示例: PUT my_index { "mappings": { "_doc": { "properties": { "status_code": { "type": "keyword" // 默认情况下,“doc_values”:true }, "session_id": { "type": "keyword", "doc_values": false } } } } } 7、dynamic是否允许动态的隐式增加字段。在执行index api或更新文档API时,对于_source字段中包含一些原先未定义的字段采取的措施,根据dynamic的取值,会进行不同的操作:1)true,默认值,表示新的字段会加入到类型映射中。2)false,新的字段会被忽略,即不会存入_souce字段中,即不会存储新字段,也无法通过新字段进行查询。3)strict,会显示抛出异常,需要新使用put mapping api先显示增加字段映射。 dynamic设置为false,也是可以通过put mapping api进行字段的新增,同样put mapping api可以对dynamic值进行更新。 举例说明: PUT my_index/_doc/1 { "username": "johnsmith", "name": { "first": "John", "last": "Smith" } } PUT my_index/_doc/2 // @1 { "username": "marywhite", "email": "mary@white.com", "name": { "first": "Mary", "middle": "Alice", "last": "White" } } GET my_index/_mapping // @2 代码@1在原有的映射下,增加了username,name.middle两个字段,通过代码@2获取映射API可以得知,es已经为原本不存在的字段自动添加了类型映射定义。 注意:dynamic只对当前层级具有约束力,例如: PUT my_index { "mappings": { "_doc": { "dynamic": false, // @1 "properties": { "user": { // @2 "properties": { "name": { "type": "text" }, "social_networks": { // @3 "dynamic": true, "properties": {} } } } } } } } 代码@1:_doc类型的顶层不能不支持动态隐式添加字段映射。 代码@2:但_doc的嵌套对象user对象,是支持动态隐式添加字段映射。 代码@3:同样对于嵌套对象social_networks,也支持动态隐式添加字段映射。 8、enabled是否建立索引,默认情况下es会尝试为你索引所有的字段,但有时候某些类型的字段,无需建立索引,只是用来存储数据即可。只有映射类型(type)和object类型的字段可以设置enabled属性。示例如下: PUT my_index { "mappings": { "_doc": { "properties": { "user_id": { "type": "keyword" }, "last_updated": { "type": "date" }, "session_data": { "enabled": false } } } } } PUT my_index/_doc/session_1 { "user_id": "kimchy", "session_data": { "arbitrary_object": { "some_array": [ "foo", "bar", { "baz": 2 } ] } }, "last_updated": "2015-12-06T18:20:22" } 上述示例,es会存储session_data对象的数据,但无法通过查询API根据session_data中的属性进行查询。 同样,可以通过put mapping api更新enabled属性。 9、eager_global_ordinals 全局序列号,它以字典顺序为每个惟一的术语保持递增的编号。全局序号只支持字符串类型(关键字和文本字段)。在关键字字段中,它们在默认情况下是可用的,但文本字段只能fielddata=true时可用。doc_values(和fielddata)也有序号,是特定段(segment)和字段中所有词根(term)的唯一编号。全局序号只是在此之上构建的,它提供了段序号(segment ordinals)和全局序号(global ordinals)之间的映射,全局序号在整个分片中是唯一的。由于每个字段的全局序号与一个分片(shard)的所有段(segment)相关联,因此当一个新的segment(段)变为可见时,需要完全重新构建它们。术语聚合依懒全局序号,首先在分片级别(shard)执行聚合,然后汇聚所有分片的结果(reduce)并将全局序号转换为真正的词根(字符串),合并后返回聚合后的结果。默认情况下,全局序号是在搜索时加载的,这对提高索引API的速度会非常有利。但是,如果您更加重视搜索性能,,那么在您计划使用的聚合的字段上设置eager_global_ordinals,会对提高查询效率更有帮助。eager_global_ordinals的意思是预先加载全局序号。 其示例如下: PUT my_index/_mapping/_doc { "properties": { "tags": { "type": "keyword", "eager_global_ordinals": true } } } 10、fielddata 为了解决排序与聚合,elasticsearch提供了doc_values属性来支持列式存储,但doc_values不支持text字段类型。因为text字段是需要先分析(分词),会影响doc_values列式存储的性能。es为了支持text字段高效排序与聚合,引入了一种新的数据结构(fielddata),使用内存进行存储。默认构建时机为第一次聚合查询、排序操作时构建,主要存储倒排索引中的词根与文档的映射关系,聚合,排序操作在内存中执行。因此fielddata需要消耗大量的JVM堆内存。一旦fielddata加载到内存后,它将永久存在。通常情况下,加载fielddata是一个昂贵的操作,故默认情况下,text字段的字段默认是不开启fielddata机制。在使用fielddata之前请慎重考虑为什么要开启fielddata。通常text字段用来进行全文搜索,对于聚合、排序字段,建议使用doc_values机制。 为了节省内存的使用,es提供了另一项机制(fielddata_frequency_filter),允许只加载那些词根频率在指定范围(最大,小值)直接的词根与文档的映射关系,最大最小值可以指定为绝对值,例如数字,也可以基于百分比(百分比的计算是基于整个分段(segment),其频率分母不是分段(segment)中所有的文档,而是segment中该字段有值的文档)。可以通过min_segment_size参数来指定分段中必须包含的最小文档数量来排除小段,也就是说可以控制fielddata_frequency_filter的作用范围是包含大于min_segment_size的文档数量的段。 11、format在JSON文档中,日期表示为字符串。Elasticsearch使用一组预先配置的格式来识别和解析这些字符串,并将其解析为long类型的数值(毫秒)。日期格式主要包括如下3种方式:1)自定义格式2)date mesh(已在DSL查询API中详解)3)内置格式一:自定义格式首先可以使用java定义时间的格式,例如: PUT my_index { "mappings": { "_doc": { "properties": { "date": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" } } } } } 二:date mesh某些API支持,已在DSL查询API中详细介绍过,这里不再重复。 三:内置格式elasticsearch为我们内置了大量的格式,如下: epoch_millis时间戳,单位,毫秒。 epoch_second时间戳,单位,秒。 date_optional_time日期必填,时间可选,其支持的格式如下: basic_date其格式表达式为 :yyyyMMdd basic_date_time其格式表达式为:yyyyMMdd'T'HHmmss.SSSZ basic_date_time_no_millis其格式表达式为:yyyyMMdd'T'HHmmssZ basic_ordinal_date 4位数的年 + 3位(day of year),其格式字符串为yyyyDDD basic_ordinal_date_time其格式字符串为yyyyDDD'T'HHmmss.SSSZ basic_ordinal_date_time_no_millis其格式字符串为yyyyDDD'T'HHmmssZ basic_time其格式字符串为HHmmss.SSSZ basic_time_no_millis其格式字符串为HHmmssZ basic_t_time其格式字符串为'T'HHmmss.SSSZ basic_t_time_no_millis其格式字符串为'T'HHmmssZ basic_week_date其格式字符串为xxxx'W'wwe,4为年 ,然后用'W', 2位week of year(所在年里周序号) 1位 day of week。 basic_week_date_time其格式字符串为xxxx'W'wwe'T'HH:mm:ss.SSSZ. basic_week_date_time_no_millis其格式字符串为xxxx'W'wwe'T'HH:mm:ssZ. date其格式字符串为yyyy-MM-dd date_hour 其格式字符串为yyyy-MM-dd'T'HH date_hour_minute其格式字符串为yyyy-MM-dd'T'HH:mm date_hour_minute_second其格式字符串为yyyy-MM-dd'T'HH:mm:ss date_hour_minute_second_fraction其格式字符串为yyyy-MM-dd'T'HH:mm:ss.SSS date_hour_minute_second_millis其格式字符串为yyyy-MM-dd'T'HH:mm:ss.SSS date_time其格式字符串为yyyy-MM-dd'T'HH:mm:ss.SSS date_time_no_millis其格式字符串为yyyy-MM-dd'T'HH:mm:ss hour其格式字符串为HH hour_minute其格式字符串为HH:mm hour_minute_second其格式字符串为HH:mm:ss hour_minute_second_fraction其格式字符串为HH:mm:ss.SSS hour_minute_second_millis其格式字符串为HH:mm:ss.SSS ordinal_date其格式字符串为yyyy-DDD,其中DDD为 day of year。 ordinal_date_time其格式字符串为yyyy-DDD‘T’HH:mm:ss.SSSZZ,其中DDD为 day of year。 ordinal_date_time_no_millis其格式字符串为yyyy-DDD‘T’HH:mm:ssZZ time其格式字符串为HH:mm:ss.SSSZZ time_no_millis 其格式字符串为HH:mm:ssZZ t_time其格式字符串为'T'HH:mm:ss.SSSZZ t_time_no_millis其格式字符串为'T'HH:mm:ssZZ week_date其格式字符串为xxxx-'W'ww-e,4位年份,ww表示week of year,e表示day of week。 week_date_time其格式字符串为xxxx-'W'ww-e'T'HH:mm:ss.SSSZZ week_date_time_no_millis其格式字符串为xxxx-'W'ww-e'T'HH:mm:ssZZ weekyear其格式字符串为xxxx weekyear_week其格式字符串为xxxx-'W'ww,其中ww为week of year。 weekyear_week_day 其格式字符串为xxxx-'W'ww-e,其中ww为week of year,e为day of week。 year其格式字符串为yyyy year_month 其格式字符串为yyyy-MM year_month_day其格式字符串为yyyy-MM-dd 温馨提示,日期格式时,es建议在上述格式之前加上strict_前缀。 12、ignore_above超过ignore_above设置的字符串不会被索引或存储。对于字符串数组,将分别对每个数组元素应ignore_above,超过ignore_above的字符串元素将不会被索引或存储。目前测试的结果为:对于字符串字符长度超过ignore_above会存储,但不索引(也就是无法根据该值去查询)。 其测试效果如下: public static void create_mapping_ignore_above() { // 创建映射 RestHighLevelClient client = EsClient.getClient(); try { CreateIndexRequest request = new CreateIndexRequest("mapping_test_ignore_above2"); XContentBuilder mapping = XContentFactory.jsonBuilder() .startObject() .startObject("properties") .startObject("lies") .field("type", "keyword") // 创建关键字段 .field("ignore_above", 10) // 设置长度不能超过10 .endObject() .endObject() .endObject(); // request.mapping("user", mapping_user); request.mapping("_doc", mapping); System.out.println(client.indices().create(request, RequestOptions.DEFAULT)); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } public static void index_mapping_ignore_above() { // 索引数据 RestHighLevelClient client = EsClient.getClient(); try { IndexRequest request = new IndexRequest("mapping_test_ignore_above2", "_doc"); Map<String, Object> data = new HashMap<>(); data.put("lies", new String[] {"dingabcdwei","huangsw","wuyanfengamdule"}); request.source(data); System.out.println(client.index(request, RequestOptions.DEFAULT)); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } public static void search_ignore_above() { // 查询数据 RestHighLevelClient client = EsClient.getClient(); try { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("mapping_test_ignore_above2"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query( // QueryBuilders.matchAllQuery() // @1 // QueryBuilders.termQuery("lies", "dingabcdwei") // @2 // QueryBuilders.termQuery("lies", "huangsw") // @3 ); searchRequest.source(sourceBuilder); SearchResponse result = client.search(searchRequest, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 代码@1:首先查询所有数据,其_souce字段的值为:"_source":{"lies":["dingabcdwei","huangsw","wuyanfengamdule"]},表名不管字符串的值是否大于ignore_above指定的值,都会存储。 代码@2:用超过ignore_above长度的值尝试去搜索,发现无法匹配到记录,表明并未加入到倒排索引中。 代码@3:用未超过ignore_above长度的值尝试去搜索,发现能匹配到记录。 注意:在es中,ignore_above的长度是字符的长度,而es其底层实现lucene是使用字节进行计算的,故,如果要反馈到lucnce,请注意关系。 13、ignore_malformed试图将错误的数据类型索引到字段中,默认情况下会抛出异常,并拒绝整个文档。ignore_malformed参数,如果设置为真,允许错误被忽略。格式不正确的字段不建立索引,但是文档中的其他字 段正常处理。可以创建索引时,设置index.mapping.ignore_malformed 配置项来定义索引级别的默认值,其优先级为 字段级、索引级。 14、index定义字段是否索引,true:代表索引,false表示不索引(则无法通过该字段进行查询),默认值为true。 index_options控制文档添加到反向索引的额外内容,其可选择值如下: docs:文档编号添加到倒排索引。 freqs:文档编号与访问频率。 positions:文档编号、访问频率、词位置(顺序性),proximity 和phrase queries 需要用到该模式。 offsets:文档编号,词频率,词偏移量(开始和结束位置)和词位置(序号),高亮显示,需要设置为该模式。默认情况下,被分析的字符串(analyzed string)字段使用positions,其他字段使用docs; 15、fieldsfields允许对同一索引中同名的字段进行不同的设置,举例说明: PUT my_index { "mappings": { "_doc": { "properties": { "city": { "type": "text", // @1 "fields": { // @2 "raw": { "type": "keyword" // @3 } } } } } } } @1:上述映射为city字段,定义类型为text,使用全文索引。 @2:为city定义多字段,city.raw,其类型用keyword。 主要就可以用user进行全文匹配,也可以用user.raw进行聚合、排序等操作。另外一种比较常用的场合是对该字段使用不同的分词器。 16、norms字段的评分规范,存储该规范信息,会提高查询时的评分计算效率。虽然规范对计分很有用,但它也需要大量磁盘(通常是索引中每个字段的每个文档一个字节的顺序,甚至对于没有这个特定字段的文档也是如此)。从这里也可以看出,norms适合为过滤或聚合的字段。注意,可以通过put mapping api 将norms=true更新为norms=false,但无法从false更新到true。 17、null_value将显示的null值替换为新定义的额值。用如下示例做一个说明: PUT my_index { "mappings": { "_doc": { "properties": { "status_code": { "type": "keyword", "null_value": "NULL" // @1 } } } } } PUT my_index/_doc/1 { "status_code": null // @2 } PUT my_index/_doc/2 { "status_code": [] // @3 } GET my_index/_search { "query": { "term": { "status_code": "NULL" // @4 } } } 代码@1:为status_code字段定义“NULL”为空值null; 代码@2:该处,存储在status_code为 null_value中定义的值,即"NULL" 代码@3:空数组不包含显式null,因此不会被null_value替换。 代码@4:该处查询,会查询出文档1。其查询结果如下: { "took":4, "timed_out":false, "_shards":{ "total":5, "successful":5, "skipped":0, "failed":0 }, "hits":{ "total":1, "max_score":0.2876821, "hits":[ { "_index":"mapping_test_null_value", "_type":"_doc", "_id":"RyjGEmcB-TTORxhqI2Zn", "_score":0.2876821, "_source":{ "status_code":null } } ] } } null_value具有如下两个特点: null_value需要与字段的数据类型相同。例如,一个long类型的字段不能有字符串null_value。 null_value只会索引中的值(倒排索引),无法改变_souce字段的值。 18、position_increment_gap针对多值字段,值与值之间的间隙。举例说明: PUT my_index { "mappings": { "_doc": { "properties": { "names": { "type": "text", "position_increment_gap": 0 // @1 // "position_increment_gap": 10 // @2 } } } } } PUT my_index/_doc/1 { "names": [ "John Abraham", "Lincoln Smith"] } names字段是个数组,也即ES中说的多值字段 当position_increment_gap=0时,es的默认使用标准分词器,分成的词根为: position 0 : john position 1 : abraham position 2 : lincoln position 3 : smith 当position_increment_gap = 10时,es使用默认分词器,分成的词根为: position 0 : john position 1 : abraham position 11 : lincoln 这是第二个词,等于上一个词的position + position_increment_gap。 position 12 : smith 针对如下下查询: GET my_index/_search { "query": { "match_phrase": { "names": "Abraham Lincoln" } } } 针对position_increment_gap=0时,能匹配上文档,如果position_increment_gap=10,则无法匹配到文档,因为abraham与lincoln的位置相差10,如果要能匹配到该文档,需要在查询时设置slop=10,该参数在前面的DSL查询部分已详细介绍过。 19、properties为映射类型创建字段定义。 20、search_analyzer通常,在索引时和搜索时应用相同的分析器,以确保查询中的术语与反向索引中的术语具有相同的格式,如果想要在搜索时使用与存储时不同的分词器,则使用search_analyzer属性指定,通常用于ES实现即时搜索(edge_ngram)。 21、similarity指定相似度算法,其可选值: BM25当前版本的默认值,使用BM25算法。 classic使用TF/IDF算法,曾经是es,lucene的默认相似度算法。 boolean一个简单的布尔相似度,当不需要全文排序时使用,并且分数应该只基于查询条件是否匹配。布尔相似度为术语提供了一个与它们的查询boost相等的分数。 22、store默认情况下,字段值被索引以使其可搜索,但它们不存储。这意味着可以查询字段,但无法检索原始字段值。通常这并不重要。字段值已经是_source字段的一部分,该字段默认存储。如果您只想检索单个字段或几个字段的值,而不是整个_source,那么这可以通过字段过滤上下文(source filting context来实现,在某些情况下,存储字段是有意义的。例如,如果您有一个包含标题、日期和非常大的内容字段的文档,您可能只想检索标题和日期,而不需要从大型_source字段中提取这些字段,es还提供了另外一种提取部分字段的方法,stored_fields,stored_fields过滤,只支持字段的store定义为ture,该部分内容已经在Elasticsearch Doc api时,_souce过滤部分详细介绍过,这里不过多介绍。 23、term_vectorterm_vector包含分析过程产生的术语的信息,包括: 术语列表。 每一项的位置(或顺序)。 开始和结束字符偏移量。term_vector可取值: no不存储term_vector信息,默认值。 yes只存储字段中的值。 with_positions存储字段中的值与位置信息。 with_offsets存储字段中的值、偏移量 with_positions_offsets存储字段中的值、位置、偏移量信息。
作者简介:《RocketMQ技术内幕》作者、中间件兴趣圈微信公众号维护者。 本文将详细介绍elasticsearch批量获取API(Multi Get API)和Bulk API的使用。 1、Multi Get API详细API如下: public final MultiGetResponse mget(MultiGetRequest multiGetRequest, RequestOptions options) throws IOException public final void mgetAsync(MultiGetRequest multiGetRequest, RequestOptions options, ActionListener listener) 其核心需要关注MultiGetRequest 。 从上面所知,mget及批量获取文档,通过add方法添加多个Item,每一个item代表一个文件获取请求,其相关字段已在get API中详细介绍,这里就不做过多详解。 Mget API使用示例 public static void testMget() { RestHighLevelClient client = EsClient.getClient(); try { MultiGetRequest request = new MultiGetRequest(); request.add("twitter", "_doc", "10"); request.add("twitter", "_doc", "11"); request.add("twitter", "_doc", "12"); request.add("gisdemo", "_doc", "10"); MultiGetResponse result = client.mget(request, RequestOptions.DEFAULT); System.out.println(result); } catch (Throwable e) { e.printStackTrace(); } finally { EsClient.close(client); } } 返回的结果其本质是一个 GetResponse的数组,不会因为其中一个失败,整个请求失败,但其结果中会标明每一个是否成功。 其返回结果类图如下: 其字段过滤(Source filtering)、路由等机制与Get API相同,详情请参考:Elasticsearch Document Get API详解、原理与示例 2、Elasticsearch Bulk API Bulk API可以在一次API调用中包含多个索引操作,例如更新索引,删除索引等,类比批量操作。 详细API如下: public final BulkResponse bulk(BulkRequest bulkRequest, RequestOptions options) throws IOException public final void bulkAsync(BulkRequest bulkRequest, RequestOptions options, ActionListener listener) 2.1 BulkRequest详解 我们先一一来看一下其核心属性与与典型方法: final List requests = new ArrayList<>():单个命令容器,DocWriteRequest的子类包括:IndexRequest、UpdateRequest、DeleteRequest。 private final Set indices = new HashSet<>():List requests涉及到的索引。List protected TimeValue timeout = BulkShardRequest.DEFAULT_TIMEOUT:timeout机制,针对一个Bulk请求生效。 private ActiveShardCount waitForActiveShards = ActiveShardCount.DEFAULT: waitForActiveShards,针对一个Bulk请求生效,各个请求中waitForActiveShards优先。 private RefreshPolicy refreshPolicy = RefreshPolicy.NONE:刷新策略。 private long sizeInBytes = 0:整个Bulk请求的大小。 通过add api为BulkRequest添加一个请求。 2.2 Bulk API请求格式详解 Bulk Rest请求协议基于如下格式: POST _bulk { "index" : { "_index" : "test", "_type" : "_doc", "_id" : "1" } } { "field1" : "value1" } { "delete" : { "_index" : "test", "_type" : "_doc", "_id" : "2" } } { "create" : { "_index" : "test", "_type" : "_doc", "_id" : "3" } } { "field1" : "value3" } { "update" : {"_id" : "1", "_type" : "_doc", "_index" : "test"} } { "doc" : {"field2" : "value2"} } 请求格式如下(restfull): POST请求,其Content-Type为application/x-ndjson。 每一个命令占用两行,每行的结束字符为rn。 第一行为元数据,"opType" : {元数据}。 第二行为有效载体(非必选),例如Index操作,其有效载荷为IndexRequest#source字段。 opType可选值 index、create、update、delete。 公用元数据(index、create、update、delete)如下 1)_index :索引名 2)_type:类型名 3)_id:文档ID 4)routing:路由值 5)parent 6)version:数据版本号 7)version_type:版本类型 各操作特有元数据 1、index | create 1)pipeline 2、update 1)retry_on_conflict :更新冲突时重试次数。 2)_source:字段过滤。 有效载荷说明 1、index | create 其有效载荷为_source字段。 2、update 其有效载荷为:partial doc, upsert and script。 3、delete 没有有效载荷。 请求格式为什么要设计成metdata+有效载体的方式,主要是为了在接收端节点(所谓的接收端节点是指收到命令的第一节点),只需解析 metadata,然后将请求直接转发给对应的数据节点。 2.3 bulk API通用特性分析2.3.1 版本管理 每一个Bulk条目拥有独自的version,存在于请求条目的item的元数据中。 2.3.2 路由 每一个Bulk条目各自生效。 2.3.3 Wait For Active Shards 通常可以设置BulkRequest#waitForActiveShards来要求Bulk批量执行之前要求处于激活的最小副本数。 2.3.4 Bulk Demo public static final void testBulk() { RestHighLevelClient client = EsClient.getClient(); try { IndexRequest indexRequest = new IndexRequest("twitter", "_doc", "12") .source(buildTwitter("dingw", "2009-11-18T14:12:12", "test bulk")); UpdateRequest updateRequest = new UpdateRequest("twitter", "_doc", "11") .doc(new IndexRequest("twitter", "_doc", "11") .source(buildTwitter("dingw", "2009-11-18T14:12:12", "test bulk update"))); BulkRequest request = new BulkRequest(); request.add(indexRequest); request.add(updateRequest); BulkResponse bulkResponse = client.bulk(request, RequestOptions.DEFAULT); for (BulkItemResponse bulkItemResponse : bulkResponse) { if (bulkItemResponse.isFailed()) { BulkItemResponse.Failure failure = bulkItemResponse.getFailure(); System.out.println(failure); continue; } DocWriteResponse itemResponse = bulkItemResponse.getResponse(); if (bulkItemResponse.getOpType() == DocWriteRequest.OpType.INDEX || bulkItemResponse.getOpType() == DocWriteRequest.OpType.CREATE) { IndexResponse indexResponse = (IndexResponse) itemResponse; System.out.println(indexRequest); } else if (bulkItemResponse.getOpType() == DocWriteRequest.OpType.UPDATE) { UpdateResponse updateResponse = (UpdateResponse) itemResponse; System.out.println(updateRequest); } else if (bulkItemResponse.getOpType() == DocWriteRequest.OpType.DELETE) { DeleteResponse deleteResponse = (DeleteResponse) itemResponse; System.out.println(deleteResponse); } } } catch (Exception e) { e.printStackTrace(); } finally { EsClient.close(client); } } 批量更新bulk api就介绍到这里了。