Dunwu Blog

大道至简,知易行难

《Dubbo 源码解读与实战》笔记

开篇词 深入掌握 Dubbo 原理与实现,提升你的职场竞争力

Apache Dubbo是一款高性能、轻量级的开源 Java RPC 框架,它提供了三大核心能力:

  • 面向接口的远程方法调用;
  • 可靠、智能的容错和负载均衡;
  • 服务自动注册和发现能力。

Dubbo 是一个分布式服务框架,致力于提供高性能、透明化的 RPC 远程服务调用方案以及服务治理方案,以帮助我们解决微服务架构落地时的问题。

Dubbo 源码环境搭建:千里之行,始于足下

Dubbo 核心组件

Registry - 注册中心。负责服务地址的注册与查找,服务的 Provider 和 Consumer 只在启动时与注册中心交互。注册中心通过长连接感知 Provider 的存在,在 Provider 出现宕机的时候,注册中心会立即推送相关事件通知 Consumer。

Provider - 服务提供者。在它启动的时候,会向 Registry 进行注册操作,将自己服务的地址和相关配置信息封装成 URL 添加到 ZooKeeper 中。

Consumer - 服务消费者。在它启动的时候,会向 Registry 进行订阅操作。订阅操作会从 ZooKeeper 中获取 Provider 注册的 URL,并在 ZooKeeper 中添加相应的监听器。获取到 Provider URL 之后,Consumer 会根据负载均衡算法从多个 Provider 中选择一个 Provider 并与其建立连接,最后发起对 Provider 的 RPC 调用。 如果 Provider URL 发生变更,Consumer 将会通过之前订阅过程中在注册中心添加的监听器,获取到最新的 Provider URL 信息,进行相应的调整,比如断开与宕机 Provider 的连接,并与新的 Provider 建立连接。Consumer 与 Provider 建立的是长连接,且 Consumer 会缓存 Provider 信息,所以一旦连接建立,即使注册中心宕机,也不会影响已运行的 Provider 和 Consumer。

Monitor - 监控中心。用于统计服务的调用次数和调用时间。Provider 和 Consumer 在运行过程中,会在内存中统计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。监控中心在上面的架构图中并不是必要角色,监控中心宕机不会影响 Provider、Consumer 以及 Registry 的功能,只会丢失监控数据而已。

Container - 服务运行容器。

Dubbo 核心模块

  • dubbo-common 模块: Dubbo 的一个公共模块,其中有很多工具类以及公共逻辑,如 Dubbo SPI 实现、时间轮实现、动态编译器等。
  • dubbo-remoting 模块: Dubbo 的远程通信模块,其中的子模块依赖各种开源组件实现远程通信。在 dubbo-remoting-api 子模块中定义该模块的抽象概念,在其他子模块中依赖其他开源组件进行实现,例如,dubbo-remoting-netty4 子模块依赖 Netty 4 实现远程通信,dubbo-remoting-zookeeper 通过 Apache Curator 实现与 ZooKeeper 集群的交互。
  • dubbo-rpc 模块: Dubbo 中对远程调用协议进行抽象的模块,其中抽象了各种协议,依赖于 dubbo-remoting 模块的远程调用功能。dubbo-rpc-api 子模块是核心抽象,其他子模块是针对具体协议的实现,例如,dubbo-rpc-dubbo 子模块是对 Dubbo 协议的实现,依赖了 dubbo-remoting-netty4 等 dubbo-remoting 子模块。 dubbo-rpc 模块的实现中只包含一对一的调用,不关心集群的相关内容。
  • dubbo-cluster 模块: Dubbo 中负责管理集群的模块,提供了负载均衡、容错、路由等一系列集群相关的功能,最终的目的是将多个 Provider 伪装为一个 Provider,这样 Consumer 就可以像调用一个 Provider 那样调用 Provider 集群了。
  • dubbo-registry 模块: Dubbo 中负责与多种开源注册中心进行交互的模块,提供注册中心的能力。其中, dubbo-registry-api 子模块是顶层抽象,其他子模块是针对具体开源注册中心组件的具体实现,例如,dubbo-registry-zookeeper 子模块是 Dubbo 接入 ZooKeeper 的具体实现。
  • dubbo-monitor 模块: Dubbo 的监控模块,主要用于统计服务调用次数、调用时间以及实现调用链跟踪的服务。
  • dubbo-config 模块: Dubbo 对外暴露的配置都是由该模块进行解析的。例如,dubbo-config-api 子模块负责处理 API 方式使用时的相关配置,dubbo-config-spring 子模块负责处理与 Spring 集成使用时的相关配置方式。有了 dubbo-config 模块,用户只需要了解 Dubbo 配置的规则即可,无须了解 Dubbo 内部的细节。
  • dubbo-metadata 模块: Dubbo 的元数据模块。dubbo-metadata 模块的实现套路也是有一个 api 子模块进行抽象,然后其他子模块进行具体实现。
  • dubbo-configcenter 模块: Dubbo 的动态配置模块,主要负责外部化配置以及服务治理规则的存储与通知,提供了多个子模块用来接入多种开源的服务发现组件。

Dubbo 的配置总线:抓住 URL,就理解了半个 Dubbo

Dubbo 中任意的一个实现都可以抽象为一个 URL,Dubbo 使用 URL 来统一描述了所有对象和配置信息,并贯穿在整个 Dubbo 框架之中。Dubbo URL 格式如下:

1
protocol://username:password@host:port/path?key=value&key=value
  • protocol:URL 的协议。我们常见的就是 HTTP 协议和 HTTPS 协议,当然,还有其他协议,如 FTP 协议、SMTP 协议等。
  • username/password:用户名/密码。 HTTP Basic Authentication 中多会使用在 URL 的协议之后直接携带用户名和密码的方式。
  • host/port:主机/端口。在实践中一般会使用域名,而不是使用具体的 host 和 port。
  • path:请求的路径。
  • parameters:参数键值对。一般在 GET 请求中会将参数放到 URL 中,POST 请求会将参数放到请求体中。

Dubbo 中和 URL 相关的核心类:

  • URL - 定义了 URL 的结构;
  • URLBuilder, 辅助构造 URL;
  • URLStrParser, 将字符串解析成 URL 对象。

Dubbo 中的 URL 示例

URL 在 SPI 中的应用:RegistryFactory.getRegistry() 方法。

URL 在服务暴露中的应用:ZookeeperRegistry.doRegister() 方法。

URL 在服务订阅中的应用:Registry.doSubscribe() 方法

Dubbo SPI 精析,接口实现两极反转(上)

Dubbo 通过 SPI 机制来实现微内核架构,以达到 OCP 原则(即“对扩展开放,对修改封闭”的原则)。

JDK SPI 要点:

  • 在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件
  • 此文件记录了该 jar 包提供的服务接口的具体实现类

JDK SPI 源码分析

ServiceLoader.load() 方法,首先会尝试获取当前使用的 ClassLoader;查找失败后使用 SystemClassLoader;然后调用 reload() 方法。

在 reload() 方法中,首先会清理 providers 缓存(LinkedHashMap 类型的集合),该缓存用来记录 ServiceLoader 创建的实现对象,其中 Key 为实现类的完整类名,Value 为实现类的对象。之后创建 LazyIterator 迭代器,用于读取 SPI 配置文件并实例化实现类对象。

Dubbo SPI 精析,接口实现两极反转(下)

Dubbo 按照 SPI 配置文件的用途,将其分成了三类目录。

  • META-INF/services/ 目录:该目录下的 SPI 配置文件用来兼容 JDK SPI 。
  • META-INF/dubbo/ 目录:该目录用于存放用户自定义 SPI 配置文件。
  • META-INF/dubbo/internal/ 目录:该目录用于存放 Dubbo 内部使用的 SPI 配置文件。

Dubbo 将 SPI 配置文件改成了 KV 格式,例如:

1
dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol

SPI 核心实现

@SPI 注解

Dubbo SPI 的核心逻辑几乎都封装在 ExtensionLoader 之中。

ExtensionLoader 中三个核心的静态字段。

  • strategies(LoadingStrategy[]类型): LoadingStrategy 接口有三个实现(通过 JDK SPI 方式加载的),分别对应前面介绍的三个 Dubbo SPI 配置文件所在的目录
  • EXTENSION_LOADERS(ConcurrentMap<Class, ExtensionLoader>类型) :Dubbo 中一个扩展接口对应一个 ExtensionLoader 实例,该集合缓存了全部 ExtensionLoader 实例,其中的 Key 为扩展接口,Value 为加载其扩展实现的 ExtensionLoader 实例。
  • EXTENSION_INSTANCES(ConcurrentMap<Class<?>, Object>类型):该集合缓存了扩展实现类与其实例对象的映射关系。在前文示例中,Key 为 Class,Value 为 DubboProtocol 对象。

海量定时任务,一个时间轮搞定

时间轮是一种高效的、批量管理定时任务的调度模型。时间轮一般会实现成一个环形结构,类似一个时钟,分为很多槽,一个槽代表一个时间间隔,每个槽使用双向链表存储定时任务;指针周期性地跳动,跳动到一个槽位,就执行该槽位的定时任务。

需要注意的是,单层时间轮的容量和精度都是有限的,对于精度要求特别高、时间跨度特别大或是海量定时任务需要调度的场景,通常会使用多级时间轮以及持久化存储与时间轮结合的方案。

核心接口和类:

  • TimerTask 接口
  • Timer 接口
  • Timeout 接口
  • HashedWheelTimeout 类
  • HashedWheelBucket 类
  • HashedWheelTimer 类

ZooKeeper 与 Curator,求你别用 ZkClient 了(上)

Dubbo 目前支持 Consul、etcd、Nacos、ZooKeeper、Redis 等多种开源组件作为注册中心,并且在 Dubbo 源码也有相应的接入模块。

ZooKeeper 是一个针对分布式系统的、可靠的、可扩展的协调服务,它通常作为统一命名服务、统一配置管理、注册中心(分布式集群管理)、分布式锁服务、Leader 选举服务等角色出现。

ZooKeeper 集群中的角色

  • Client 节点:从业务角度来看,这是分布式应用中的一个节点,通过 ZkClient 或是其他 ZooKeeper 客户端与 ZooKeeper 集群中的一个 Server 实例维持长连接,并定时发送心跳。从 ZooKeeper 集群的角度来看,它是 ZooKeeper 集群的一个客户端,可以主动查询或操作 ZooKeeper 集群中的数据,也可以在某些 ZooKeeper 节点(ZNode)上添加监听。当被监听的 ZNode 节点发生变化时,例如,该 ZNode 节点被删除、新增子节点或是其中数据被修改等,ZooKeeper 集群都会立即通过长连接通知 Client。
  • Leader 节点:ZooKeeper 集群的主节点,负责整个 ZooKeeper 集群的写操作,保证集群内事务处理的顺序性。同时,还要负责整个集群中所有 Follower 节点与 Observer 节点的数据同步。
  • Follower 节点:ZooKeeper 集群中的从节点,可以接收 Client 读请求并向 Client 返回结果,并不处理写请求,而是转发到 Leader 节点完成写入操作。另外,Follower 节点还会参与 Leader 节点的选举。
  • Observer 节点:ZooKeeper 集群中特殊的从节点,不会参与 Leader 节点的选举,其他功能与 Follower 节点相同。引入 Observer 角色的目的是增加 ZooKeeper 集群读操作的吞吐量,如果单纯依靠增加 Follower 节点来提高 ZooKeeper 的读吞吐量,那么有一个很严重的副作用,就是 ZooKeeper 集群的写能力会大大降低,因为 ZooKeeper 写数据时需要 Leader 将写操作同步给半数以上的 Follower 节点。引入 Observer 节点使得 ZooKeeper 集群在写能力不降低的情况下,大大提升了读操作的吞吐量。

ZNode 节点类型有如下四种:

  • 持久节点。 持久节点创建后,会一直存在,不会因创建该节点的 Client 会话失效而删除。
  • 持久顺序节点。 持久顺序节点的基本特性与持久节点一致,创建节点的过程中,ZooKeeper 会在其名字后自动追加一个单调增长的数字后缀,作为新的节点名。
  • 临时节点。 创建临时节点的 ZooKeeper Client 会话失效之后,其创建的临时节点会被 ZooKeeper 集群自动删除。与持久节点的另一点区别是,临时节点下面不能再创建子节点。
  • 临时顺序节点。 基本特性与临时节点一致,创建节点的过程中,ZooKeeper 会在其名字后自动追加一个单调增长的数字后缀,作为新的节点名。

ZooKeeper 与 Curator,求你别用 ZkClient 了(下)

代理模式与常见实现

代理模式

JDK 动态代理

JDK 动态代理的核心是 InvocationHandler 接口。

CGLIB

CGLib(Code Generation Library)是一个基于 ASM 的字节码生成库。它允许我们在运行时对字节码进行修改和动态生成。CGLib 采用字节码技术实现动态代理功能,其底层原理是通过字节码技术为目标类生成一个子类,并在该子类中采用方法拦截的方式拦截所有父类方法的调用,从而实现代理的功能。

因为 CGLib 使用生成子类的方式实现动态代理,所以无法代理 final 关键字修饰的方法(因为 final 方法是不能够被重写的)。这样的话,CGLib 与 JDK 动态代理之间可以相互补充:在目标类实现接口时,使用 JDK 动态代理创建代理对象;当目标类没有实现接口时,使用 CGLib 实现动态代理的功能。在 Spring、MyBatis 等多种开源框架中,都可以看到 JDK 动态代理与 CGLib 结合使用的场景。

CGLib 的实现有两个重要的成员组成。

  • Enhancer:指定要代理的目标对象以及实际处理代理逻辑的对象,最终通过调用 create() 方法得到代理对象,对这个对象所有的非 final 方法的调用都会转发给 MethodInterceptor 进行处理。
  • MethodInterceptor:动态代理对象的方法调用都会转发到 intercept 方法进行增强。

Javassist

Javassist 是一个开源的生成 Java 字节码的类库,其主要优点在于简单、快速,直接使用 Javassist 提供的 Java API 就能动态修改类的结构,或是动态生成类。

Netty 入门,用它做网络编程都说好(上)

Netty I/O 模型设计

传统阻塞 I/O 模型

I/O 多路复用模型

针对传统的阻塞 I/O 模型的缺点,I/O 复用的模型在性能方面有不小的提升。I/O 复用模型中的多个连接会共用一个 Selector 对象,由 Selector 感知连接的读写事件,而此时的线程数并不需要和连接数一致,只需要很少的线程定期从 Selector 上查询连接的读写状态即可,无须大量线程阻塞等待连接。当某个连接有新的数据可以处理时,操作系统会通知线程,线程从阻塞状态返回,开始进行读写操作以及后续的业务逻辑处理。

Netty 就是采用了上述 I/O 复用的模型。由于多路复用器 Selector 的存在,可以同时并发处理成百上千个网络连接,大大增加了服务器的处理能力。另外,Selector 并不会阻塞线程,也就是说当一个连接不可读或不可写的时候,线程可以去处理其他可读或可写的连接,这就充分提升了 I/O 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程切换。

Netty 线程模型设计

Netty 采用了 Reactor 线程模型的设计。 Reactor 模式,也被称为 Dispatcher 模式,核心原理是 Selector 负责监听 I/O 事件,在监听到 I/O 事件之后,分发(Dispatch)给相关线程进行处理

单 Reactor 单线程

Reactor 对象监听客户端请求事件,收到事件后通过 Dispatch 进行分发。如果是连接建立的事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接建立之后的业务请求。如果不是连接建立的事件,而是数据的读写事件,则 Reactor 会将事件分发对应的 Handler 来处理,由这里唯一的线程调用 Handler 对象来完成读取数据、业务处理、发送响应的完整流程。当然,该过程中也可能会出现连接不可读或不可写等情况,该单线程会去执行其他 Handler 的逻辑,而不是阻塞等待。

单 Reactor 单线程的优点就是:线程模型简单,没有引入多线程,自然也就没有多线程并发和竞争的问题。

但其缺点也非常明显,那就是性能瓶颈问题,一个线程只能跑在一个 CPU 上,能处理的连接数是有限的,无法完全发挥多核 CPU 的优势。一旦某个业务逻辑耗时较长,这唯一的线程就会卡在上面,无法处理其他连接的请求,程序进入假死的状态,可用性也就降低了。正是由于这种限制,一般只会在客户端使用这种线程模型。

单 Reactor 多线程

在单 Reactor 多线程的架构中,Reactor 监控到客户端请求之后,如果连接建立的请求,则由 Acceptor 通过 accept 处理,然后创建一个 Handler 对象处理连接建立之后的业务请求。如果不是连接建立请求,则 Reactor 会将事件分发给调用连接对应的 Handler 来处理。到此为止,该流程与单 Reactor 单线程的模型基本一致,唯一的区别就是执行 Handler 逻辑的线程隶属于一个线程池

很明显,单 Reactor 多线程的模型可以充分利用多核 CPU 的处理能力,提高整个系统的吞吐量,但引入多线程模型就要考虑线程并发、数据共享、线程调度等问题。在这个模型中,只有一个线程来处理 Reactor 监听到的所有 I/O 事件,其中就包括连接建立事件以及读写事件,当连接数不断增大的时候,这个唯一的 Reactor 线程也会遇到瓶颈。

主从 Reactor 多线程

为了解决单 Reactor 多线程模型中的问题,我们可以引入多个 Reactor。其中,Reactor 主线程负责通过 Acceptor 对象处理 MainReactor 监听到的连接建立事件,当 Acceptor 完成网络连接的建立之后,MainReactor 会将建立好的连接分配给 SubReactor 进行后续监听。

当一个连接被分配到一个 SubReactor 之上时,会由 SubReactor 负责监听该连接上的读写事件。当有新的读事件(OP_READ)发生时,Reactor 子线程就会调用对应的 Handler 读取数据,然后分发给 Worker 线程池中的线程进行处理并返回结果。待处理结束之后,Handler 会根据处理结果调用 send 将响应返回给客户端,当然此时连接要有可写事件(OP_WRITE)才能发送数据。

主从 Reactor 多线程的设计模式解决了单一 Reactor 的瓶颈。主从 Reactor 职责明确,主 Reactor 只负责监听连接建立事件,SubReactor 只负责监听读写事件。整个主从 Reactor 多线程架构充分利用了多核 CPU 的优势,可以支持扩展,而且与具体的业务逻辑充分解耦,复用性高。但不足的地方是,在交互上略显复杂,需要一定的编程门槛。

Netty 线程模型

Netty 同时支持上述几种线程模式

Netty 抽象出两组线程池:BossGroup 专门用于接收客户端的连接,WorkerGroup 专门用于网络的读写。BossGroup 和 WorkerGroup 类型都是 NioEventLoopGroup,相当于一个事件循环组,其中包含多个事件循环 ,每一个事件循环是 NioEventLoop。

NioEventLoop 表示一个不断循环的、执行处理任务的线程,每个 NioEventLoop 都有一个 Selector 对象与之对应,用于监听绑定在其上的连接,这些连接上的事件由 Selector 对应的这条线程处理。每个 NioEventLoopGroup 可以含有多个 NioEventLoop,也就是多个线程。

每个 Boss NioEventLoop 会监听 Selector 上连接建立的 accept 事件,然后处理 accept 事件与客户端建立网络连接,生成相应的 NioSocketChannel 对象,一个 NioSocketChannel 就表示一条网络连接。之后会将 NioSocketChannel 注册到某个 Worker NioEventLoop 上的 Selector 中。

每个 Worker NioEventLoop 会监听对应 Selector 上的 read/write 事件,当监听到 read/write 事件的时候,会通过 Pipeline 进行处理。一个 Pipeline 与一个 Channel 绑定,在 Pipeline 上可以添加多个 ChannelHandler,每个 ChannelHandler 中都可以包含一定的逻辑,例如编解码等。Pipeline 在处理请求的时候,会按照我们指定的顺序调用 ChannelHandler。

Netty 入门,用它做网络编程都说好(下)

Channel

Channel 是 Netty 对网络连接的抽象,核心功能是执行网络 I/O 操作。不同协议、不同阻塞类型的连接对应不同的 Channel 类型。

常用的 NIO Channel 类型。

  • NioSocketChannel:对应异步的 TCP Socket 连接。
  • NioServerSocketChannel:对应异步的服务器端 TCP Socket 连接。
  • NioDatagramChannel:对应异步的 UDP 连接。

ChannelFuture

Selector

Selector 是对多路复用器的抽象,也是 Java NIO 的核心基础组件之一。Netty 就是基于 Selector 对象实现 I/O 多路复用的,在 Selector 内部,会通过系统调用不断地查询这些注册在其上的 Channel 是否有已就绪的 I/O 事件,例如,可读事件(OP_READ)、可写事件(OP_WRITE)或是网络连接事件(OP_ACCEPT)等,而无须使用用户线程进行轮询。这样,我们就可以用一个线程监听多个 Channel 上发生的事件。

EventLoop

EventLoopGroup

简易版 RPC 框架实现(上)

简易版 RPC 框架实现(下)

本地缓存:降低 ZooKeeper 压力的一个常用手段

重试机制是网络操作的基本保证

ZooKeeper 注册中心实现,官方推荐注册中心实践

Dubbo Serialize 层:多种序列化算法,总有一款适合你

Dubbo Remoting 层核心接口分析:这居然是一套兼容所有 NIO 框架的设计?

Buffer 缓冲区:我们不生产数据,我们只是数据的搬运工

Transporter 层核心实现:编解码与线程模型一文打尽(上)

Transporter 层核心实现:编解码与线程模型一文打尽(下)

Exchange 层剖析:彻底搞懂 Request-Response 模型(上)

Exchange 层剖析:彻底搞懂 Request-Response 模型(下)

核心接口介绍,RPC 层骨架梳理

从 Protocol 起手,看服务暴露和服务引用的全流程(上)

从 Protocol 起手,看服务暴露和服务引用的全流程(下)

加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker(上)

加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker(下)

复杂问题简单化,代理帮你隐藏了多少底层细节?

加餐:HTTP 协议 + JSON-RPC,Dubbo 跨语言就是如此简单

Filter 接口,扩展 Dubbo 框架的常用手段指北

加餐:深潜 Directory 实现,探秘服务目录玄机

路由机制:请求到底怎么走,它说了算(上)

路由机制:请求到底怎么走,它说了算(下)

加餐:初探 Dubbo 动态配置的那些事儿

负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上)

负载均衡:公平公正物尽其用的负载均衡策略,这里都有(下)

集群容错:一个好汉三个帮(上)

集群容错:一个好汉三个帮(下)

加餐:多个返回值不用怕,Merger 合并器来帮忙

加餐:模拟远程调用,Mock 机制帮你搞定

加餐:一键通关服务发布全流程

加餐:服务引用流程全解析

服务自省设计方案:新版本新方案

元数据方案深度剖析,如何避免注册中心数据量膨胀?

加餐:深入服务自省方案中的服务发布订阅(上)

加餐:深入服务自省方案中的服务发布订阅(下)

配置中心设计与实现:集中化配置 and 本地化配置,我都要(上)

配置中心设计与实现:集中化配置 and 本地化配置,我都要(下)

结束语 认真学习,缩小差距

参考资料

《极客时间教程 - 分布式技术原理与算法解析》笔记

开篇词丨四纵四横,带你透彻理解分布式技术

分布式缘何而起:从单兵,到游击队,到集团军

分布式系统的指标:啥是分布式的三围

分布式互斥:有你没我,有我没你

分布式选举:国不可一日无君

分布式共识:存异求同

分布式事务:Allornothing

分布式锁:关键重地,非请勿入

答疑篇:分布式技术是如何引爆人工智能的?

分布式体系结构之集中式结构:一人在上,万人在下

分布式体系结构之非集中式结构:众生平等

分布式调度架构之单体调度:物质文明、精神文明一手抓

定义:单体调度是指,一个集群中只有一个节点运行调度进程,该节点对集群中的其他节点具有访问权限,可以搜集其他节点的资源信息、节点状态等进行统一管理,同时根据用户下发的任务对资源的需求,在调度器中进行任务与资源匹配,然后根据匹配结果将任务指派给其他节点。

架构:单体调度器也叫作集中式调度器,指的是使用中心化的方式去管理资源和调度任务。

特点:单体调度器拥有全局资源视图和全局任务,可以很容易地实现对任务的约束并实施全局性的调度策略

单体调度代表:K8S、Borg 等。

分布式调度架构之两层调度:物质文明、精神文明两手抓

定义:在两层调度器中,资源的使用状态同时由中央调度器和第二层调度器管理,但中央调度器一般只负责宏观的、大规模的资源分配,业务压力比较小;第二层调度器负责任务与资源的匹配,因此第二层调度可以有多个,以支持不同的任务类型。

特点:解决了单体调度架构中,中央服务器的单点瓶颈问题;相较于单体调度而言,提升了调度效率;支持多种类型的任务。

两层调度代表:YARN、Mesos 等。

分布式调度架构之共享状态调度:物质文明、精神文明多手协商抓

定义:共享状态调度架构沿袭了单体架构的模式,通过将单体调度器分解为多个调度器,每个调度器都有全局的资源状态信息,从而实现最优的任务调度。

分布式通信之远程调用:我是你的千里眼

本地过程调用(Local Procedure Call, LPC),是指运行在同一台机器上的进程之间的互相通信。

远程过程调用(Remote Procedure Call, RPC),是指不同机器中运行的进程之间的相互通信,某一机器上运行的进程在不知道底层通信细节的情况下,就像访问本地服务一样,去调用远程机器上的服务。

分布式通信之发布订阅:送货上门

分布式通信之消息队列:货物自取

CAP 理论:这顶帽子我不想要

CAP 是指:在一个分布式系统中, 一致性、可用性和分区容错性,最多只能同时满足其中两项。

  • 一致性(C:Consistency) - 多个数据副本是否能保持一致
  • 可用性(A:Availability)- 分布式系统在面对各种异常时可以提供正常服务的能力
  • 分区容错性(P:Partition Tolerance) - 分布式系统在遇到任何网络分区故障的时候,仍然需要能对外提供一致性和可用性的服务,除非是整个网络环境都发生了故障

在分布式系统中,分区容错性必不可少,因为需要总是假设网络是不可靠的;CAP 理论实际在是要在可用性和一致性之间做权衡。

  • CP - 需要让所有节点下线成为不可用的状态,等待同步完成。
  • AP - 在同步过程中允许读取所有节点的数据,但是数据可能不一致。

分布式数据存储系统之三要素:顾客、导购与货架

数据的生产和消费

数据特征:结构化数据、半结构化数据、非结构化数据

分区和复制

数据分布方式之哈希与一致性哈希:“掐指一算”与“掐指两算”的事

分布式数据存储选型的考量维度:

数据均匀:数据存储、访问尽量均衡

数据稳定:当数据存储集群扩容或缩容时,数据分布规则应尽量稳定,不要出现大范围的数据迁移。

节点异构性:应考虑集群中不同节点硬件配置的差异,将数据承载根据配置尽量均衡

分布式数据复制技术:分身有术

数据复制是指,如何让主备数据库保持数据一致的技术。

复制技术分类

  • 同步 - 注重一致性(CP 模型)。数据更新时,主节点必须要同步所有从节点,才提交更新。
  • 异步 - 注重可用性(AP 模型)。数据更新时,主节点处理完后,直接提交更新;从节点异步进行数据的同步。
  • 半同步 - 采用折中处理。数据更新时,主节点同步部分从节点(通常为一个节点或一半节点)成功后,才提交更新。

很多分布式存储支持通过配置,切换复制策略,以满足不同场景的需要。

分布式数据之缓存技术:“身手钥钱”随身带

分布式高可靠之负载均衡:不患寡,而患不均

负载均衡(Load Balancing)是指将请求或流量均衡地分配到多个服务器或节点上,以实现资源的最优化利用和高效的响应速度。

负载均衡常见策略

  • 随机负载均衡
    • 策略 - 将请求随机分发到候选服务器
    • 特点 - 调用量越大,负载越均衡
    • 适合场景 - 适合服务器硬件相同的场景
  • 轮询负载均衡
    • 策略 - 将请求依次分发到候选服务器
    • 特点 - 请求完全均匀分发
    • 场景 - 适合服务器硬件相同的场景
  • 最小活跃数负载均衡
    • 策略 - 将请求分发到连接数/请求数最少的候选服务器
    • 特点 - 根据候选服务器当前的请求连接数,动态分配
    • 适合场景 - 适用于对系统负载较为敏感或请求连接时长相差较大的场景
  • 哈希负载均衡
    • 策略 - 根据一个 key (可以是唯一 ID、IP 等),通过哈希计算得到一个数值,用该数值在候选服务器列表的进行取模运算,得到的结果便是选中的服务器
    • 特点 - 保证特定用户总是请求到相同的服务器,若服务器宕机,会话会丢失
    • 适合场景 - 可以保证同一 IP 的客户端的请求会转发到同一台服务器上,用来实现会话粘滞(Sticky Session)
  • 一致性哈希负载均衡
    • 策略 - 相同的请求尽可能落到同一个服务器上。尽可能是指:服务器可能发生上下线,少数服务器的变化不应该影响大多数的请求。当某台候选服务器宕机时,原本发往该服务器的请求,会基于虚拟节点,平摊到其它候选服务器,不会引起剧烈变动。
    • 优点 - 加入和删除节点只影响哈希环中顺时针方向的相邻的节点,对其他节点无影响。
    • 缺点 - 加减节点会造成哈希环中部分数据无法命中。当使用少量节点时,节点变化将大范围影响哈希环中数据映射,不适合少量数据节点的分布式方案。普通的一致性哈希分区在增减节点时需要增加一倍或减去一半节点才能保证数据和负载的均衡。
    • 适合场景 - 一致性哈希可以很好的解决稳定性问题,可以将所有的存储节点排列在首尾相接的 Hash 环上,每个 key 在计算 Hash 后会顺时针找到临接的存储节点存放。而当有节点加入或退出时,仅影响该节点在 Hash 环上顺时针相邻的后续节点。

分布式高可靠之流量控制:大禹治水,在疏不在堵

分布式高可用之故障隔离:当断不断,反受其乱

分布式高可用之故障恢复:知错能改,善莫大焉

答疑篇:如何判断并解决网络分区问题?

知识串联:以购买火车票的流程串联分布式核心技术

搭建一个分布式实验环境:纸上得来终觉浅,绝知此事要躬行

特别放送丨那些你不能错过的分布式系统论文

分布式理论基础

Time, Clocks, and the Ordering of Events in a Distributed System

The Byzantine Generals Problem

Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services

CAP Twelve Years Later: How the “Rules” Have Changed

BASE: An Acid Alternative

A Simple Totally Ordered Broadcast Protocol

Virtual Time and Global States of Distributed Systems

分布式一致性算法

Paxos Made Simple

Paxos Made Practical

Paxos Made Live: An Engineering Perspective

Raft: In Search of an Understandable Consensus Algorithm

ZooKeeper: Wait-Free Coordination for Internet-Scale Systems

Using Paxos to Build a Scalable, Consistent, and Highly Available Datastore
Impossibility of Distributed Consensus With One Faulty Process

A Brief History of Consensus, 2PC and Transaction Commit

Consensus in the Presence of Partial Synchrony

分布式数据结构

Chord: A Scalable Peer-to-Peer Lookup Service for Internet Applications

Pastry: Scalable, Distributed Object Location, and Routing for Large-Scale Peerto-Peer Systems

Kademlia: A Peer-to-Peer Information System Based on the XOR Metric

A Scalable Content-Addressable Network

Ceph: A Scalable, High-Performance Distributed File System

The Log-Structured-Merge-Tree

HBase: A NoSQL Database

Tango: Distributed Data Structure over a Shared Log

分布式系统实战

The Google File System

BigTable: A Distributed Storage System for Structured Data

The Chubby Lock Service for Loosely-Coupled Distributed Systems

Finding a Needle in Haystack: Facebook’s Photo Storage

Windows Azure Storage: A Highly Available Cloud Storage Service with Strong Consistency

Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing

Scaling Distributed Machine Learning with the Parameter Server

Dremel: Interactive Analysis of Web-Scale Datasets

Pregel: A System for Large-Scale Graph Processing

Spanner: Google’s Globally-Distributed Database

Dynamo: Amazon’s Highly Available Key-value Store

S4: Distributed Stream Computing Platform

Storm @Twitter

Large-scale Cluster Management at Google with Borg

F1 - The Fault-Tolerant Distributed RDBMS Supporting Google’s Ad Business

Cassandra: A Decentralized Structured Storage System

MegaStore: Providing Scalable, Highly Available Storage for Interactive Services

Dapper, a Large-Scale Distributed Systems Tracing Infrastructure

Kafka: A distributed Messaging System for Log Processing

Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases

参考资料

微服务之注册和发现

服务注册和发现的基本原理

服务定义是服务提供者和服务消费者之间的约定,但是在微服务架构中,如何达成这个约定呢?这就依赖于服务注册和发现机制。

注册和发现的角色

在微服务架构下,服务注册和发现机制中主要有三种角色:

  • 服务提供者(RPC Server / Provider)
  • 服务消费者(RPC Client / Consumer)
  • 服务注册中心(Registry)

服务发现通常依赖于注册中心来协调服务发现的过程,其步骤如下:

  1. 服务提供者将接口信息以注册到注册中心。
  2. 服务消费者从注册中心读取和订阅服务提供者的地址信息。
  3. 如果有可用的服务,注册中心会主动通知服务消费者。
  4. 服务消费者根据可用服务的地址列表,调用服务提供者的接口。

这个过程很像是生活中的房屋租赁,房东将租房信息挂到中介公司,房客从中介公司查找租房信息。房客如果想要租房东的房子,通过中介公司牵线搭桥,联系上房东,双方谈妥签订协议,就可以正式建立起租赁关系。

主流的服务注册与发现的解决方案,主要有两种:

  • 应用内注册与发现:注册中心提供服务端和客户端的 SDK,业务应用通过引入注册中心提供的 SDK,通过 SDK 与注册中心交互,来实现服务的注册和发现。
  • 应用外注册与发现:业务应用本身不需要通过 SDK 与注册中心打交道,而是通过其他方式与注册中心交互,间接完成服务注册与发现。

应用内注册与发现

应用内注册与发现方案是:注册中心提供服务端和客户端的 SDK,业务应用通过引入注册中心提供的 SDK,通过 SDK 与注册中心交互,来实现服务的注册和发现。最典型的案例要属 Netflix 开源的 Eureka,官方架构图如下:

Eureka 的架构主要由三个重要的组件组成:

  • Eureka Server:注册中心的服务端,实现了服务信息注册、存储以及查询等功能。
  • 服务端的 Eureka Client:集成在服务端的注册中心 SDK,服务提供者通过调用 SDK,实现服务注册、反注册等功能。
  • 客户端的 Eureka Client:集成在客户端的注册中心 SDK,服务消费者通过调用 SDK,实现服务订阅、服务更新等功能。

应用外注册与发现

应用外注册与发现方案是:业务应用本身不需要通过 SDK 与注册中心打交道,而是通过其他方式与注册中心交互,间接完成服务注册与发现。最典型的案例是开源注册中心 Consul。

Consul 实现应用外服务注册和发现主要依靠三个重要的组件:

  • Consul:注册中心的服务端,实现服务注册信息的存储,并提供注册和发现服务。
  • Registrator:一个开源的第三方服务管理器项目,它通过监听服务部署的 Docker 实例是否存活,来负责服务提供者的注册和销毁。
  • Consul Template:定时从注册中心服务端获取最新的服务提供者节点列表并刷新 LB 配置(比如 Nginx 的 upstream),这样服务消费者就通过访问 Nginx 就可以获取最新的服务提供者信息。

注册中心的基本功能

从服务注册和发现的流程,可以看出,注册中心是服务发现的核心组件。常见的注册中心组件有:Nacos、Consul、Zookeeper 等。

注册中心的实现主要涉及几个问题:注册中心需要提供哪些接口,该如何部署;如何存储服务信息;如何监控服务提供者节点的存活;如果服务提供者节点有变化如何通知服务消费者,以及如何控制注册中心的访问权限。

元数据定义

构建微服务的首要问题是:服务提供者和服务消费者通信时,如何达成共识。具体来说,就是这个服务的接口名是什么?调用这个服务需要传递哪些参数?接口的返回值是什么类型?以及一些其他接口描述信息。

常见的定义服务元数据的方式有:

  • XML 文件 - 如果只是企业内部之间的服务调用,并且都是 Java 语言的话,选择 XML 配置方式是最简单的。
  • IDL 文件 - 如果企业内部存在多个跨语言服务,建议使用 IDL 文件方式进行描述服务。
  • REST API - 如果存在对外开放服务调用的情形的话,使用 REST API 方式则更加通用。

XML 文件

XML 配置方式通过在服务提供者和服务消费者之间维持一份对等的 XML 配置文件,来保证服务消费者按照服务提供者的约定来进行服务调用。在这种方式下,如果服务提供者变更了接口定义,不仅需要更新服务提供者加载的接口描述文件 server.xml,还需要同时更新服务消费者加载的接口描述文件 client.xml。但这种方式对业务代码侵入性比较高,XML 配置有变更的时候,服务消费者和服务提供者都要更新,所以适合公司内部联系比较紧密的业务之间采用。支持 XML 文件的主流 RPC 有:阿里的 Dubbo(XML 配置示例:基于 Spring XML 开发微服务应用)、微博的 Motan。

XML 文件这种方式的服务发布和引用主要分三个步骤:

(1)服务提供者定义接口,并实现接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// The demo service definition.
service DemoService {
rpc sayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
string name = 1;
}

// The response message containing the greetings
message HelloReply {
string message = 1;
}

(2)服务提供者进程启动时,通过加载 xml 配置文件将接口暴露出去。

1
2
3
4
5
6
7
8
9
10
11
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">
<dubbo:application name="demo-provider"/>
<dubbo:registry address="zookeeper://127.0.0.1:2181"/>
<dubbo:protocol name="dubbo" port="20890"/>
<bean id="demoService" class="org.apache.dubbo.samples.basic.impl.DemoServiceImpl"/>
<dubbo:service interface="org.apache.dubbo.samples.basic.api.DemoService" ref="demoService"/>
</beans>

(3)服务消费者进程启动时,通过加载 xml 配置文件来引入要调用的接口。

1
2
3
4
5
6
7
8
9
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">
<dubbo:application name="demo-consumer"/>
<dubbo:registry group="aaa" address="zookeeper://127.0.0.1:2181"/>
<dubbo:reference id="demoService" check="false" interface="org.apache.dubbo.samples.basic.api.DemoService"/>
</beans>

IDL 文件

IDL 就是接口描述语言(interface description language)的缩写,通过一种中立、通用的方式来描述接口,使得在不同的平台上运行的对象和不同语言编写的程序可以相互通信交流。也就是说,IDL 主要用于跨语言的服务之间的调用。支持 IDL 文件的主流 RPC 有:阿里的 Dubbo(XML 配置示例:IDL 定义跨语言服务),Facebook 的 Thrift,Google 的 gRPC

以 gRPC 协议为例,gRPC 协议使用 Protobuf 简称 proto 文件来定义接口名、调用参数以及返回值类型。比如文件 helloword.proto 定义了一个接口 SayHello 方法,它的请求参数是 HelloRequest,它的返回值是 HelloReply。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// The greeter service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
rpc SayHelloAgain (HelloRequest) returns (HelloReply) {}

}

// The request message containing the user's name.
message HelloRequest {
string name = 1;
}

// The response message containing the greetings
message HelloReply {
string message = 1;
}

假如服务提供者使用的是 Java 语言,那么利用 protoc 插件即可自动生成 Server 端的 Java 代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private class GreeterImpl extends GreeterGrpc.GreeterImplBase {

@Override
public void sayHello(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}

@Override
public void sayHelloAgain(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage("Hello again " + req.getName()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}

假如服务消费者使用的也是 Java 语言,那么利用 protoc 插件即可自动生成 Client 端的 Java 代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void greet(String name) {
logger.info("Will try to greet " + name + " ...");
HelloRequest request = HelloRequest.newBuilder().setName(name).build();
HelloReply response;
try {
response = blockingStub.sayHello(request);
} catch (StatusRuntimeException e) {
logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus());
return;
}
logger.info("Greeting: " + response.getMessage());
try {
response = blockingStub.sayHelloAgain(request);
} catch (StatusRuntimeException e) {
logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus());
return;
}
logger.info("Greeting: " + response.getMessage());
}

假如服务消费者使用的是其他语言,也可以利用相应的插件生成代码。

由此可见,gRPC 协议的服务描述是通过 proto 文件来定义接口的,然后再使用 protoc 来生成不同语言平台的客户端和服务端代码,从而具备跨语言服务调用能力。

有一点特别需要注意的是,在描述接口定义时,IDL 文件需要对接口返回值进行详细定义。如果接口返回值的字段比较多,并且经常变化时,采用 IDL 文件方式的接口定义就不太合适了。一方面可能会造成 IDL 文件过大难以维护,另一方面只要 IDL 文件中定义的接口返回值有变更,都需要同步所有的服务消费者都更新,管理成本就太高了。

REST API

REST API 方式主要被用作 HTTP 或者 HTTPS 协议的接口定义,即使在非微服务架构体系下,也被广泛采用。由于 HTTP 本身就是公开标准网络协议,所以几乎没有什么额外学习成本。支持 REST API 的主流 RPC 有:Eureka,下面以 Eureka 为例。

服务提供者定义接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
public class ProviderController {

private final DiscoveryClient discoveryClient;

public ProviderController(DiscoveryClient discoveryClient) {
this.discoveryClient = discoveryClient;
}

@GetMapping("/send")
public String send() {
String services = "Services: " + discoveryClient.getServices();
System.out.println(services);
return services;
}

}

服务消费者消费接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
public class ConsumerController {

private final LoadBalancerClient loadBalancerClient;
private final RestTemplate restTemplate;

public ConsumerController(LoadBalancerClient loadBalancerClient,
RestTemplate restTemplate) {
this.loadBalancerClient = loadBalancerClient;
this.restTemplate = restTemplate;
}

@GetMapping("/recv")
public String recv() {
ServiceInstance serviceInstance = loadBalancerClient.choose("eureka-provider");
String url = "http://" + serviceInstance.getHost() + ":" + serviceInstance.getPort() + "/send";
System.out.println(url);
return restTemplate.getForObject(url, String.class);
}

}

元数据存储

注册中心本质上是一个用于保存元数据的分布式存储。你如果明白了这一点,就会了解实现一个注册中心的所有要点都是围绕这个目标去构建的。

想要构建微服务,首先要解决的问题是,服务提供者如何发布一个服务,服务消费者如何引用这个服务。具体来说,就是这个服务的接口名是什么?调用这个服务需要传递哪些参数?接口的返回值是什么类型?以及一些其他接口描述信息。

服务的元数据信息通常有以下信息:

  • 服务节点信息,如 IP、端口等。
  • 接口定义,如接口名、请求参数、响应参数等。
  • 请求失败的重试次数
  • 序列化方式
  • 压缩方式
  • 通信协议
  • 等等

在具体存储时,注册中心一般会按照“服务 - 分组 - 节点信息”的层次化的结构来存储。以 ZooKeeper 为例:

  • 在 ZooKeeper 中,数据按目录层级存储,每个目录叫作 znode,并且其有一个唯一的路径标识。
  • znode 可以包含数据和子 znode。
  • znode 中的数据可以有多个版本,比如某一个 znode 下存有多个数据版本,那么查询这个路径下的数据需带上版本信息。

img

注册中心 API

既然是分布式存储,势必要提供支持读写数据的接口,也就是 API,一般来说,需要支持以下功能:

  • 服务注册接口:服务提供者通过调用服务注册接口来完成服务注册。
  • 服务反注册接口:服务提供者通过调用服务反注册接口来完成服务注销。
  • 心跳汇报接口:服务提供者通过调用心跳汇报接口完成节点存活状态上报。
  • 服务订阅接口:服务消费者通过调用服务订阅接口完成服务订阅,获取可用的服务提供者节点列表。
  • 服务变更查询接口:服务消费者通过调用服务变更查询接口,获取最新的可用服务节点列表。

除此之外,为了便于管理,注册中心还必须提供一些后台管理的 API,例如:

  • 服务查询接口:查询注册中心当前注册了哪些服务信息。
  • 服务修改接口:修改注册中心中某一服务的信息。

服务健康检测

注册中心除了要支持最基本的服务注册和服务订阅功能以外,还必须具备对服务提供者节点的健康状态检测功能,这样才能保证注册中心里保存的服务节点都是可用的。注册中心通常使用长连接或心跳探测方式检查服务健康状态

还是以 ZooKeeper 为例,它是基于 ZooKeeper 客户端和服务端的长连接和会话超时控制机制,来实现服务健康状态检测的。在 ZooKeeper 中,客户端和服务端建立连接后,会话也随之建立,并生成一个全局唯一的 Session ID。服务端和客户端维持的是一个长连接,在 SESSION_TIMEOUT 周期内,服务端会检测与客户端的链路是否正常,具体方式是通过客户端定时向服务端发送心跳消息(ping 消息),服务器重置下次 SESSION_TIMEOUT 时间。如果超过 SESSION_TIMEOUT 后服务端都没有收到客户端的心跳消息,则服务端认为这个 Session 就已经结束了,ZooKeeper 就会认为这个服务节点已经不可用,将会从注册中心中删除其信息。

服务状态变更通知

一旦注册中心探测到有服务提供者节点新加入或者被剔除,就必须立刻通知所有订阅该服务的服务消费者,刷新本地缓存的服务节点信息,确保服务调用不会请求不可用的服务提供者节点。注册中心通常基于服务状态订阅来实现服务状态变更通知。

继续以 ZooKeeper 为例,基于 ZooKeeper 的 Watcher 机制,来实现服务状态变更通知给服务消费者的。服务消费者在调用 ZooKeeper 的 getData 方法订阅服务时,还可以通过监听器 Watcher 的 process 方法获取服务的变更,然后调用 getData 方法来获取变更后的数据,刷新本地缓存的服务节点信息。

集群部署

注册中心作为服务提供者和服务消费者之间沟通的桥梁,它的重要性不言而喻。所以注册中心一般都是采用集群部署来保证高可用性,并通过分布式一致性协议来确保集群中不同节点之间的数据保持一致。根据 CAP 理论,三种特性无法同时达成,必须在可用性和一致性之间做取舍。于是,根据不同侧重点,注册中心可以分为 CP 和 AP 两个阵营:

  • CP 型注册中心 - 牺牲可用性来换取数据强一致性,最典型的例子就是 ZooKeeper,etcd,Consul 了。ZooKeeper 集群内只有一个 Leader,而且在 Leader 无法使用的时候通过 Paxos 算法选举出一个新的 Leader。这个 Leader 的目的就是保证写信息的时候只向这个 Leader 写入,Leader 会同步信息到 Followers,这个过程就可以保证数据的强一致性。但如果多个 ZooKeeper 之间网络出现问题,造成出现多个 Leader,发生脑裂的话,注册中心就不可用了。而 etcd 和 Consul 集群内都是通过 Raft 协议来保证强一致性,如果出现脑裂的话, 注册中心也不可用。
  • AP 型注册中心 - 牺牲一致性(只保证最终一致性)来换取可用性,最典型的例子就是 Eureka 了。对比下 Zookeeper,Eureka 不用选举一个 Leader,每个 Eureka 服务器单独保存服务注册地址,因此有可能出现数据信息不一致的情况。但是当网络出现问题的时候,每台服务器都可以完成独立的服务。

以开源注册中心 ZooKeeper 为例,ZooKeeper 集群中包含多个节点,服务提供者和服务消费者可以同任意一个节点通信,因为它们的数据一定是相同的,这是为什么呢?这就要从 ZooKeeper 的工作原理说起:

  • 每个 Server 在内存中存储了一份数据,Client 的读请求可以请求任意一个 Server。
  • ZooKeeper 启动时,将从实例中选举一个 leader(Paxos 协议)。
  • Leader 负责处理数据更新等操作(ZAB 协议)。
  • 一个更新操作成功,当且仅当大多数 Server 在内存中成功修改 。

通过上面这种方式,ZooKeeper 保证了高可用性以及数据一致性。

img

注册中心的扩展功能

多注册中心

对于服务消费者来说,要能够同时从多个注册中心订阅服务;

对于服务提供者来说,要能够同时向多个注册中心注册服务。

并行订阅服务

如果只支持串行订阅,如果服务消费者订阅的服务较多,并且某些服务节点的初始化连接过程中出现连接超时的情况,则后续所有的服务节点的初始化连接都需要等待它完成,这就会导致消费者启动非常慢。

可以每订阅一个服务就单独用一个线程来处理,这样的话即使遇到个别服务节点连接超时,其他服务节点的初始化连接也不受影响,最慢也就是这个服务节点的初始化连接耗费的时间,最终所有服务节点的初始化连接耗时控制在了 30 秒以内。

批量注销服务

在与注册中心的多次交互中,可能由于网络抖动、注册中心集群异常等原因,导致个别调用失败。对于注册中心来说,偶发的注册调用失败对服务调用基本没有影响,其结果顶多就是某一个服务少了一个可用的节点。但偶发的反注册调用失败会导致不可用的节点残留在注册中心中,变成“僵尸节点”。

需要定时去清理注册中心中的“僵尸节点”,如果支持批量注销服务,就可以一次调用就把该节点上提供的所有服务同时注销掉。

服务变更信息增量更新

为了减少服务消费者从注册中心中拉取的服务可用节点信息的数据量,这个时候可以通过增量更新的方式,注册中心只返回变化的那部分节点信息。尤其在只有少数节点信息变更时,此举可以大大减少服务消费者从注册中心拉取的数据量,从而最大程度避免产生网络风暴。

心跳开关保护机制

在网络频繁抖动的情况下,注册中心中可用的节点会不断变化,这时候服务消费者会频繁收到服务提供者节点变更的信息,于是就不断地请求注册中心来拉取最新的可用服务节点信息。当有成百上千个服务消费者,同时请求注册中心获取最新的服务提供者的节点信息时,可能会把注册中心的带宽给占满,尤其是注册中心是百兆网卡的情况下。

所以针对这种情况,需要一种保护机制,即使在网络频繁抖动的时候,服务消费者也不至于同时去请求注册中心获取最新的服务节点信息

我曾经就遇到过这种情况,一个可行的解决方案就是给注册中心设置一个开关,当开关打开时,即使网络频繁抖动,注册中心也不会通知所有的服务消费者有服务节点信息变更,比如只给 10% 的服务消费者返回变更,这样的话就能将注册中心的请求量减少到原来的 1/10。

当然打开这个开关也是有一定代价的,它会导致服务消费者感知最新的服务节点信息延迟,原先可能在 10s 内就能感知到服务提供者节点信息的变更,现在可能会延迟到几分钟,所以在网络正常的情况下,开关并不适合打开;可以作为一个紧急措施,在网络频繁抖动的时候,才打开这个开关。

服务节点摘除保护机制

服务提供者在进程启动时,会注册服务到注册中心,并每隔一段时间,汇报心跳给注册中心,以标识自己的存活状态。如果隔了一段固定时间后,服务提供者仍然没有汇报心跳给注册中心,注册中心就会认为该节点已经处于“dead”状态,于是从服务的可用节点信息中移除出去。

如果遇到网络问题,大批服务提供者节点汇报给注册中心的心跳信息都可能会传达失败,注册中心就会把它们都从可用节点列表中移除出去,造成剩下的可用节点难以承受所有的调用,引起“雪崩”。但是这种情况下,可能大部分服务提供者节点是可用的,仅仅因为网络原因无法汇报心跳给注册中心就被“无情”的摘除了。

这个时候就需要根据实际业务的情况,设定一个阈值比例,即使遇到刚才说的这种情况,注册中心也不能摘除超过这个阈值比例的节点

这个阈值比例可以根据实际业务的冗余度来确定,我通常会把这个比例设定在 20%,就是说注册中心不能摘除超过 20% 的节点。因为大部分情况下,节点的变化不会这么频繁,只有在网络抖动或者业务明确要下线大批量节点的情况下才有可能发生。而业务明确要下线大批量节点的情况是可以预知的,这种情况下可以关闭阈值保护;而正常情况下,应该打开阈值保护,以防止网络抖动时,大批量可用的服务节点被摘除。

白名单机制

在实际的微服务测试和部署时,通常包含多套环境,比如生产环境一套、测试环境一套。开发在进行业务自测、测试在进行回归测试时,一般都是用测试环境,部署的 RPC Server 节点注册到测试的注册中心集群。但经常会出现开发或者测试在部署时,错误的把测试环境下的服务节点注册到了线上注册中心集群,这样的话线上流量就会调用到测试环境下的 RPC Server 节点,可能会造成意想不到的后果。

为了防止这种情况发生,注册中心需要提供一个保护机制,你可以把注册中心想象成一个带有门禁的房间,只有拥有门禁卡的 RPC Server 才能进入。在实际应用中,注册中心可以提供一个白名单机制,只有添加到注册中心白名单内的 RPC Server,才能够调用注册中心的注册接口,这样的话可以避免测试环境中的节点意外跑到线上环境中去。

静态注册中心

因为服务提供者是向服务消费者提供服务的,服务是否可用,服务消费者应该比注册中心更清楚。因此,可以直接在服务消费者端,根据调用服务提供者是否成功来判定服务提供者是否可用。如果服务消费者调用某一个服务提供者节点连续失败超过一定次数,可以在本地内存中将这个节点标记为不可用。并且每隔一段固定时间,服务消费者都要向标记为不可用的节点发起保活探测,如果探测成功了,就将标记为不可用的节点再恢复为可用状态,重新发起调用。

参考资料

微服务之服务调用

RPC 简介

通过注册中心,服务消费者和服务提供者就可以感知彼此。但是,要实现交互还必须解决通信问题。

在单体应用中,一次服务调用发生在同一台机器上的同一个进程内部,因此也被称为本地方法调用。在微服务应用中,由于服务提供者和服务消费者运行在不同物理机器上的不同进程内,因此也被称为远程方法调用,简称 RPC(Remote Procedure Call)

RPC 是微服务架构的基石,它提供了一种应用间通信的方式。RPC 的主要作用是:

  • 屏蔽远程调用跟本地调用的差异,让用户像调用本地一样去调用远程方法。
  • 隐藏底层网络通信的复杂性,让用户更聚焦于业务逻辑。

RPC 核心原理

RPC 是如何像本地方法调用一样,完成一次请求处理的呢?我们不妨推导一二。首先,服务消费者和服务提供者通常位于网络上两个不同地址,要想交换信息,必须先建立网络连接;建立网络连接后,如果要想识别彼此的信息,必须遵循相同的通信协议;服务提供者和服务消费者,需要采用某种方式数据传输;为了减少传输数据量,还要对数据进行压缩,即序列化。

它的通信流程中需要注意以下环节:

  • 传输方式:RPC 是一个远程调用,因此必然需要通过网络传输数据,且 RPC 常用于业务系统之间的数据交互,需要保证其可靠性,所以 RPC 一般默认采用 TCP 来传输。
  • 序列化:在网络中传输的数据只能是二进制数据,而 RPC 请求时,发送的都是对象。因此,请求方需要将请求参数转为二进制数据,即序列化。RPC 响应方接受到请求,要将二进制数据转换为请求参数,需要反序列化
  • 通信协议:请求方和响应方要互相识别彼此的信息,需要约定好彼此数据的格式,即协议。大多数的协议至少分成两部分,分别是数据头和消息体。数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息;消息体主要是请求的业务参数信息和扩展属性等。
  • 动态代理:为了屏蔽底层通信细节,使用户聚焦自身业务,因此 RPC 框架一般引入了动态代理,通过依赖注入等技术,拦截方法调用,完成远程调用的通信逻辑。

下图诠释了以上环节是如何串联起来的:

通信协议

通信协议的作用

只有二进制才能在网络中传输,所以 RPC 请求在发送到网络中之前,需要把方法调用的请求参数转成二进制;转成二进制后,写入本地 Socket 中,然后被网卡发送到网络设备中。

在传输过程中,RPC 并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包(合并的前提是同一个 TCP 连接上的数据),至于怎么拆分合并,这其中的细节会涉及到系统参数配置和 TCP 窗口大小。对于服务提供方应用来说,他会从 TCP 通道里面收到很多的二进制数据,那这时候怎么识别出哪些二进制是第一个请求的呢?

这就好比让你读一篇没有标点符号的文章,你要怎么识别出每一句话到哪里结束呢?很简单啊,我们加上标点,完成断句就好了。为了避免语义不一致的事情发生,我们就需要在发送请求的时候设定一个边界,然后在收到请求的时候按照这个设定的边界进行数据分割。这个边界语义的表达,就是我们所说的协议。

通信协议要解决的是:客户端和服务端如何建立连接、管理连接以及服务端如何处理请求的问题。

常见网络协议

HTTP 通信是基于应用层 HTTP 协议的,而 HTTP 协议又是基于传输层 TCP 协议的。一次 HTTP 通信过程就是发起一次 HTTP 调用,而一次 HTTP 调用就会建立一个 TCP 连接,经历三次握手的过程来建立连接。完成请求后,再经历一次四次挥手的过程来断开连接。

TCP 通信的过程分为四个步骤:服务器监听客户端请求连接确认数据传输。当客户端和服务端建立网络连接后,就可以发起请求了。但网络不一定总是可靠的,经常会遇到网络闪断、连接超时、服务端宕机等各种异常,通常的处理手段有两种:链路存活检测断连重试

通过两种通信方式的对比,不难看出:HTTP 通信由于每次都要建立 TCP 连接,而建立连接又较为耗时,所以 HTTP 通信性能是不如 TCP 通信的

为何需要设计 RPC 协议

既然有了现成的 HTTP 协议,还有必要设计 RPC 协议吗?

有必要。因为 HTTP 这些通信标准协议,数据包中的实际请求数据相对于数据包本身要小很多,有很多无用的内容;并且 HTTP 属于无状态协议,无法将请求和响应关联,每次请求要重新建立连接。这对于高性能的 RPC 来说,HTTP 协议难以满足需求,所以有必要设计一个紧凑的私有协议

如何设计 RPC 协议

首先,必须先明确消息的边界,即确定消息的长度。因此,至少要分为:消息长度+消息内容两部分。

接下来,我们会发现,在使用过程中,仅消息长度,不足以明确通信中的很多细节:如序列化方式是怎样的?是否消息压缩?压缩格式是怎样的?如果协议发生变化,需要明确协议版本等等。

综上,一个 RPC 协议大概会由下图中的这些参数组成:

可扩展的协议

前面所述的协议属于定长协议头,那也就是说往后就不能再往协议头里加新参数了,如果加参
数就会导致线上兼容问题。

为了保证能平滑地升级改造前后的协议,我们有必要设计一种支持可扩展的协议。其关键在于让协议头支持可扩展,扩展后协议头的长度就不能定长了。那要实现读取不定长的协议头里面的内容,在这之前肯定需要一个固定的地方读取长度,所以我们需要一个固定的写入协议头的长度。整体协议就变成了三部分内容:固定部分、协议头内容、协议体内容。

序列化

有兴趣深入了解 JDK 序列化方式,可以参考:Java 序列化

由于,网络传输的数据必须是二进制数据,而调用方请求的出参、入参都是对象。因此,必须将对象转换可传输的二进制,并且要求转换算法是可逆的。

  • 序列化(serialize):序列化是将对象转换为二进制数据。
  • 反序列化(deserialize):反序列化是将二进制数据转换为对象。

序列化就是将对象转换成二进制数据的过程,而反序列就是反过来将二进制转换为对象的过程。

序列化技术

Java 领域,常见的序列化技术如下

序列化技术选型

市面上有如此多的序列化技术,那么我们在应用时如何选择呢?

序列化技术选型,需要考量的维度,根据重要性从高到低,依次有:

  • 安全性:是否存在漏洞。如果存在漏洞,就有被攻击的可能性。
  • 兼容性:版本升级后的兼容性是否很好,是否支持更多的对象类型,是否是跨平台、跨语言的。服务调用的稳定性与可靠性,要比服务的性能更加重要。
  • 性能
    • 时间开销:序列化、反序列化的耗时性能自然越小越好。
    • 空间开销:序列化后的数据越小越好,这样网络传输效率就高。
  • 易用性:类库是否轻量化,API 是否简单易懂。

鉴于以上的考量,序列化技术的选型建议如下:

  • JDK 序列化:性能较差,且有很多使用限制,不建议使用。
  • ThriftProtobuf:适用于对性能敏感,对开发体验要求不高
  • Hessian:适用于对开发体验敏感,性能有要求
  • JacksonGsonFastjson:适用于对序列化后的数据要求有良好的可读性(转为 json 、xml 形式)。

序列化问题

由于 RPC 每次通信,都要经过序列化、反序列化的过程,所以序列化方式,会直接影响 RPC 通信的性能。除了选择合适的序列化技术,如何合理使用序列化也非常重要。

RPC 序列化常见的使用不当的情况如下:

  • 对象过于复杂、庞大 - 对象过于复杂、庞大,会降低序列化、反序列化的效率,并增加传输开销,从而导致响应时延增大。

    • 过于复杂:存在多层的嵌套,比如 A 对象关联 B 对象,B 对象又聚合 C 对象,C 对象又关联聚合很多其他对象
    • 过于庞大:比如一个大 List 或者大 Map
  • 对象有复杂的继承关系 - 对象关系越复杂,就越浪费性能,同时又很容易出现序列化上的问题。大多数序列化框架在进行序列化时,如果发现类有继承关系,会不停地寻找父类,遍历属性。

  • 使用序列化框架不支持的类作为入参类 - 比如 Hessian 框架,他天然是不支持 LinkHashMap、LinkedHashSet 等,而且大多数情况下最好不要使用第三方集合类,如 Guava 中的集合类,很多开源的序列化框架都是优先支持编程语言原生的对象。因此如果入参是集合类,应尽量选用原生的、最为常用的集合类,如 HashMap、ArrayList。

序列化要点

前面已经列举了常见的序列化问题,既然明确了问题,就要针对性预防。RPC 序列化时要注意以下几点:

  1. 对象要尽量简单,没有太多的依赖关系,属性不要太多,尽量高内聚;
  2. 入参对象与返回值对象体积不要太大,更不要传太大的集合;
  3. 尽量使用简单的、常用的、开发语言原生的对象,尤其是集合类;
  4. 对象不要有复杂的继承关系,最好不要有父子类的情况。

网络传输

一次 RPC 调用,本质就是服务消费者与服务提供者间的一次网络信息交换的过程。可见,通信时 RPC 实现的核心。

常见的网络 IO 模型有:同步阻塞(BIO)、同步非阻塞(NIO)、异步非阻塞(AIO)。

同步阻塞方式(BIO)

同步阻塞方式的工作流程大致为:客户端每发一次请求,服务端就生成一个线程去处理。当客户端同时发起的请求很多时,服务端需要创建很多的线程去处理每一个请求,如果达到了系统最大的线程数瓶颈,新来的请求就没法处理了。

BIO 适用于连接数比较小的业务场景,这样的话不至于系统中没有可用线程去处理请求。这种方式写的程序也比较简单直观,易于理解。

img

同步非阻塞方式 (NIO)

同步非阻塞方式 (NIO) 的工作流程大致为:客户端每发一次请求,服务端并不是每次都创建一个新线程来处理,而是通过 I/O 多路复用技术进行处理。就是把多个 I/O 的阻塞复用到同一个 select 的阻塞上,从而使系统在单线程的情况下可以同时处理多个客户端请求。这种方式的优势是开销小,不用为每个请求创建一个线程,可以节省系统开销。

IO 多路复用

IO 多路复用(Reactor 模式)在高并发场景下使用最为广泛,很多知名软件都应用了这一技术,如:Netty、Redis、Nginx 等。什么是 IO 多路复用?字面上的理解,多路就是指多个通道,也就是多个网络连接的 IO,而复用就是指多个通道复用在一个复用器上。IO 多路复用分为 select,poll 和 epoll。

零拷贝

系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中;而拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。

img

应用进程的每一次写操作,都会把数据写到用户空间的缓冲区中,再由 CPU 将数据拷贝到系统内核的缓冲区中,之后再由 DMA 将这份数据拷贝到网卡中,最后由网卡发送出去。这里我们可以看到,一次写操作数据要拷贝两次才能通过网卡发送出去,而用户进程的读操作则是将整个流程反过来,数据同样会拷贝两次才能让应用程序读取到数据。

应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷贝,都需要 CPU 进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程),这样很浪费 CPU 和性能。

所谓的零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,可以通过一种方式,直接将数据写入内核或从内核中读取数据,再通过 DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。

img

Netty 的零拷贝偏向于用户空间中对数据操作的优化,这对处理 TCP 传输中的拆包粘包问题有着重要的意义,对应用程序处理请求数据与返回数据也有重要的意义。

Netty 框架中很多内部的 ChannelHandler 实现类,都是通过 CompositeByteBuf、slice、wrap 操作来处理 TCP 传输中的拆包与粘包问题的。

Netty 的 ByteBuffer 可以采用 Direct Buffers,使用堆外直接内存进行 Socketd 的读写
操作,最终的效果与我刚才讲解的虚拟内存所实现的效果是一样的。

Netty 还提供 FileRegion 中包装 NIO 的 FileChannel.transferTo() 方法实现了零拷
贝,这与 Linux 中的 sendfile 方式在原理上也是一样的。

NIO vs BIO

NIO 适用于连接数比较多并且请求消耗比较轻的业务场景,比如聊天服务器。这种方式相比 BIO,相对来说编程比较复杂。

BIO 与 NIO 最重要的区别是数据打包和传输的方式:BIO 以流的方式处理数据,而 NIO 以块的方式处理数据

  • 面向流的 BIO 一次处理一个字节数据:一个输入流产生一个字节数据,一个输出流消费一个字节数据。为流式数据创建过滤器非常容易,链接几个过滤器,以便每个过滤器只负责复杂处理机制的一部分。不利的一面是,面向流的 I/O 通常相当慢。
  • 面向块的 NIO 一次处理一个数据块,按块处理数据比按流处理数据要快得多。但是面向块的 NIO 缺少一些面向流的 BIO 所具有的优雅性和简单性。

img

异步非阻塞方式 (AIO)

异步非阻塞方式 (AIO) 的大致工作流程为:客户端只需要发起一个 I/O 操作然后立即返回,等 I/O 操作真正完成以后,客户端会得到 I/O 操作完成的通知,此时客户端只需要对数据进行处理就好了,不需要进行实际的 I/O 读写操作,因为真正的 I/O 读取或者写入操作已经由内核完成了。这种方式的优势是客户端无需等待,不存在阻塞等待问题。

AIO 适用于连接数比较多而且请求消耗比较重的业务场景,比如涉及 I/O 操作的相册服务器。这种方式相比另外两种,编程难度最大,程序也不易于理解。

参考资料

HBase Java API 管理功能

初始化 Admin 实例

1
2
3
Configuration conf = HBaseConfiguration.create();
Connection connection = ConnectionFactory.createConnection(conf);
Admin admin = connection.getAdmin();

管理命名空间

查看命名空间

1
2
3
4
TableName[] tableNames = admin.listTableNamesByNamespace("test");
for (TableName tableName : tableNames) {
System.out.println(tableName.getName());
}

创建命名空间

1
2
NamespaceDescriptor namespace = NamespaceDescriptor.create("test").build();
admin.createNamespace(namespace);

修改命名空间

1
2
3
4
NamespaceDescriptor namespace = NamespaceDescriptor.create("test")
.addConfiguration("Description", "Test Namespace")
.build();
admin.modifyNamespace(namespace);

删除命名空间

1
admin.deleteNamespace("test");

管理表

创建表

1
2
3
4
5
TableName tableName = TableName.valueOf("test:test");
HTableDescriptor tableDescriptor = new HTableDescriptor(tableName);
HColumnDescriptor columnDescriptor = new HColumnDescriptor(Bytes.toBytes("cf"));
tableDescriptor.addFamily(columnDescriptor);
admin.createTable(tableDescriptor);

删除表

1
admin.deleteTable(TableName.valueOf("test:test"));

修改表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 原始表
TableName tableName = TableName.valueOf("test:test");
HColumnDescriptor columnDescriptor = new HColumnDescriptor("cf1");
HTableDescriptor tableDescriptor = new HTableDescriptor(tableName)
.addFamily(columnDescriptor)
.setValue("Description", "Original Table");
admin.createTable(tableDescriptor, Bytes.toBytes(1L), Bytes.toBytes(10000L), 50);

// 修改表
HTableDescriptor newTableDescriptor = admin.getTableDescriptor(tableName);
HColumnDescriptor newColumnDescriptor = new HColumnDescriptor("cf2");
newTableDescriptor.addFamily(newColumnDescriptor)
.setMaxFileSize(1024 * 1024 * 1024L)
.setValue("Description", "Modified Table");

// 修改表必须先禁用再想修改
admin.disableTable(tableName);
admin.modifyTable(tableName, newTableDescriptor);

禁用表

需要注意:HBase 表在删除前,必须先禁用。

1
admin.disableTable(TableName.valueOf("test:test"));

启用表

1
admin.enableTable(TableName.valueOf("test:test"));

查看表是否有效

1
2
boolean isOk = admin.isTableAvailable(tableName);
System.out.println("Table available: " + isOk);

参考资料

HBase Java API 其他高级特性

计数器

HBase 提供了一种高级功能:计数器(counter)。HBase 计数器可以用于实时统计,无需延时较高的批量处理操作。HBase 有一种机制可以将列当作计数器:即读取并修改(其实就是一种 CAS 模式),其保证了在一次操作中的原子性。否则,用户需要对一行数据加锁,然后读取数据,再对当前数据做加法,最后写回 HBase 并释放行锁,这一系列操作会引起大量的资源竞争问题。

早期的 HBase 版本会在每次计数器更新操作调用一次 RPC 请求,新版本中可以在一次 RPC 请求中完成多个计数器的更新操作,但是多个计数器必须在同一行。

计数器使用 Shell 命令行

计数器不需要初始化,创建一个新列时初始值为 0,第一次 incr 操作返回 1。

计数器使用 incr 命令,增量可以是正数也可以是负数,但是必须是长整数 Long:

1
incr '<table>','<row>','<column>',['<increment-value>']

计数器使用的例子:

1
2
3
4
5
6
7
8
9
10
11
hbase(main):001:0> create 'counters','daily','weekly','monthly'
0 row(s) in 1.2260 seconds

hbase(main):002:0> incr 'counters','20190301','daily:hites',1
COUNTER VALUE = 1

hbase(main):003:0> incr'counters','20190301','daily:hites',1
COUNTER VALUE = 2

hbase(main):004:0> get_counter 'counters','20190301','daily:hites'
COUNTER VALUE = 2

需要注意的是,增加的参数必须是长整型 Long,如果按照错误的格式更新了计数器(如字符串格式),下次调用 incr 会得到错误的结果:

1
2
3
4
5
hbase(main):005:0> put 'counters','20190301','daily:clicks','1'
0 row(s) in 1.3250 seconds

hbase(main):006:0> incr'counters','20190301','daily:clicks',1
COUNTER VALUE = 3530822107858468865

单计数器

操作一个计数器,类似 shell 命令 incr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
HTable table  = new HTable(conf, "counters");

long cnt1 = table.incrementColumnValue(Bytes.toBytes("20190301"),
Bytes.toBytes("daily"),
Bytes.toBytes("hits"),
1L);

long cnt2 = table.incrementColumnValue(Bytes.toBytes("20190301"),
Bytes.toBytes("daily"),
Bytes.toBytes("hits"),
1L);

long current = table.incrementColumnValue(Bytes.toBytes("20190301"),
Bytes.toBytes("daily"),
Bytes.toBytes("hits"),
0);

多计数器

使用 Tableincrement() 方法可以操作一行的多个计数器,需要构建 Increment 实例,并且指定行键:

1
2
3
4
5
6
7
8
9
10
11
12
HTable table  = new HTable(conf, "counters");

Increment incr1 = new Increment(Bytes.toBytes("20190301"));
incr1.addColumn(Bytes.toBytes("daily"), Bytes.toBytes("clicks"),1);
incr1.addColumn(Bytes.toBytes("daily"), Bytes.toBytes("hits"), 1);
incr1.addColumn(Bytes.toBytes("weekly"), Bytes.toBytes("clicks"), 2);
incr1.addColumn(Bytes.toBytes("weekly"), Bytes.toBytes("hits"), 2);

Result result = table.increment(incr1);
for(Cell cell : result.rawCells()) {
// ...
}

Increment 类还有一种构造器:

1
Increment(byte[] row, RowLock rowLock)

rowLock 参数可选,可以设置用户自定义锁,可以限制其他写程序操作此行,但是不保证读的操作性。

连接管理

连接管理简介

在 HBase Java API 中,Connection 类代表了一个集群连接,封装了与多台服务器(Matser/Region Server)的底层连接以及与 zookeeper 的连接。Connection 通过 ConnectionFactory 类实例化,而连接的生命周期则由调用者管理,调用者必须显示调用 close() 来释放连接。Connection 是线程安全的。创建 Connection 实例的开销很高,因此一个进程只需要实例化一个 Connection 即可。

Table 接口用于对指定的 HBase 表进行 CRUD 操作。一般,通过 Connection 获取 Table 实例,用完后,调用 close() 释放连接。

Admin 接口主要用于创建、删除、查看、启用/禁用 HBase 表,以及一些其他管理操作。一般,通过 Connection 获取 Admin 实例,用完后,调用 close() 释放连接。

TableAdmin 实例都是轻量级且并非线程安全的。建议每个线程只实例化一个 TableAdmin 实例。

连接池

问题:HBase 为什么没有提供 Connection 的连接池来获取更好的性能?是否需要自定义 Connection 连接池?

答:不需要。官方对于 Connection 的使用说明中,明确指出:对于高并发多线程访问的应用程序,一个进程中只需要预先创建一个 Connection

问题:HBase 老版本中 HTablePool 为什么废弃?是否需要自定义 Table 的连接池?

答:不需要。Table 和 Admin 的连接本质上是复用 Connection,实例化是一个较为轻量级的操作,因此,并不需要缓存或池化。实际上,HBase Java API 官方就是这么建议的。

下面是管理 HBase 连接的一个正确编程模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 所有进程共用一个 connection 对象
connection = ConnectionFactory.createConnection(config);

// 每个线程使用单独的 table 对象
Table table = connection.getTable(TableName.valueOf("tableName"));
try {
...
} finally {
table.close();
}

Admin admin = connection.getAdmin();
try {
...
} finally {
admin.close();
}

参考资料

HBase 数据模型

HBase 是一个面向 的数据库管理系统,这里更为确切的而说,HBase 是一个面向 列族 的数据库管理系统。表 schema 仅定义列族,表具有多个列族,每个列族可以包含任意数量的列,列由多个单元格(cell)组成,单元格可以存储多个版本的数据,多个版本数据以时间戳进行区分。

HBase 逻辑存储结构

  • **Table**:Table 由 Row 和 Column 组成。
  • **Row**:Row 是列族(Column Family)的集合。
  • Row KeyRow Key 是用来检索记录的主键
    • Row Key 是未解释的字节数组,所以理论上,任何数据都可以通过序列化表示成字符串或二进制,从而存为 HBase 的键值。
    • 表中的行,是按照 Row Key 的字典序进行排序。这里需要注意以下两点:
      • 因为字典序对 Int 排序的结果是 1,10,100,11,12,13,14,15,16,17,18,19,2,20,21,…,9,91,92,93,94,95,96,97,98,99。如果你使用整型的字符串作为行键,那么为了保持整型的自然序,行键必须用 0 作左填充。
      • 行的一次读写操作是原子性的 (不论一次读写多少列)。
    • 所有对表的访问都要通过 Row Key,有以下三种方式:
      • 通过指定的 Row Key 进行访问;
      • 通过 Row Key 的 range 进行访问,即访问指定范围内的行;
      • 进行全表扫描。
  • **Column Family**:即列族。HBase 表中的每个列,都归属于某个列族。列族是表的 Schema 的一部分,所以列族需要在创建表时进行定义。
    • 一个表的列族必须作为表模式定义的一部分预先给出,但是新的列族成员可以随后按需加入。
    • 同一个列族的所有成员具有相同的前缀,例如 info:formatinfo:geo 都属于 info 这个列族。
  • **Column Qualifier**:列限定符。可以理解为是具体的列名,例如 info:formatinfo:geo 都属于 info 这个列族,它们的列限定符分别是 formatgeo。列族和列限定符之间始终以冒号分隔。需要注意的是列限定符不是表 Schema 的一部分,你可以在插入数据的过程中动态创建列。
  • **Column**:HBase 中的列由列族和列限定符组成,由 :(冒号) 进行分隔,即一个完整的列名应该表述为 列族名 :列限定符
  • **Cell**:Cell 是行,列族和列限定符的组合,并包含值和时间戳。HBase 中通过 row keycolumn 确定的为一个存储单元称为 Cell,你可以等价理解为关系型数据库中由指定行和指定列确定的一个单元格,但不同的是 HBase 中的一个单元格是由多个版本的数据组成的,每个版本的数据用时间戳进行区分。
    - Cell 由行和列的坐标交叉决定,是有版本的。默认情况下,版本号是自动分配的,为 HBase 插入 Cell 时的时间戳。Cell 的内容是未解释的字节数组。

  • **Timestamp**:Cell 的版本通过时间戳来索引,时间戳的类型是 64 位整型,时间戳可以由 HBase 在数据写入时自动赋值,也可以由客户显式指定。每个 Cell 中,不同版本的数据按照时间戳倒序排列,即最新的数据排在最前面。

img

HBase 物理存储结构

HBase 自动将表水平划分成区域(Region)。每个 Region 由表中 Row 的子集构成。每个 Region 由它所属的表的起始范围来表示(包含的第一行和最后一行)。初始时,一个表只有一个 Region,随着 Region 膨胀,当超过一定阈值时,会在某行的边界上分裂成两个大小基本相同的新 Region。在第一次划分之前,所有加载的数据都放在原始 Region 所在的那台服务器上。随着表变大,Region 个数也会逐渐增加。Region 是在 HBase 集群上分布数据的最小单位。

HBase 数据模型示例

下图为 HBase 中一张表的:

  • RowKey 为行的唯一标识,所有行按照 RowKey 的字典序进行排序;
  • 该表具有两个列族,分别是 personal 和 office;
  • 其中列族 personal 拥有 name、city、phone 三个列,列族 office 拥有 tel、addres 两个列。

img

图片引用自 : HBase 是列式存储数据库吗 https://www.iteblog.com/archives/2498.html

HBase 表特性

Hbase 的表具有以下特点:

  • 容量大:一个表可以有数十亿行,上百万列;
  • 面向列:数据是按照列存储,每一列都单独存放,数据即索引,在查询时可以只访问指定列的数据,有效地降低了系统的 I/O 负担;
  • 稀疏性:空 (null) 列并不占用存储空间,表可以设计的非常稀疏 ;
  • 数据多版本:每个单元中的数据可以有多个版本,按照时间戳排序,新的数据在最上面;
  • 存储类型:所有数据的底层存储格式都是字节数组 (byte[])。

参考资料

HBase Java API 高级特性之协处理器

简述

在使用 HBase 时,如果你的数据量达到了数十亿行或数百万列,此时能否在查询中返回大量数据将受制于网络的带宽,即便网络状况允许,但是客户端的计算处理也未必能够满足要求。在这种情况下,协处理器(Coprocessors)应运而生。它允许你将业务计算代码放入在 RegionServer 的协处理器中,将处理好的数据再返回给客户端,这可以极大地降低需要传输的数据量,从而获得性能上的提升。同时协处理器也允许用户扩展实现 HBase 目前所不具备的功能,如权限校验、二级索引、完整性约束等。

参考资料

HBase Java API 高级特性之过滤器

HBase 中两种主要的数据读取方法是 get()scan(),它们都支持直接访问数据和通过指定起止 row key 访问数据。此外,可以指定列族、列、时间戳和版本号来进行条件查询。它们的缺点是不支持细粒度的筛选功能。为了弥补这种不足,GetScan 支持通过过滤器(Filter)对 row key、列或列值进行过滤。

HBase 提供了一些内置过滤器,也允许用户通过继承 Filter 类来自定义过滤器。所有的过滤器都在服务端生效,称为 谓词下推。这样可以保证被过滤掉的数据不会被传到客户端。

图片来自 HBase 权威指南

HBase 过滤器层次结构的最底层是 Filter 接口和 FilterBase 抽象类。大部分过滤器都直接继承自 FilterBase

比较过滤器

所有比较过滤器均继承自 CompareFilterCompareFilterFilterBase 多了一个 compare() 方法,它需要传入参数定义比较操作的过程:比较运算符和比较器。

创建一个比较过滤器需要两个参数,分别是比较运算符比较器实例

1
2
3
4
public CompareFilter(final CompareOp compareOp,final ByteArrayComparable comparator) {
this.compareOp = compareOp;
this.comparator = comparator;
}

比较运算符

  • LESS (<)
  • LESS_OR_EQUAL (<=)
  • EQUAL (=)
  • NOT_EQUAL (!=)
  • GREATER_OR_EQUAL (>=)
  • GREATER (>)
  • NO_OP (排除所有符合条件的值)

比较运算符均定义在枚举类 CompareOperator

1
2
3
4
5
6
7
8
9
10
@InterfaceAudience.Public
public enum CompareOperator {
LESS,
LESS_OR_EQUAL,
EQUAL,
NOT_EQUAL,
GREATER_OR_EQUAL,
GREATER,
NO_OP,
}

注意:在 1.x 版本的 HBase 中,比较运算符定义在 CompareFilter.CompareOp 枚举类中,但在 2.0 之后这个类就被标识为 @deprecated ,并会在 3.0 移除。所以 2.0 之后版本的 HBase 需要使用 CompareOperator 这个枚举类。

比较器

所有比较器均继承自 ByteArrayComparable 抽象类,常用的有以下几种:

  • BinaryComparator : 使用 Bytes.compareTo(byte [],byte []) 按字典序比较指定的字节数组。
  • BinaryPrefixComparator : 按字典序与指定的字节数组进行比较,但只比较到这个字节数组的长度。
  • RegexStringComparator : 使用给定的正则表达式与指定的字节数组进行比较。仅支持 EQUALNOT_EQUAL 操作。
  • SubStringComparator : 测试给定的子字符串是否出现在指定的字节数组中,比较不区分大小写。仅支持 EQUALNOT_EQUAL 操作。
  • NullComparator :判断给定的值是否为空。
  • BitComparator :按位进行比较。

BinaryPrefixComparatorBinaryComparator 的区别不是很好理解,这里举例说明一下:

在进行 EQUAL 的比较时,如果比较器传入的是 abcd 的字节数组,但是待比较数据是 abcdefgh

  • 如果使用的是 BinaryPrefixComparator 比较器,则比较以 abcd 字节数组的长度为准,即 efgh 不会参与比较,这时候认为 abcdabcdefgh 是满足 EQUAL 条件的;
  • 如果使用的是 BinaryComparator 比较器,则认为其是不相等的。

比较过滤器种类

比较过滤器共有五个(Hbase 1.x 版本和 2.x 版本相同):

  • RowFilter :基于行键来过滤数据;
  • FamilyFilterr :基于列族来过滤数据;
  • QualifierFilterr :基于列限定符(列名)来过滤数据;
  • ValueFilterr :基于单元格 (cell) 的值来过滤数据;
  • DependentColumnFilter :指定一个参考列来过滤其他列的过滤器,过滤的原则是基于参考列的时间戳来进行筛选 。

前四种过滤器的使用方法相同,均只要传递比较运算符和运算器实例即可构建,然后通过 setFilter 方法传递给 scan

1
2
3
Filter filter  = new RowFilter(CompareOperator.LESS_OR_EQUAL,
new BinaryComparator(Bytes.toBytes("xxx")));
scan.setFilter(filter);

DependentColumnFilter 的使用稍微复杂一点,这里单独做下说明。

DependentColumnFilter

可以把 DependentColumnFilter 理解为一个 valueFilter 和一个时间戳过滤器的组合DependentColumnFilter 有三个带参构造器,这里选择一个参数最全的进行说明:

1
2
3
DependentColumnFilter(final byte [] family, final byte[] qualifier,
final boolean dropDependentColumn, final CompareOperator op,
final ByteArrayComparable valueComparator)
  • family :列族
  • qualifier :列限定符(列名)
  • dropDependentColumn :决定参考列是否被包含在返回结果内,为 true 时表示参考列被返回,为 false 时表示被丢弃
  • op :比较运算符
  • valueComparator :比较器

这里举例进行说明:

1
2
3
4
5
6
DependentColumnFilter dependentColumnFilter = new DependentColumnFilter(
Bytes.toBytes("student"),
Bytes.toBytes("name"),
false,
CompareOperator.EQUAL,
new BinaryPrefixComparator(Bytes.toBytes("xiaolan")));
  • 首先会去查找 student:name 中值以 xiaolan 开头的所有数据获得 参考数据集,这一步等同于 valueFilter 过滤器;
  • 其次再用参考数据集中所有数据的时间戳去检索其他列,获得时间戳相同的其他列的数据作为 结果数据集,这一步等同于时间戳过滤器;
  • 最后如果 dropDependentColumn 为 true,则返回 参考数据集+结果数据集,若为 false,则抛弃参考数据集,只返回 结果数据集

专用过滤器

专用过滤器通常直接继承自 FilterBase,用于更特定的场景。

单列列值过滤器 (SingleColumnValueFilter)

基于某列(参考列)的值决定某行数据是否被过滤。其实例有以下方法:

  • setFilterIfMissing(boolean filterIfMissing) :默认值为 false,即如果该行数据不包含参考列,其依然被包含在最后的结果中;设置为 true 时,则不包含;
  • setLatestVersionOnly(boolean latestVersionOnly) :默认为 true,即只检索参考列的最新版本数据;设置为 false,则检索所有版本数据。
1
2
3
4
5
6
7
SingleColumnValueFilter singleColumnValueFilter = new SingleColumnValueFilter(
"student".getBytes(),
"name".getBytes(),
CompareOperator.EQUAL,
new SubstringComparator("xiaolan"));
singleColumnValueFilter.setFilterIfMissing(true);
scan.setFilter(singleColumnValueFilter);

单列列值排除器 (SingleColumnValueExcludeFilter)

SingleColumnValueExcludeFilter 继承自上面的 SingleColumnValueFilter,过滤行为与其相反。

行键前缀过滤器 (PrefixFilter)

基于 RowKey 值决定某行数据是否被过滤。

1
2
PrefixFilter prefixFilter = new PrefixFilter(Bytes.toBytes("xxx"));
scan.setFilter(prefixFilter);

列名前缀过滤器 (ColumnPrefixFilter)

基于列限定符(列名)决定某行数据是否被过滤。

1
2
ColumnPrefixFilter columnPrefixFilter = new ColumnPrefixFilter(Bytes.toBytes("xxx"));
scan.setFilter(columnPrefixFilter);

分页过滤器 (PageFilter)

可以使用这个过滤器实现对结果按行进行分页,创建 PageFilter 实例的时候需要传入每页的行数。

1
2
3
4
public PageFilter(final long pageSize) {
Preconditions.checkArgument(pageSize >= 0, "must be positive %s", pageSize);
this.pageSize = pageSize;
}

下面的代码体现了客户端实现分页查询的主要逻辑,这里对其进行一下解释说明:

客户端进行分页查询,需要传递 startRow(起始 RowKey),知道起始 startRow 后,就可以返回对应的 pageSize 行数据。这里唯一的问题就是,对于第一次查询,显然 startRow 就是表格的第一行数据,但是之后第二次、第三次查询我们并不知道 startRow,只能知道上一次查询的最后一条数据的 RowKey(简单称之为 lastRow)。

我们不能将 lastRow 作为新一次查询的 startRow 传入,因为 scan 的查询区间是[startRow,endRow) ,即前开后闭区间,这样 startRow 在新的查询也会被返回,这条数据就重复了。

同时在不使用第三方数据库存储 RowKey 的情况下,我们是无法通过知道 lastRow 的下一个 RowKey 的,因为 RowKey 的设计可能是连续的也有可能是不连续的。

由于 Hbase 的 RowKey 是按照字典序进行排序的。这种情况下,就可以在 lastRow 后面加上 0 ,作为 startRow 传入,因为按照字典序的规则,某个值加上 0 后的新值,在字典序上一定是这个值的下一个值,对于 HBase 来说下一个 RowKey 在字典序上一定也是等于或者大于这个新值的。

所以最后传入 lastRow+0,如果等于这个值的 RowKey 存在就从这个值开始 scan,否则从字典序的下一个 RowKey 开始 scan。

25 个字母以及数字字符,字典排序如下:

1
'0' < '1' < '2' < ... < '9' < 'a' < 'b' < ... < 'z'

分页查询主要实现逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
byte[] POSTFIX = new byte[] { 0x00 };
Filter filter = new PageFilter(15);

int totalRows = 0;
byte[] lastRow = null;
while (true) {
Scan scan = new Scan();
scan.setFilter(filter);
if (lastRow != null) {
// 如果不是首行 则 lastRow + 0
byte[] startRow = Bytes.add(lastRow, POSTFIX);
System.out.println("start row: " +
Bytes.toStringBinary(startRow));
scan.withStartRow(startRow);
}
ResultScanner scanner = table.getScanner(scan);
int localRows = 0;
Result result;
while ((result = scanner.next()) != null) {
System.out.println(localRows++ + ": " + result);
totalRows++;
lastRow = result.getRow();
}
scanner.close();
//最后一页,查询结束
if (localRows == 0) break;
}
System.out.println("total rows: " + totalRows);

需要注意的是在多台 Regin Services 上执行分页过滤的时候,由于并行执行的过滤器不能共享它们的状态和边界,所以有可能每个过滤器都会在完成扫描前获取了 PageCount 行的结果,这种情况下会返回比分页条数更多的数据,分页过滤器就有失效的可能。

时间戳过滤器 (TimestampsFilter)

1
2
3
4
List<Long> list = new ArrayList<>();
list.add(1554975573000L);
TimestampsFilter timestampsFilter = new TimestampsFilter(list);
scan.setFilter(timestampsFilter);

首次行键过滤器 (FirstKeyOnlyFilter)

FirstKeyOnlyFilter 只扫描每行的第一列,扫描完第一列后就结束对当前行的扫描,并跳转到下一行。相比于全表扫描,其性能更好,通常用于行数统计的场景,因为如果某一行存在,则行中必然至少有一列。

1
2
FirstKeyOnlyFilter firstKeyOnlyFilter = new FirstKeyOnlyFilter();
scan.set(firstKeyOnlyFilter);

包装过滤器

包装过滤器就是通过包装其他过滤器以实现某些拓展的功能。

SkipFilter 过滤器

SkipFilter 包装一个过滤器,当被包装的过滤器遇到一个需要过滤的 KeyValue 实例时,则拓展过滤整行数据。下面是一个使用示例:

1
2
3
4
5
// 定义 ValueFilter 过滤器
Filter filter1 = new ValueFilter(CompareOperator.NOT_EQUAL,
new BinaryComparator(Bytes.toBytes("xxx")));
// 使用 SkipFilter 进行包装
Filter filter2 = new SkipFilter(filter1);

WhileMatchFilter 过滤器

WhileMatchFilter 包装一个过滤器,当被包装的过滤器遇到一个需要过滤的 KeyValue 实例时,WhileMatchFilter 则结束本次扫描,返回已经扫描到的结果。下面是其使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Filter filter1 = new RowFilter(CompareOperator.NOT_EQUAL,
new BinaryComparator(Bytes.toBytes("rowKey4")));

Scan scan = new Scan();
scan.setFilter(filter1);
ResultScanner scanner1 = table.getScanner(scan);
for (Result result : scanner1) {
for (Cell cell : result.listCells()) {
System.out.println(cell);
}
}
scanner1.close();

System.out.println("--------------------");

// 使用 WhileMatchFilter 进行包装
Filter filter2 = new WhileMatchFilter(filter1);

scan.setFilter(filter2);
ResultScanner scanner2 = table.getScanner(scan);
for (Result result : scanner1) {
for (Cell cell : result.listCells()) {
System.out.println(cell);
}
}
scanner2.close();
rowKey0/student:name/1555035006994/Put/vlen=8/seqid=0
rowKey1/student:name/1555035007019/Put/vlen=8/seqid=0
rowKey2/student:name/1555035007025/Put/vlen=8/seqid=0
rowKey3/student:name/1555035007037/Put/vlen=8/seqid=0
rowKey5/student:name/1555035007051/Put/vlen=8/seqid=0
rowKey6/student:name/1555035007057/Put/vlen=8/seqid=0
rowKey7/student:name/1555035007062/Put/vlen=8/seqid=0
rowKey8/student:name/1555035007068/Put/vlen=8/seqid=0
rowKey9/student:name/1555035007073/Put/vlen=8/seqid=0
--------------------
rowKey0/student:name/1555035006994/Put/vlen=8/seqid=0
rowKey1/student:name/1555035007019/Put/vlen=8/seqid=0
rowKey2/student:name/1555035007025/Put/vlen=8/seqid=0
rowKey3/student:name/1555035007037/Put/vlen=8/seqid=0

可以看到被包装后,只返回了 rowKey4 之前的数据。

FilterList

以上都是讲解单个过滤器的作用,当需要多个过滤器共同作用于一次查询的时候,就需要使用 FilterListFilterList 支持通过构造器或者 addFilter 方法传入多个过滤器。

1
2
3
4
5
6
7
8
// 构造器传入
public FilterList(final Operator operator, final List<Filter> filters)
public FilterList(final List<Filter> filters)
public FilterList(final Filter... filters)

// 方法传入
public void addFilter(List<Filter> filters)
public void addFilter(Filter filter)

多个过滤器组合的结果由 operator 参数定义 ,其可选参数定义在 Operator 枚举类中。只有 MUST_PASS_ALLMUST_PASS_ONE 两个可选的值:

  • MUST_PASS_ALL :相当于 AND,必须所有的过滤器都通过才认为通过;
  • MUST_PASS_ONE :相当于 OR,只有要一个过滤器通过则认为通过。
1
2
3
4
5
6
7
@InterfaceAudience.Public
public enum Operator {
/** !AND */
MUST_PASS_ALL,
/** !OR */
MUST_PASS_ONE
}

使用示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
List<Filter> filters = new ArrayList<Filter>();

Filter filter1 = new RowFilter(CompareOperator.GREATER_OR_EQUAL,
new BinaryComparator(Bytes.toBytes("XXX")));
filters.add(filter1);

Filter filter2 = new RowFilter(CompareOperator.LESS_OR_EQUAL,
new BinaryComparator(Bytes.toBytes("YYY")));
filters.add(filter2);

Filter filter3 = new QualifierFilter(CompareOperator.EQUAL,
new RegexStringComparator("ZZZ"));
filters.add(filter3);

FilterList filterList = new FilterList(filters);

Scan scan = new Scan();
scan.setFilter(filterList);

参考资料

HBase Schema 设计

HBase Schema 设计要素

  • 这个表应该有多少 Column Family
  • Column Family 使用什么数据
  • 每个 Column Family 有有多少列
  • 列名是什么,尽管列名不必在建表时定义,但读写数据是要知道的
  • 单元应该存放什么数据
  • 每个单元存储多少时间版本
  • 行健(rowKey)结构是什么,应该包含什么信息

Row Key 设计

Row Key 的作用

在 HBase 中,所有对表的访问都要通过 Row Key,有三种访问方式:

  • 使用 get 命令,查询指定的 Row Key,即精确查找。
  • 使用 scan 命令,根据 Row Key 进行范围查找。
  • 全表扫描,即直接扫描表中所有行记录。

此外,在 HBase 中,表中的行,是按照 Row Key 的字典序进行排序的。

由此,可见,Row Key 的良好设计对于 HBase CRUD 的性能至关重要。

Row Key 的设计原则

长度原则

RowKey 是一个二进制码流,可以是任意字符串,最大长度为 64kb,实际应用中一般为 10-100byte,以 byte[]形式保存,一般设计成定长。建议越短越好,不要超过 16 个字节,原因如下:

  1. 数据的持久化文件 HFile 中时按照 Key-Value 存储的,如果 RowKey 过长,例如超过 100byte,那么 1000w 行的记录,仅 RowKey 就需占用近 1GB 的空间。这样会极大影响 HFile 的存储效率。
  2. MemStore 会缓存部分数据到内存中,若 RowKey 字段过长,内存的有效利用率就会降低,就不能缓存更多的数据,从而降低检索效率。
  3. 目前操作系统都是 64 位系统,内存 8 字节对齐,控制在 16 字节,8 字节的整数倍利用了操作系统的最佳特性。

唯一原则

必须在设计上保证 RowKey 的唯一性。由于在 HBase 中数据存储是 Key-Value 形式,若向 HBase 中同一张表插入相同 RowKey 的数据,则原先存在的数据会被新的数据覆盖。

排序原则

HBase 的 RowKey 是按照 ASCII 有序排序的,因此我们在设计 RowKey 的时候要充分利用这点。

散列原则

设计的 RowKey 应均匀的分布在各个 HBase 节点上。

热点问题

Region 是在 HBase 集群上分布数据的最小单位。每个 Region 由它所属的表的起始范围来表示(即起始 Row Key 和结束 Row Key)。

如果,Row Key 使用单调递增的整数或时间戳,就会产生一个问题:因为 Hbase 的 Row Key 是就近存储的,这会导致一段时间内大部分读写集中在某一个 Region 或少数 Region 上(根据二八原则,最近产生的数据,往往是读写频率最高的数据),即所谓 热点问题

反转(Reversing)

第一种咱们要分析的方法是反转,顾名思义它就是把固定长度或者数字格式的 RowKey 进行反转,反转分为一般数据反转和时间戳反转,其中以时间戳反转较常见。

  • 反转固定格式的数值 - 以手机号为例,手机号的前缀变化比较少(如 152、185 等),但后半部分变化很多。如果将它反转过来,可以有效地避免热点。不过其缺点就是失去了有序性。
  • 反转时间 - 如果数据访问以查找最近的数据为主,可以将时间戳存储为反向时间戳(例如: timestamp = Long.MAX_VALUE – timestamp),这样有利于扫描最近的数据。

加盐(Salting)

这里的“加盐”与密码学中的“加盐”不是一回事。它是指在 RowKey 的前面增加一些前缀,加盐的前缀种类越多,RowKey 就被打得越散。

需要注意的是分配的随机前缀的种类数量应该和我们想把数据分散到的那些 region 的数量一致。只有这样,加盐之后的 rowkey 才会根据随机生成的前缀分散到各个 region 中,避免了热点现象。

哈希(Hashing)

其实哈希和加盐的适用场景类似,但我们前缀不可以是随机的,因为必须要让客户端能够完整地重构 RowKey。所以一般会拿原 RowKey 或其一部分计算 Hash 值,然后再对 Hash 值做运算作为前缀。

HBase Schema 设计规则

Column Family 设计

HBase 不能很好处理 2 ~ 3 个以上的 Column Family,所以 HBase 表应尽可能减少 Column Family 数。如果可以,请只使用一个列族,只有需要经常执行 Column 范围查询时,才引入多列族。也就是说,尽量避免同时查询多个列族。

  • Column Family 数量多,会影响数据刷新。HBase 的数据刷新是在每个 Region 的基础上完成的。因此,如果一个 Column Family 携带大量导致刷新的数据,那么相邻的列族即使携带的数据量很小,也会被刷新。当存在许多 Column Family 时,刷新交互会导致一堆不必要的 IO。 此外,在表/区域级别的压缩操作也会在每个存储中发生。
  • Column Family 数量多,会影响查找效率。如:Column Family A 有 100 万行,Column Family B 有 10 亿行,那么 Column Family A 的数据可能会分布在很多很多区域(和 RegionServers)。 这会降低 Column Family A 的批量扫描效率。

Column Family 名尽量简短,最好是一个字符。Column Family 会在列限定符中被频繁使用,缩短长度有利于节省空间并提升效率。

Row 设计

HBase 中的 Row 按 Row Key 的字典顺序排序

  • 不要将 Row Key 设计为单调递增的,例如:递增的整数或时间戳

    • 问题:因为 Hbase 的 Row Key 是就近存储的,这样会导致一段时间内大部分写入集中在某一个 Region 上,即所谓热点问题。

    • 解决方法一、加盐:这里的不是指密码学的加盐,而是指将随机分配的前缀添加到行键的开头。这么做是为了避免相同前缀的 Row Key 数据被存储在相邻位置,从而导致热点问题。示例如下:

      • foo0001
        foo0002
        foo0003
        foo0004
        
        改为
        
        a-foo0003
        b-foo0001
        c-foo0003
        c-foo0004
        d-foo0002
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29

        - 解决方法二、Hash:Row Key 的前缀使用 Hash

        - **尽量减少行和列的长度**

        - **反向时间戳**:反向时间戳可以极大地帮助快速找到值的最新版本。

        - **行健不能改变**:唯一可以改变的方式是先删除后插入。

        - **Row Key 和 Column Family**:Row Key 从属于 Column Family,因此,相同的 Row Key 可以存在每一个 Column Family 中而不会出现冲突。

        ### Version 设计

        最大、最小 Row 版本号:表示 HBase 会保留的版本号数的上下限。均可以通过 HColumnDescriptor 对每个列族进行配置

        Row 版本号过大,会大大增加 StoreFile 的大小;所以,最大 Row 版本号应按需设置。HBase 会在主要压缩时,删除多余的版本。

        ### TTL 设计

        Column Family 会设置一个以秒为单位的 TTL,一旦达到 TTL 时,HBase 会自动删除行记录。

        仅包含过期行的存储文件在次要压缩时被删除。 将 hbase.store.delete.expired.storefile 设置为 false 会禁用此功能。将最小版本数设置为 0 以外的值也会禁用此功能。

        在较新版本的 HBase 上,还支持在 Cell 上设置 TTL,与 Column Family 的 TTL 不同的是,单位是毫秒。

        ### Column Family 属性配置

        - HFile 数据块,默认是 64KB,数据库的大小影响数据块索引的大小。数据块大的话一次加载进内存的数据越多,扫描查询效果越好。但是数据块小的话,随机查询性能更好

create ‘mytable’,{NAME => ‘cf1’, BLOCKSIZE => ‘65536’}
复制代码

1
2
3

- 数据块缓存,数据块缓存默认是打开的,如果一些比较少访问的数据可以选择关闭缓存

create ‘mytable’,{NAME => ‘cf1’, BLOCKCACHE => ‘FALSE’}
复制代码

1
2
3

- 数据压缩,压缩会提高磁盘利用率,但是会增加 CPU 的负载,看情况进行控制

create ‘mytable’,{NAME => ‘cf1’, COMPRESSION => ‘SNAPPY’}
复制代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

Hbase 表设计是和需求相关的,但是遵守表设计的一些硬性指标对性能的提升还是很有帮助的,这里整理了一些设计时用到的要点。

## Schema 设计案例

### 案例:日志数据和时序数据

假设采集以下数据

- Hostname
- Timestamp
- Log event
- Value/message

应该如何设计 Row Key?

1TimestampRow Key 头部

如果 Row Key 设计为 `[timestamp][hostname][log-event]` 形式,会出现热点问题。

如果针对时间的扫描很重要,可以采用时间戳分桶策略,即

bucket = timestamp % bucketNum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

计算出桶号后,将 Row Key 指定为:`[bucket][timestamp][hostname][log-event]`

如上所述,要为特定时间范围选择数据,需要对每个桶执行扫描。 例如,100 个桶将在键空间中提供广泛的分布,但需要 100 次扫描才能获取单个时间戳的数据,因此需要权衡取舍。

2)Hostname 在 Row Key 头部

如果主机样本量很大,将 Row Key 设计为 `[hostname][log-event][timestamp]`,这样有利于扫描 hostname。

3Timestamp 还是反向 Timestamp

如果数据访问以查找最近的数据为主,可以将时间戳存储为反向时间戳(例如: `timestamp = Long.MAX_VALUE – timestamp`),这样有利于扫描最近的数据。

4Row Key 是可变长度还是固定长度

拼接 Row Key 的关键字长度不一定是固定的,例如 hostname 有可能很长,也有可能很短。如果想要统一长度,可以参考以下做法:

- 将关键字 Hash 编码:使用某种 Hash 算法计算关键字,并取固定长度的值(例如:8 位或 16 位)。
- 使用数字替代关键字:例如:使用事件类型 Code 替换事件类型;hostname 如果是 IP,可以转换为 long
- 截取关键字:截取后的关键字需要有足够的辨识度,长度大小根据具体情况权衡。

5)时间分片

[hostname][log-event][timestamp1]
[hostname][log-event][timestamp2]
[hostname][log-event][timestamp3]

1
2
3

上面的例子中,每个详细事件都有单独的行键,可以重写如下,即每个时间段存储一次:

[hostname][log-event][timerange]


## 参考资料

- [HBase 官方文档之 HBase and Schema Design](https://hbase.apache.org/book.html#schema)