1. RPC相比HTTP的优势
- RPC框架一般使用长链接,不必每次通信都要3次握手,减少网络开销,
- RPC框架一般都有注册中心,有丰富的监控管理发布、下线接口、动态扩展等,对调用方来说是无感知、统一化的操作协议私密,安全性较高
- RPC协议更简单内容更小,效率更高,服务化架构、服务化治理,RPC框架是一个强力的支撑。
2. 架构图
3. 调用流程
4. 涉及过程
1. 序列化
1. 自定义协议
2. 序列化的处理要素
- 解析效率 : 序列化协议应该首要考虑的因素,像xml/json解析起来比较耗时,需要解析doom树,二进制自定义协议解析起来效率要快很多。
- 压缩率 : 同样一个对象,xml/json传输起来有大量的标签冗余信息,信息有效性低,二进制自定义协议占2用的空间相对来说会小很多。
- 扩展性与兼容性 :是否能够利于信息的扩展,并且增加字段后旧版客户端是否需要强制升级,这都是需要考虑的问题,在自定义二进制协议时候,要做好充分考虑设计。
- 可读性与可调试性 : xml/json的可读性会比二进制协议好很多,并且通过网络抓包是可以直接读取,二进制则需要反序列化才能查看其内容。
- 跨语言 : 有些序列化协议是与开发语言紧密相关的,例如dubbo的Hessian序列化协议就只能支持lava的RPC调用。例如 thrift 支持跨语言
- 通用性 : xml/json非常通用,都有很好的第三方解析库,各个语言解析起来都十分方便,二进制数据的处理方面也有Protobuf和Hessian等插件,在做设计的时候尽量做到较好的通用性。
3. 常用序列化技术
JDK原生序列化
JSON序列化
Protobuf 序列化
2. 动态代理
JDK 动态代理
Cglib 动态代理
javassist动态代理
bytebuddy字节码增强技术
对比
性能:byte buddy > jvassist > cglib > jdk
3. 服务发现发现
作用:
感知服务端的变化,获取最新服务节点的连接信息。
服务注册:
服务提供方将对外暴露的接口发布到注册中心内,注册中心为了检测服务的有效状态,一般会建立双向心跳机制。
服务订阅:
服务调用方去注册中心查找并订阅服务提供方的IP,并缓存到本地。
4. 健康检测
作用:
网络中的波动,硬件设施的老化等等。可能造成集群当中的某个节点存在问题,无法正常调用。
状态描述:
- 健康状态
- 波动状态:调用的请求有失败的请求
- 失败状态:节点直接挂掉。
解决方案:
- 阈值:设置健康阈值,统计请求成功/失败次数
- 成功率:( 成功次数 / 总次数)
- 主动检测是否存活
5. 网络IO模型
采用 NIO 构建服务端架构。
select
poll
epoll
6. 零拷贝
Netty零拷贝主要体现在:
- Netty的接收和发送ByteBuffer是采用DIRECT BUFFERS,使用堆外的直接内存(内存对象分配在JVM中堆以外的内存)进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果采用传统堆内存(HEAPBUFFERS)进行Socket读写,JM会将堆内存Buffer拷贝一份到直接内存中,然后写入Socket中。
- Netty提供了组合Buffer对象,也就是CompositeByteBuf类,可以将 ByteBuf分解为多个共享同一个存诸区域的 ByteBuf,避免了内存的拷贝。
- Netty的文件传输采用了FileRegion 中包装 N0 的 FileChannel.transferTo()方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。
7. 时间轮
为什么需要时间轮?
在Dubbo中,为增强系统的容错能力,会有相应的监听判断处理机制。比如RPC调用的超时机制的实现,消费者判断RPC调用是否超时,如果超时会将超时结果返回给应用层。在Dubbo最开始的实现中,是将所有的返回结果(DefaultFuture)都放入一个集合中,并且通过一个定时任务,每隔一定时间间隔就扫描所有的future,逐个判断是否超时。
这样的实现方式虽然比较简单,但是存在一个问题就是会有很多无意义的遍历操作开销。比如一个RPC调用的超时时间是10秒,而设置的超时判定的定时任务是2秒执行一次,那么可能会有4次左右无意义的循环检测判断操作。 为了解决上述的问题就需要引入时间轮算法。
参见:Kafka延迟任务时间轮解析 + java版源码 - 掘金 (juejin.cn)
8. 异步处理机制
consumer 如何实现异步?
server 端如何实现异步?
为了提升性能,连接请求与业务处理不会放在一个线程处理,这个就是服务端的异步化。服务端业务处理逻 辑加入异步处理机制。
在RPC 框架提供一种回调方式,让业务逻辑可以异步处理,处理完之后调用 RPC框架的回调接口。
总结
RPC框架的异步策略主要是调用端异步与服务端异步。调用端的异步就是通过 Future 方式。服务端异步则需要一种回调方式,让业务逻辑可以异步处理。这样就实现了RPC调用的全异步化
9. 负载均衡
随机
private static final List<String> SERVICE_INSTANCES = Arrays.asList("A", "B", "C");
private static Random random = new Random();
public static void main(String[] args) {
int len = SERVICE_INSTANCES.size();
for (int i = 0; i < 50; i++) {
int randomIndex = random.nextInt(len); // 生成一个0到 len - 1之间的随机数
System.out.println(SERVICE_INSTANCES.get(randomIndex));
}
}
加权随机
private static Map<String, Integer> nameAndBalance = new TreeMap<>();
static {
nameAndBalance.put("A", 2);
nameAndBalance.put("B", 3);
nameAndBalance.put("C", 5);
}
private static Random random = new Random();
public static void main(String[] args) {
//统计最后的结果是否正确
Map<String, Integer> nameAndFreq = new HashMap<>();
//计算出总的weight
int totalWeight = 0;
for (Integer value : nameAndBalance.values()) {
totalWeight += value;
}
for (int i = 0; i < 100000000; i++) {
//产生一个 生成一个 1 到 totalWeight之间的随机整数
int randomNumber = random.nextInt(totalWeight) + 1;
for (Map.Entry<String, Integer> nameAndBalance : nameAndBalance.entrySet()) {
if (randomNumber <= nameAndBalance.getValue()) {
// 权重分别是 3
// 4
// 5
// 如果 randomNumber
// 是 1 2 3 那么会命中A
// 如果是 4 5 6 7 那么会命中 B
// 如果是 8 9 10 11 12 那么会命中 C
nameAndFreq.put(nameAndBalance.getKey(), nameAndFreq.getOrDefault(nameAndBalance.getKey(), 0) + 1);
break;
} else {
randomNumber -= nameAndBalance.getValue();
}
}
}
//打印结果
System.out.println(nameAndFreq);
//{A=20005025, B=29998624, C=49996351}
}
轮询
private static Map<String, Integer> nameAndBalance = new TreeMap<>();
private static List<String> serviceInstances = Arrays.asList("A", "B", "C");
private static AtomicLong pos = new AtomicLong();
public static void main(String[] args) {
Map<String, Integer> serviceAndFreq = new HashMap<>();
int size = serviceInstances.size();
for (int i = 0; i < 10000; i++) {
int curIndex = (int) (pos.getAndIncrement() % size);
serviceAndFreq.put(serviceInstances.get(curIndex), serviceAndFreq.getOrDefault(serviceInstances.get(curIndex), 0) + 1);
}
System.out.println(serviceAndFreq);
//{A=3334, B=3333, C=3333}
}
加权轮询
private static Map<String, Integer> nameAndBalance = new TreeMap<>();
static {
nameAndBalance.put("A", 2);
nameAndBalance.put("B", 3);
nameAndBalance.put("C", 5);
}
private static List<String> serviceInstances = Arrays.asList("A", "B", "C");
private static AtomicLong pos = new AtomicLong();
public static void main(String[] args) {
Map<String, Integer> serviceAndFreq = new HashMap<>();
int size = serviceInstances.size();
//计算总的权重
int totalWeight = nameAndBalance.values().stream().mapToInt(i -> i).sum();
for (int i = 0; i < 10000; i++) {
int curIndex = (int) (pos.getAndIncrement() % totalWeight);
for (Map.Entry<String, Integer> stringIntegerEntry : nameAndBalance.entrySet()) {
if (curIndex < stringIntegerEntry.getValue()) {
serviceAndFreq.put(stringIntegerEntry.getKey(), serviceAndFreq.getOrDefault(stringIntegerEntry.getKey(), 0) + 1);
break;
} else {
curIndex -= stringIntegerEntry.getValue();
}
}
}
System.out.println(serviceAndFreq);
//{A=2000, B=3000, C=5000}
}
平滑加权轮询
加权轮询带来的问题是什么? 加权轮询的缺点就是会连续的访问某个服务器,然后该服务器可能就会处理不太过来。所以我们要把这些请求打散,假设A 2 B 3 C 5 。 我们把这些请求打散 10次 请求 呈现 2 3 5 的分布就可以了
按照下面的算法实现就好了:
currWeight, 每次挑选最大的就是此次轮询的结果。然后最大的 - 总的权重,再加上 固定权重,就得到下一轮 currWeight 然后反复执行。
private static Map<String, Integer> nameAndBalance = new TreeMap<>();
static {
nameAndBalance.put("A", 2);
nameAndBalance.put("B", 3);
nameAndBalance.put("C", 5);
}
public static void main(String[] args) {
Map<String, Integer> serviceAndFreq = new HashMap<>();
int size = nameAndBalance.size();
List<String> services = new ArrayList<>(nameAndBalance.keySet());
//初始化固定权重
int[] fixedWeight = nameAndBalance.values().stream().mapToInt(i -> i).toArray();
int[] curWeight = new int[size];
//计算总的权重
int totalWeight = nameAndBalance.values().stream().mapToInt(i -> i).sum();
for (int i = 0; i < 10000; i++) {
//curWeight + 固定权重
for (int j = 0; j < size; j++) {
curWeight[j] += fixedWeight[j];
}
//挑选出最大的及其对应的坐标
int max = Integer.MIN_VALUE;
int maxIndex = 0;
for (int j = 0; j < size; j++) {
if (curWeight[j] > max) {
max = curWeight[j];
maxIndex = j;
}
}
//更新
curWeight[maxIndex] -= totalWeight;
//根据最大的元素的坐标挑选结果
serviceAndFreq.put(services.get(maxIndex), serviceAndFreq.getOrDefault(services.get(maxIndex), 0) + 1);
}
System.out.println(serviceAndFreq);
//{A=2000, B=3000, C=5000}
}
一致性hash算法
为什么要有一致性hash算法?
现在有那么多个节点(后面统称服务器为节点,因为少一个字),要如何分配客户端的请求呢?
其实这个问题就是「负载均衡问题」。解决负载均衡问题的算法很多,不同的负载均衡算法,对应的就是不同的分配策略,适应的业务场景也不同。
最简单的方式,引入一个中间的负载均衡层,让它将外界的请求「轮流」的转发给内部的集群。比如集群有 3 个节点,外界请求有 3 个,那么每个节点都会处理 1 个请求,达到了分配请求的目的。
考虑到每个节点的硬件配置有所区别,我们可以引入权重值,将硬件配置更好的节点的权重值设高,然后根据各个节点的权重值,按照一定比重分配在不同的节点上,让硬件配置更好的节点承担更多的请求,这种算法叫做加权轮询。
加权轮询算法使用场景是建立在每个节点存储的数据都是相同的前提。所以,每次读数据的请求,访问任意一个节点都能得到结果。
但是,加权轮询算法是无法应对「分布式系统(数据分片的系统)」的,因为分布式系统中,每个节点存储的数据是不同的。
当我们想提高系统的容量,就会将数据水平切分到不同的节点来存储,也就是将数据分布到了不同的节点。比如一个分布式 KV(key-valu) 缓存系统,某个 key 应该到哪个或者哪些节点上获得,应该是确定的,不是说任意访问一个节点都可以得到缓存结果的。
因此 一致性哈希算法 就是来解决该问题的
Hash算法
哈希算法。因为对同一个关键字进行哈希计算,每次计算都是相同的值,这样就可以将某个 key 确定到一个节点了,可以满足分布式系统的负载均衡需求。
哈希算法最简单的做法就是进行取模运算,比如分布式系统中有 3 个节点,基于 hash(key) % 3 公式对数据进行了映射。
如果客户端要获取指定 key 的数据,通过下面的公式可以定位节点:
hash(key) % 3
如果经过上面这个公式计算后得到的值是 0,就说明该 key 需要去第一个节点获取。
但是有一个很致命的问题,如果节点数量发生了变化,也就是在对系统做扩容或者缩容时,必须迁移改变了映射关系的数据,否则会出现查询不到数据的问题。
举个例子,假设我们有一个由 A、B、C 三个节点组成分布式 KV 缓存系统,基于计算公式 hash(key) % 3 将数据进行了映射,每个节点存储了不同的数据:
现在有 3 个查询 key 的请求,分别查询 key-01,key-02,key-03 的数据,这三个 key 分别经过 hash() 函数计算后的值为 hash( key-01) = 6、hash( key-02) = 7、hash(key-03) = 8,然后再对这些值进行取模运算。
通过这样的哈希算法,每个 key 都可以定位到对应的节点。
当 3 个节点不能满足业务需求了,这时我们增加了一个节点,节点的数量从 3 变化为 4,意味取模哈希函数中基数的变化,这样会导致大部分映射关系改变,如下图:
比如,之前的 hash(key-01) % 3 = 0,就变成了 hash(key-01) % 4 = 2,查询 key-01 数据时,寻址到了节点 C,而 key-01 的数据是存储在节点 A 上的,不是在节点 C,所以会查询不到数据。
同样的道理,如果我们对分布式系统进行缩容,比如移除一个节点,也会因为取模哈希函数中基数的变化,可能出现查询不到数据的问题。
要解决这个问题的办法,就需要我们进行迁移数据,比如节点的数量从 3 变化为 4 时,要基于新的计算公式 hash(key) % 4 ,重新对数据和节点做映射。
假设总数据条数为 M,哈希算法在面对节点数量变化时,最坏情况下所有数据都需要迁移,所以它的数据迁移规模是 O(M) ,这样数据的迁移成本太高了。
所以,我们应该要重新想一个新的算法,来避免分布式系统在扩容或者缩容时,发生过多的数据迁移。
一致性哈希算法
一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对 2^32 进行取模运算,是一个固定的值。
我们可以把一致哈希算法是对 2^32 进行取模运算的结果值组织成一个圆环,就像钟表一样,钟表的圆可以理解成由 60 个点组成的圆,而此处我们把这个圆想象成由 2^32 个点组成的圆,这个圆环被称为哈希环,如下图:
一致性哈希要进行两步哈希:
- 第一步:对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的 IP 地址进行哈希;
- 第二步:当对数据进行存储或访问时,对数据进行哈希映射;
所以,一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上。
问题来了,对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢?
答案是,映射的结果值往顺时针的方向的找到第一个节点,就是存储该数据的节点。
举个例子,有 3 个节点经过哈希计算,映射到了如下图的位置:
接着,对要查询的 key-01 进行哈希计算,确定此 key-01 映射在哈希环的位置,然后从这个位置往顺时针的方向找到第一个节点,就是存储该 key-01 数据的节点。
比如,下图中的 key-01 映射的位置,往顺时针的方向找到第一个节点就是节点 A。
所以,当需要对指定 key 的值进行读写的时候,要通过下面 2 步进行寻址:
- 首先,对 key 进行哈希计算,确定此 key 在环上的位置;
- 然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。
知道了一致哈希寻址的方式,我们来看看,如果增加一个节点或者减少一个节点会发生大量的数据迁移吗?
假设节点数量从 3 增加到了 4,新的节点 D 经过哈希计算后映射到了下图中的位置:
你可以看到,key-01、key-03 都不受影响,只有 key-02 需要被迁移节点 D。
假设节点数量从 3 减少到了 2,比如将节点 A 移除:
你可以看到,key-02 和 key-03 不会受到影响,只有 key-01 需要被迁移节点 B。
因此,在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。
一致性hash算法的问题
但是一致性哈希算法并不保证节点能够在哈希环上分布均匀,这样就会带来一个问题,会有大量的请求集中在一个节点上。
比如,下图中 3 个节点的映射位置都在哈希环的右半边:
这时候有一半以上的数据的寻址都会找节点 A,也就是访问请求主要集中的节点 A 上,这肯定不行的呀,说好的负载均衡呢,这种情况一点都不均衡。
所以,一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题。
通过虚拟节点提高均衡度
要想解决节点能在哈希环上分配不均匀的问题,就是要有大量的节点,节点数越多,哈希环上的节点分布的就越均匀。
但问题是,实际中我们没有那么多节点。所以这个时候我们就加入虚拟节点,也就是对一个真实节点做多个副本。
具体做法是,不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。
比如对每个节点分别设置 3 个虚拟节点:
- 对节点 A 加上编号来作为虚拟节点:A-01、A-02、A-03
- 对节点 B 加上编号来作为虚拟节点:B-01、B-02、B-03
- 对节点 C 加上编号来作为虚拟节点:C-01、C-02、C-03
引入虚拟节点后,原本哈希环上只有 3 个节点的情况,就会变成有 9 个虚拟节点映射到哈希环上,哈希环上的节点数量多了 3 倍。
你可以看到,节点数量多了后,节点在哈希环上的分布就相对均匀了。这时候,如果有访问请求寻址到「A-01」这个虚拟节点,接着再通过「A-01」虚拟节点找到真实节点 A,这样请求就能访问到真实节点 A 了。
上面为了方便你理解,每个真实节点仅包含 3 个虚拟节点,这样能起到的均衡效果其实很有限。而在实际的工程中,虚拟节点的数量会大很多,比如 Nginx 的一致性哈希算法,每个权重为 1 的真实节点就含有160 个虚拟节点。
另外,虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高。
比如,当某个节点被移除时,对应该节点的多个虚拟节点均会移除,而这些虚拟节点按顺时针方向的下一个虚拟节点,可能会对应不同的真实节点,即这些不同的真实节点共同分担了节点变化导致的压力。
而且,有了虚拟节点后,还可以为硬件配置更好的节点增加权重,比如对权重更高的节点增加更多的虚拟机节点即可。
因此,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景。
代码实现
主要通过数据结构 TreeMap来模拟hash环,TreeMap的key是 虚拟节点的hashcode,value是虚拟节点,TreeMap天然有序,可以实现给定一个hashcode,查找比他大的hashcode。
public class ConsistentHashLoadBalance {
public static final String PADDING = ".";
//key 是 服务名 + url
//value 是 该接口对应的哈希环
private static final ConcurrentHashMap<String, ConsistentHashSelector> selectors
= new ConcurrentHashMap<>();
public ServiceInstance doSelect(List<ServiceInstance> serviceInstances, String path, String serviceName, List<String> paramValues) {
//生成key
String key = generateKey(path, serviceName);
StringBuilder paramValue = new StringBuilder();
//生成这次请求的参数
StringBuilder param = new StringBuilder();
for (String s : paramValues) {
paramValue.append(s);
}
//获得 这次请求的参数 其hashcode
int hashCode = param.toString().hashCode();
// 获得 服务实例列表对应的哈希值
// 也就是说如果服务实例列表没有改变过 hash值不会改变
// 如果服务实例列表有新增 or 删除 那么hash值会发生改变
int serviceInstancesHashCode = serviceInstances.hashCode();
//获得该url对应的哈希环
ConsistentHashSelector consistentHashSelector = selectors.get(key);
//每个服务实例虚拟节点个数 这个可以从网关路由规则里面获得
int replicaNumber = 160;
//一旦结点数量发生变化之后 serviceInstancesHashCode 就会发生变化
//我们就需要重新生成hash环
if (consistentHashSelector == null || serviceInstancesHashCode != consistentHashSelector.getIdentityHashCode()) {
selectors.put(key, new ConsistentHashSelector(serviceInstances, serviceInstancesHashCode, replicaNumber));
}
//根据哈希环去选择节点
return consistentHashSelector.selectByParamsHash(hashCode);
}
private String generateKey(String path, String serviceName) {
return serviceName + PADDING + path;
}
}
public class ConsistentHashSelector {
// k 哈希值 hash环里面的一个个点
private final TreeMap<Long, ServiceInstance> virtualInvokers; //虚拟结点
private final int replicaNumber;//虚拟结点个数
private final int identityHashCode;//哈希环的id
public ConsistentHashSelector(List<ServiceInstance> serviceInstances, int hashCode, int replicaNumber) {
//创建虚拟结点
this.virtualInvokers = new TreeMap<>();
//获取hash环的id
this.identityHashCode = hashCode;
//虚拟结点个数 默认是160
this.replicaNumber = replicaNumber;
//遍历所有服务列表 构造哈希环
for (ServiceInstance serviceInstance : serviceInstances) {
//对于一个invoker来说 会创建 replicaNumber 个 虚拟节点
String address = serviceInstance.getAddress();
for (int i = 0; i < replicaNumber / 4; i++) {
// 这里值得注意的是:以replicaNumber取默认值160为例,
// 假设当前遍历到的serviceInstance地址为127.0.0.1:20880,它会依次获得“127.0.0.1:208800”、
// “127.0.0.1:208801”、......、“127.0.0.1:2088040”的md5摘要,
// 在每次获得摘要之后,还会对该摘要进行四次数位级别的散列。大致可以猜到其目的应该是为了加强散列效果。
byte[] digest = md5(address + i);
for (int h = 0; h < 4; h++) {
long m = hash(digest, h);
virtualInvokers.put(m, serviceInstance);
}
}
}
}
public ServiceInstance selectByParamsHash(long hash) {
Map.Entry<Long, ServiceInstance> hash2ServiceInstance = virtualInvokers.ceilingEntry(hash);
if (hash2ServiceInstance == null) {
return virtualInvokers.firstEntry().getValue();
} else {
return hash2ServiceInstance.getValue();
}
}
}
10. 熔断
RPC应该还提供熔断的功能。
11. 优雅启动
启动预热
启动预热就是让刚启动的服务,不直接承担全部的流量,而是让它随着时间的移动慢慢增加调用次数, 最终让流量缓和运行一段时间后达到正常水平。
- 一种是服务提供方在启动的时候,主动将启动的时间发送给注册中心,
- 另一种就是注册中心来检测,将服务提供方的请求注册时间作为启动时间
调用方通过服务发现获取服务提供方的启动时间,然后进行降权,减少被负载均衡选择的概率,从而实现预热的过程。
- 如果provider运行了1分钟,那么weight为10,承担10%流量;
- 如果provider运行了2分钟,那么weight为20,承担20%流量;
- 如果provider运行了5分钟,那么weight为50,承担50%流量。
如何实现
首先要知道服务提供方的启动时间,有两种获取方法
12. 优雅下线
当服务提供方正在关闭,可以直接返回一个特定的异常给调用方。然后调用方把这个节点从健康列表挪出,并把其他请求自动重试到其他节点。如需更为完善,可以再加上主动通知机制。
如何捕获关闭事件?
在接收到结束信号时,会调用Runtime.addShutdownHook方法触发关闭钩子Java应用程序,
触发优雅关闭的情况:
- JVM主动关闭(system.exit(int);
- JVM由于资源问题退出(OOM);
- 应用程序接收到进程正常结束信号:SIGTERM或SIGINT信号。
13. 插拔式架构
上面提到的 负载均衡,序列化,注册中心等组件都应该是插拔式架构的,也就是说可以由开发者自由并且方便地去扩展。做到最修改关闭,对扩展开放。
这里可以讲一下常见的一些插拔式架构实现方式:
- JDK 提供的SPI机制(Dubbo还搞了一套自己的SPI模型,具有简单的 依赖注入,AOP功能)
- SpringBoot中的SPI机制(spring.factories)
- 借助spring容器。