Tallate

该吃吃该喝喝 啥事别往心里搁

消息实时性

RocketMQ 支持 pull 和 push 两种消息消费模式,但 push 是使用长轮询 Pull 的方式实现的,可保证消息非常实时,消息实时性不低于 Push。
长轮询 pull 的原理是:发起 pull 请求失败后(比如 Broker 端暂时没有可以消费的消息),先 hold 住线程并挂起该请求。

RocketMQ除了上述的准实时消息外,还支持延时消息

延时消息

RocketMQ里延时消息功能并不能指定时间,而是只能指定延时级别:

1
2
3
4
Message message = new Message("TopicTest", "TagA", ("Hello scheduled message " + i).getBytes());
// 设置延时等级3,这个消息将在10s之后发送(现在只支持固定的几个时间,详看delayTimeLevel)
message.setDelayTimeLevel(3);
producer.send(message);

原理

  1. 延时消息和普通消息一样会先被写入commitLog,但不会立刻写入consumerQueue中,而是存放到SCHEDULE_TOPIC_XXX的topic下面,并且以延时粒度作为queueId区分;
  2. 之后Broker端会有定时任务扫描SCHEDULE_TOPIC_XXX下的每个Queue,到时候后写入到consumerQueue中。

源码入口是ScheduleMessageService.start,启动时会调用:

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
public void start() {
if (started.compareAndSet(false, true)) {
this.timer = new Timer("ScheduleMessageTimerThread", true);
// 1. 根据支持的各种延迟级别,添加不同延迟时间的TimeTask
for (Map.Entry<Integer, Long> entry : this.delayLevelTable.entrySet()) {
Integer level = entry.getKey();
Long timeDelay = entry.getValue();
// 每个延迟级别对应一个offset,代表一个普通消息队列文件
Long offset = this.offsetTable.get(level);
if (null == offset) {
offset = 0L;
}

if (timeDelay != null) {
this.timer.schedule(new DeliverDelayedMessageTimerTask(level, offset), FIRST_DELAY_TIME);
}
}
// 2. 添加一个10s执行一次的TimeTask
this.timer.scheduleAtFixedRate(new TimerTask() {

@Override
public void run() {
try {
if (started.get()) ScheduleMessageService.this.persist();
} catch (Throwable e) {
log.error("scheduleAtFixedRate flush exception", e);
}
}
}, 10000, this.defaultMessageStore.getMessageStoreConfig().getFlushDelayOffsetInterval());
}
}

DeliverDelayedMessageTimerTask
ScheduleMessageService.executeOnTimeup
扫描延迟消息队列(SCHEDULE_TOPIC_XXX)的消息,将该延迟消息转换为指定的topic的消息。
1、读取不同延迟级别对应的延迟消息;
2、取得对应延迟级别读取的开始位置offset;
3、将延迟消息转换为指定topic的普通消息并存放起来。
4、修改下一次读取的offset值(修改的只是缓存),并指定下一次转换延迟消息的timetask。

ScheduleMessageService.this.persist
将延迟队列扫描处理的进度offset持久化到delayOffset.json文件中。

RocketMQ延迟队列也有一个缺点:Java中的Timer是单线程,而延迟消息的原理是Timer,也就是说当同时发送的延迟消息过多的时候一个线程处理速度一定是有瓶颈的,因此在实际项目中使用延迟消息一定不要过多依赖,只能作为一个辅助手段。

RocketMQ-消息存储
如上图所示,消息的存储分为如下 3 个部分:

  1. CommitLog:日志,存储消息主体;
  2. ConsumerQueue:在 CommitLog 中根据 Topic 和 Tag 检索消息是非常低效的,因此引入了 ConsumerQueue 作为消费消息的索引,它保存的其实是 CommitLog 中存储的消息的指针。
  3. IndexFile:hash 索引,提供一种通过 key 或时间区间来查询消息的方法。
阅读全文 »

架构

选型

消息队列 Kafka RocketMQ
适用场景 大量消息快速消费如流式计算 高性能、稳定、高可靠
热度 与周边生态系统的兼容性最好 有活跃中文社区
消息可靠传递
延迟 毫秒级 毫秒级
性能 每秒几十万 每秒几十万
消息丢失 参数优化配置后0丢失 参数优化配置后0丢失
消费模式 Pull Pull + Push(原理都是Pull)
可用性 非常高(分布式) 非常高(主从)
topic数量对吞吐量的影响 topic达到几十,几百个时,吞吐量会大幅度下降 topic达到几百,几千个时,吞吐量会有较小幅度的下降

缺点:

  • Kafka:同步收发消息的响应时延比较高,因为当客户端发送一条消息的时候,Kafka 并不会立即发送出去,而是要等一会儿攒一批再发送,在它的 Broker 中,很多地方都会使用这种“先攒一波再一起处理”的设计。当业务场景中,每秒钟消息数量没有那么多的时候,Kafka 的时延反而会比较高。所以,Kafka 不太适合在线业务场景。
  • RocketMQ:没有太明显的缺点

部署结构

RocketMQ-架构图
RocketMQ-部署图

  • 启动 NameServer,NameServer 起来后监听端口,等待 Broker、Producer、Consumer 连上来,相当于一个路由控制中心。
  • Broker 启动,跟所有的 NameServer 保持长连接,定时发送心跳包。心跳包中包含当前 Broker 信息(IP+端口等)以及存储所有 Topic 信息。注册成功后,NameServer 集群中就有 Topic 跟 Broker 的映射关系。
  • 收发消息前,先创建 Topic,创建 Topic 时需要指定该 Topic 要存储在哪些 Broker 上,也可以在发送消息时自动创建 Topic。
  • Producer 发送消息,启动时先跟 NameServer 集群中的其中一台建立长连接,并从 NameServer 中获取当前发送的 Topic 存在哪些 Broker 上,轮询从队列列表中选择一个队列,然后与队列所在的 Broker 建立长连接从而向 Broker 发消息。
  • Consumer 跟 Producer 类似,跟其中一台 NameServer 建立长连接,获取当前订阅 Topic 存在哪些 Broker 上,然后直接跟 Broker 建立连接通道,开始消费消息。

下图来自GitHub
rocketmq_architecture

推拉模型

推模式优缺点

  • 实时性高

  • 推送速率难以适应消费速率

  • 不同消费者的消费速率很有可能不一样,Broker难以平衡每个消费者的推送速率,如果要实现自适应就会大大增加Broker自身的复杂度

因此推模式适用于消息量不大、消费能力强要求实时性高的情况下。

拉模式优缺点

  • 消费者可以根据自己能力拉取消息处理,灵活稳定

  • 可以更合适地进行消息的批量发送,基于推模式可以来一个消息就推送,也可以缓存一些消息之后再推送,但是推送的时候其实不知道消费者能不能一次性处理这么多消息。而拉模式可以根据消费者缓存能力决定拉取多少消息。

  • 会造成消息的延迟消费,如果长时间没有消息,消费端不断轮询拉取,会造成一定时间的忙等,如果轮询时间过长,又会导致消息的延迟加大。

QA

消息队列可以做什么?

异步处理耗时任务
解耦上下游系统
削峰填谷

哪些消息队列可以做到在消息生产、消费过程中不重、不丢(Exactly once)?

Kafka、RocketMQ、RabbitMQ都没有实现这个需求,因为要实现Exactly once,除了重发外还需要做幂等,实现比较复杂,而且对性能影响比较大。

RocketMQ中的Consumer是推还是拉?

RocketMQ支持推和拉,但这两种方式实际上都是通过pull实现的,只是拉是同步的,而推是传个回调函数,当RocketMQ客户端接收到消息后再调用这个回调函数。

RocketMQ发送、存储、接收的流程?

当发现消费者不消费时,如何诊断问题?

  1. 检查连接状态,看消费者是否正常连接Broker;
  2. 看消费者是否有分配到ConsumeQueue,因为一个ConsumeQueue只能被一个消费者消费,所以消费者数量超过ConsumeQueue时,就会出现部分消费者没有ConsumeQueue可消费的情况;
  3. 生产者是否有正常消费,从控制台就可以看;
  4. 如果检查完以上步骤后仍然没有发现问题,则需要查看消费者的客户端日志再进一步分析。

怎么实现消息发送的严格顺序性?

RMQ中的分区算法指的就是把消息发到固定的某些队列上,因为同一队列只能被一个消费者消费,因此可以保证这个队列中消息的顺序性。
可选的分区算法如:

  1. 在表中存储key和分区的对应关系,通过查表确定分区号;
  2. 取模

RocketMQ能否做到单队列的并行消费?

RocketMQ 在消费的时候,为了保证消息的不丢失和严格顺序,每个队列只能串行消费(一个消费者可以消费多个队列),无法做到并发,否则会出现消费空洞的问题。那如果放宽一下限制,不要求严格顺序,能否做到单个队列的并行消费呢?

怎么实现负载均衡?

RocketMQ如何保证消息不丢(消息一定能被消费)?

  1. Producer端重试
    默认push重试3次。
  2. Broker端只有在复制半数以上副本之后才会返回发送成功。

    和MySQL里的semisync有点像。

  3. Consumer端重复消费
    DefaultMQPushConsumer默认超时重试无限次,默认异常重试16次,过期或重试不成功则进入死信队列、默认凌晨 3 点会清除死信队列,为了确保重试不会出现重复消费,业务逻辑一般都需要保证幂等(幂等key可以使用业务oid或uniqId)。
    Consumer有两种返回值,CONSUME_SUCCESS和CONSUME_LATER,后者令Broker将消息转移到另一个Retry队列中供重试使用。

RocketMQ如何实现消息去重?

RocketMQ本身没有实现消息的去重功能,因为RocketMQ是At-Least-Once的。
所以,很多时候我们需要自己通过Redis等来实现消息去重,但是要注意的是不要用错了msgId:

  • MessageExt.msgId:重试时这个msgId是会变的,因此不适合当作幂等key;
  • MessageExt.properties["PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX"]:相对上面那个msgId来说,这个UNIQ_KEY就算重试多次值还是一样的,因此更适合当作幂等key。

消费失败怎么重发的?

怎么判断消息堆积了?

刷盘的原理?

  1. CommitLog
  2. ConsumeLog

怎么实现消息复制(Broker主从之间)?

RocketMQ 如何保证消息的高可用?

  1. NameServer 集群
    NameServer集群节点没有Master、Slave之分,即使挂掉其中几台,其他的仍可提供服务。
  2. Broker多主多从
    Broker支持多主多从集群,即使其中某台Master挂掉了,其他Master照样可以提供服务,而且挂掉的Master,其从节点照样可以通过选举得到一个新的Master。

broker集群的master宕机,slave是怎么提供服务的?master是怎么切换回来的?

为什么 RocketMQ 使用 NameServer 而不是 ZooKeeper 作为服务注册表

NameServer 具有高可用性,就算其中某台挂掉,其他服务器仍然能提供服务注册和查询功能。
ZooKeeper 的设计目标是高一致性,其中某台服务器挂掉,整个 ZooKeeper 集群就无法提供服务了——直到下一个Leader被选举出来为止。

NameServer是怎么感知Broker的变化的?

RocketMQ的事务消息是否完整实现了事务的ACID特性?

为什么要有Half Message?

  1. 可以先确认Broker服务器是否正常,如果半消息都发送失败了,就说明Broker挂了。
  2. 可以通过半消息来回查事务状态,如果半消息发出后一直没有被二次确认,就会回查事务状态。

    事务回查有两种情况:1、由于网络等原因一直没有执行事务的commit和rollback;2、本地事务执行成功了,但是返回commit的时候服务挂了,Broker最终也没有收到消息,因此还是半消息状态,因此仍会进行重试。

为什么说RocketMQ只能保证最终一致性?

比如以一个转帐功能为例,A账户扣减、发MQ消息通知另一个服务增加B账户的余额,扣减和增加是在两个事务中执行的,MQ虽然能保证两个事务最终一定都能执行上,但是并不能保证中间状态不会出现,比如某个时刻A账户扣减了、但是B账户仍为原状。

RocketMQ使用某个消息序号messageID消费某个队列的消息,时间复杂度是多少?(假设消息文件commitLog数量为m,每个消息文件中消息条数是k,索引文件consumerQueue的数量是n,队列中共有j条消息)

复杂度是O(1),因为消息序号中包含了消息在commitLog中的偏移量,因此可以直接通过偏移量来拿到消息。

参考

环境

  1. rocketmq 控制台搭建(rocketmq-console)
  2. Quick Start
  3. RocketMQ 源码解析 —— 调试环境搭建
  4. RocketMQ管理命令说明

原理

  1. 特性(features)
  2. 设计(design)
  3. RocketMQ 实战(三) - 消息的有序性
  4. 事务消息
  5. RocketMQ 源码分析

为什么使用RocketMQ

使用MQ的优点

  1. 解耦
    直连的情况下,每接入一个系统就需要直接改代码调新系统接口;
    使用 RocketMQ 的情况下,新系统可以自己监听消息。
  2. 异步
    非必要的业务逻辑异步处理,加快响应速度。
  3. 削峰
    直连的情况下,所有请求会直接全部打到下游服务,引入消息队列后,消息队列可以暂存消息,让下游服务慢慢消费;

使用 MQ 的缺点

系统可用性降低:消息队列挂掉将影响系统可用性。
系统复杂性增加:加入了消息队列,要多考虑很多方面的问题,比如:一致性问题、如何保证消息不被重复消费、如何保证消息可靠性传输等。因此,需要考虑的东西更多,系统复杂性增大。

使用 RocketMQ

搭建 Name Server 和 Broker Server(单点)

看下这个
为了方便,需要设置 Broker 服务器的一些属性,在 broker.conf 中:

1
2
3
4
# 允许使用SQL语法过滤消息
enablePropertyFilter=true
# 自动创建Topic,否则使用新Topic时还需要在控制台创建
autoCreateTopicEnable=true

对 Broker 服务器的启动可以使用下面的命令:

1
start mqbroker.cmd -n 127.0.0.1:9876 -c ../conf/broker.conf &

如果后续生产消息时报错:maybe your broker machine memory too small
出现这个问题的主要原因是磁盘空间不够了,而我的电脑磁盘一直都是稀缺资源,所以就出问题了。
可以修改 runbroker.sh,在里面增加一句话即可: JAVA_OPT=”${JAVA_OPT} -Drocketmq.broker.diskSpaceWarningLevelRatio=0.98” 我这里把磁盘保护的百分比设置成98%,只有磁盘空间使用率达到98%时才拒绝接收producer消息。

搭建RocketMQ Console

控制台,方便创建Topic、统计消息、查看集群状态等。
看下这个

多主多从(2主多从)

同上下载源码并编译完成后,第一步是创建配置文件,我计划将所有主从放到同一台服务器上,因此在本地创建所有配置文件和数据存储目录:
1、创建数据存储目录

1
2
mkdir -p /home/hgc/Documents/project/open/rocketmq/store1/{rootdir-a-m,commitlog-a-m,rootdir-a-s,commitlog-a-s}
mkdir -p /home/hgc/Documents/project/open/rocketmq/store2/{rootdir-b-m,commitlog-b-m,rootdir-b-s,commitlog-b-s}

2、配置环境变量
/etc/profile文件中加入:

1
2
3
# RocketMQ
export ROCKETMQ_HOME=/home/hgc/Documents/project/open/rocketmq
export PATH=$PATH:$ROCKETMQ_HOME/bin

3、创建配置文件
/home/hgc/Documents/project/open/rocketmq/conf.1.m/broker.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
brokerClusterName = DefaultCluster
brokerName = rocketmq-nameserver-1
#brokerId 0 表示 Master,>0 表示 Slave
brokerId=0
# Broker 对外服务的监听端口
listenPort=10911
#nameServer地址,分号分割
namesrvAddr=127.0.0.1:9876
# 删除文件时间点,默认凌晨 4点
deleteWhen=04
#文件保留时间,默认 48 小时
fileReservedTime=72
#Broker role有3种:SYNC_MASTER、ASYNC_MASTER、SLAVE。关键词SYNC和ASYNC表示Master和Slave之间同步消息的机制,SYNC即同步更新,指当Slave和Master消息同步完成后,再返回发送成功的状态。ASYNC即异步更新,master与slave有短暂消息延迟,毫> 秒级。本文在此使用了异步复制集群模式,线上环境推荐使用同步双写模式,即SYNC_MASTER
brokerRole=SYNC_MASTER
# 刷盘方式 ASYNC_FLUSH 异步刷盘
flushDiskType=ASYNC_FLUSH
#存储路径
storePathRootDir=/home/hgc/Documents/project/open/rocketmq/store1/rootdir-a-m
storePathCommitLog=/home/hgc/Documents/project/open/rocketmq/store1/commitlog-a-m
# 是否允许 Broker 自动创建Topic
autoCreateTopicEnable=true
# 是否允许 Broker 自动创建订阅组
autoCreateSubscriptionGroup=true

/home/hgc/Documents/project/open/rocketmq/conf.2.m/broker.conf

1
2
3
4
5
6
7
8
9
10
11
12
brokerName=rocketmq-nameserver-2
listenPort=10921
namesrvAddr=rocketmq-nameserver-1:9876
brokerId=0
deleteWhen=04
fileReservedTime=72
brokerRole=SYNC_MASTER
storePathRootDir=/home/hgc/Documents/project/open/rocketmq/store2/rootdir-b-m
storePathCommitLog=/home/hgc/Documents/project/open/rocketmq/store2/commitlog-b-m
autoCreateTopicEnable=true
autoCreateSubscriptionGroup=true
flushDiskType=ASYNC_FLUSH

/home/hgc/Documents/project/open/rocketmq/conf.1.s/broker.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
brokerClusterName = DefaultCluster
brokerName = rocketmq-nameserver-1
brokerId=1
listenPort=10917
namesrvAddr=127.0.0.1:9876
deleteWhen=04
fileReservedTime=72
brokerRole=SLAVE
flushDiskType=ASYNC_FLUSH
storePathRootDir=/home/hgc/Documents/project/open/rocketmq/store1/rootdir-a-s
storePathCommitLog=/home/hgc/Documents/project/open/rocketmq/store1/commitlog-a-s
autoCreateTopicEnable=true
autoCreateSubscriptionGroup=true

/home/hgc/Documents/project/open/rocketmq/conf.2.s/broker.conf

1
2
3
4
5
6
7
8
9
10
11
12
brokerName=rocketmq-nameserver-2
listenPort=10927
namesrvAddr=127.0.0.1:9876
brokerId=1
deleteWhen=04
fileReservedTime=72
brokerRole=SLAVE
storePathRootDir=/home/hgc/Documents/project/open/rocketmq/store2/rootdir-b-s
storePathCommitLog=/home/hgc/Documents/project/open/rocketmq/store2/commitlog-b-s
autoCreateTopicEnable=true
autoCreateSubscriptionGroup=true
flushDiskType=ASYNC_FLUSH

4、启动NameServer
启动前先修改启动脚本,因为是单机使用,把堆大小稍微改小点:

1
JAVA_OPT="${JAVA_OPT} -server -Xms1g -Xmx1g -Xmn1g -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m"
1
nohup sh bin/mqnamesrv &

5、启动Broker
同理改下启动脚本:

1
JAVA_OPT="${JAVA_OPT} -server -Xms1g -Xmx1g -Xmn1g"

按master - slave的顺序启动每个Broker

1
2
3
4
nohup sh bin/mqbroker -c conf.1.m/broker.conf > conf.1.m/logs/broker.1.m.log &
nohup sh bin/mqbroker -c conf.2.m/broker.conf > conf.2.m/logs/broker.2.m.log &
nohup sh bin/mqbroker -c conf.1.s/broker.conf > conf.1.s/logs/broker.1.s.log &
nohup sh bin/mqbroker -c conf.2.s/broker.conf > conf.2.s/logs/broker.2.s.log &

6、查看注册情况

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
# 看看集群注册情况
./mqadmin clusterList -n 127.0.0.1:9876
# 创建一个Topic
./mqadmin updateTopic -c DefaultCluster -n localhost:9876 -t TopicTest
# 看看Topic列表
./mqadmin topicList -n 127.0.0.1:9876
# 看看Topic路由信息
./mqadmin topicRoute -n 127.0.0.1:9876 -t TopicTest
# 查看Topic统计信息
mqadmin topicStatus -n 127.0.0.1:9876 -t TopicTest
# 根据消息key查询消息
./mqadmin queryMsgByKey -n "127.0.0.1:9876" -t TopicTest -k messageKey
# 根据消息ID查询消息
./mqadmin queryMsgById -n "127.0.0.1:9876" -t TopicTest -i 240882208B134140BD0FB45F5DBD2075000018B4AAC2130BE69B0001
# 查询Producer的网络连接
./mqadmin producerConnection -n 127.0.0.1:9876 -g ExampleProducerGroup -t TopicTest
# 查询Consumer的网络连接
./mqadmin consumerConnection -n 127.0.0.1:9876 -g ExampleConsumerGroup -t TopicTest
# 查看订阅组的消费状态
consumerProgress
# 添加KV配置信息
updateKvConfig
# 删除KV配置信息
deleteKvConfig
# 添加project group配置信息
updateProjectGroup
# 删除project group配置信息
deleteProjectGroup
# 获取product group配置信息
getProjectGroup
# 设置消费进度
resetOffsetByTime
# 清除特定Broker权限
wipeWritePerm
# 获取Consumer消费进度
getConsumerStatus

7、使用客户端测试

8、关闭服务器

1
2
sh mqshutdown namesrv
sh mqshutdown broker

主从自动切换(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,并且特别需要强调,只能第一个字符为英文,其他字符需要配置成数字。
  • storePathRootDir
    DLedger 日志文件的存储根目录,为了能够支持平滑升级,该值与 storePathCommitLog 设置为不同的目录。

第一个节点的broker.conf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
brokerClusterName = DefaultCluster
brokerName = broker1
brokerId = 0
listenPort=10911
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
namesrvAddr=127.0.0.1:9876
storePathRootDir=/home/hgc/Documents/project/open/rocketmq-dleger/store1/
# 与dledger相关的属性
enableDLegerCommitLog=true
dLegerGroup=broker1
dLegerPeers=n0-127.0.0.1:40911;n1-127.0.0.1:40912;n2-127.0.0.1:40913
dLegerSelfId=n0

第二个节点的broker.conf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
brokerClusterName = DefaultCluster
brokerName = broker1
brokerId = 0
listenPort=10921
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
namesrvAddr=127.0.0.1:9876
storePathRootDir=/home/hgc/Documents/project/open/rocketmq-dleger/store2/
# 与dledger 相关的配置属性
enableDLegerCommitLog=true
dLegerGroup=broker1
dLegerPeers=n0-127.0.0.1:40911;n1-127.0.0.1:40912;n2-127.0.0.1:40913
dLegerSelfId=n1

第三个节点的broker.conf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
brokerClusterName = DefaultCluster
brokerName = broker1
brokerId = 0
listenPort=10931
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
namesrvAddr=127.0.0.1:9876
storePathRootDir=/home/hgc/Documents/project/open/rocketmq-dleger/store3/
# 与dledger 相关的配置属性
enableDLegerCommitLog=true
dLegerGroup=broker1
dLegerPeers=n0-127.0.0.1:40911;n1-127.0.0.1:40912;n2-127.0.0.1:40913
dLegerSelfId=n2

对于DLedger集群来说,一方面要保证消息仍能正常收发,另一方面还要保证Master挂掉后集群会选举出一个新的Master,可以将Master kill掉来模拟这种情况,在rocketmq-console中查看节点角色的变化情况。

客户端配置

消费模式

有以下3种:

  1. CONSUME_FROM_LAST_OFFSET:默认策略,从该队列最尾开始消费,即跳过历史消息
  2. CONSUME_FROM_FIRST_OFFSET:从队列最开始开始消费,即历史消息(还储存在broker的)全部消费一遍
  3. CONSUME_FROM_TIMESTAMP:从某个时间点开始消费,和setConsumeTimestamp()配合使用,默认是半个小时以前

其他概念

msgId:根据 queueNum 取模放到 broker 队列
instanceName:每个集群不一致,同一集群 groupName 不能重,两个 topic 的 groupName 不能一样
两种模式本质都是拉:push 是构建长连接,一次拉完,一个一个给 broker 回执 ack,多个 Consumer 平分(必须是 4 的倍数,不够再给余数,比如 9 个消息三个 Consumer 分别拉到 4、4、1),poll 模式批量,按设定拉取

消息的生成和消费

1
2
3
4
5
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client</artifactId>
<version>4.3.0</version>
</dependency>

消息生产:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
public class ProducerTest {

/**
* 同步消息
*/
@Test
public void sendSyncMsg() throws MQClientException, UnsupportedEncodingException, RemotingException, InterruptedException, MQBrokerException {
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
producer.setNamesrvAddr("localhost:9876");
producer.start();
for (int i = 0; i < 100; i++) {
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest",
"TagA",
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)
);
// 发送消息到一个Broker
SendResult sendResult = producer.send(msg);
// 通过sendResult返回消息是否成功送达
System.out.printf("%s%n", sendResult);
}
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}

/**
* 异步消息
*/
@Test
public void sendAsyncMsg() throws MQClientException, UnsupportedEncodingException, RemotingException, InterruptedException {
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
producer.setNamesrvAddr("localhost:9876");
producer.start();
producer.setRetryTimesWhenSendAsyncFailed(0);
int messageCount = 100;
// 根据消息数量实例化倒计时计算器
final CountDownLatch2 countDownLatch = new CountDownLatch2(messageCount);
for (int i = 0; i < messageCount; i++) {
final int index = i;
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest",
"TagA",
"OrderID188",
"Hello world".getBytes(RemotingHelper.DEFAULT_CHARSET));
// SendCallback接收异步返回结果的回调
producer.send(msg, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
System.out.printf("%-10d OK %s %n", index,
sendResult.getMsgId());
}

@Override
public void onException(Throwable e) {
System.out.printf("%-10d Exception %s %n", index, e);
e.printStackTrace();
}
});
}
// 等待5s
countDownLatch.await(5, TimeUnit.SECONDS);
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}

/**
* 单向消息
*/
@Test
public void testSendOnewayMsg() throws MQClientException, UnsupportedEncodingException, RemotingException, InterruptedException {
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
producer.setNamesrvAddr("localhost:9876");
producer.start();
for (int i = 0; i < 100; i++) {
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest",
"TagA",
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)
);
// 发送单向消息,没有任何返回结果
producer.sendOneway(msg);

}
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}

消息消费:

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

public static void main(String[] args) throws MQClientException {
// 实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name");

// 设置NameServer的地址
consumer.setNamesrvAddr("localhost:9876");

// 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
consumer.subscribe("TopicTest", "*");
// 注册回调实现类来处理从broker拉取回来的消息
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, 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");
}
}

顺序消息

发送:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
public class OrderedProducer {

public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");

producer.setNamesrvAddr("127.0.0.1:9876");

producer.start();

String[] tags = new String[]{"TagA", "TagC", "TagD"};

// 订单列表
List<OrderStep> orderList = new OrderedProducer().buildOrders();

Date date = new Date();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String dateStr = sdf.format(date);
for (int i = 0; i < 10; i++) {
// 加个时间前缀
String body = dateStr + " Hello RocketMQ " + orderList.get(i);
Message msg = new Message("TopicTest", tags[i % tags.length], "KEY" + i, body.getBytes());

SendResult sendResult = producer.send(msg, (mqs, msg1, arg) -> {
Long id = (Long) arg; //根据订单id选择发送queue
long index = id % mqs.size();
return mqs.get((int) index);
}, orderList.get(i).getOrderId());//订单id

System.out.println(String.format("SendResult status:%s, queueId:%d, body:%s",
sendResult.getSendStatus(),
sendResult.getMessageQueue().getQueueId(),
body));
}
producer.shutdown();
}

/**
* 订单的步骤
*/
private static class OrderStep {

private long orderId;
private String desc;

public long getOrderId() {
return orderId;
}

public void setOrderId(long orderId) {
this.orderId = orderId;
}

public String getDesc() {
return desc;
}

public void setDesc(String desc) {
this.desc = desc;
}

@Override
public String toString() {
return "OrderStep{" +
"orderId=" + orderId +
", desc='" + desc + '\'' +
'}';
}
}

/**
* 生成模拟订单数据
*/
private List<OrderStep> buildOrders() {
List<OrderStep> orderList = new ArrayList<OrderStep>();

OrderStep orderDemo = new OrderStep();
orderDemo.setOrderId(15103111039L);
orderDemo.setDesc("创建");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111065L);
orderDemo.setDesc("创建");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111039L);
orderDemo.setDesc("付款");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103117235L);
orderDemo.setDesc("创建");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111065L);
orderDemo.setDesc("付款");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103117235L);
orderDemo.setDesc("付款");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111065L);
orderDemo.setDesc("完成");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111039L);
orderDemo.setDesc("推送");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103117235L);
orderDemo.setDesc("完成");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111039L);
orderDemo.setDesc("完成");
orderList.add(orderDemo);

return orderList;
}
}

接收:

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
41
42
43
/**
* 顺序消息消费,带事务方式(应用可控制Offset什么时候提交)
*/
public class OrderedConsumer {

public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_3");
consumer.setNamesrvAddr("127.0.0.1:9876");
/**
* 设置Consumer第一次启动是从队列头部开始消费还是队列尾部开始消费<br>
* 如果非第一次启动,那么按照上次消费的位置继续消费
*/
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);

consumer.subscribe("TopicTest", "TagA || TagC || TagD");

consumer.registerMessageListener(new MessageListenerOrderly() {

Random random = new Random();

@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
context.setAutoCommit(true);
for (MessageExt msg : msgs) {
// 可以看到每个queue有唯一的consume线程来消费, 订单对每个queue(分区)有序
System.out.println("consumeThread=" + Thread.currentThread().getName() + " queueId=" + msg.getQueueId() + ", topic:" + msg.getTopic() + " content:" + new String(msg.getBody()));
}

try {
//模拟业务逻辑处理中...
// TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
e.printStackTrace();
}
return ConsumeOrderlyStatus.SUCCESS;
}
});

consumer.start();

System.out.println("Consumer Started.");
}
}

延时消息

延时消息可以用于一些需要延迟处理业务的场景,比如下单后超过一定时间没支付就自动取消。
发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ScheduledProducer {

public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup");
producer.setNamesrvAddr("127.0.0.1:9876");
producer.start();
int totalMessagesToSend = 100;
for (int i = 0; i < totalMessagesToSend; i++) {
Message message = new Message("TopicTest", "TagA", ("Hello scheduled message " + i).getBytes());
// 设置延时等级3,这个消息将在10s之后发送(现在只支持固定的几个时间,详看delayTimeLevel)
message.setDelayTimeLevel(3);
producer.send(message);
}
producer.shutdown();
}
}

接收:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ScheduledConsumer {

public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("ExampleConsumer");
consumer.setNamesrvAddr("127.0.0.1:9876");
consumer.subscribe("TopicTest", "*");
consumer.registerMessageListener((MessageListenerConcurrently) (messages, context) -> {
for (MessageExt message : messages) {
// Print approximate delay time period
System.out.println("Receive message[msgId=" + message.getMsgId() + "] " + (System.currentTimeMillis() - message.getStoreTimestamp()) + "ms later");
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
consumer.start();
}
}

批量消息

批量发送消息能显著提高传递小消息的性能。限制是这些批量消息应该有相同的 topic,相同的 waitStoreMsgOK,而且不能是延时消息,此外,这一批消息的总大小不应超过 4MB。
发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class BatchProducer {

public static void main(String[] args) throws MQClientException {
DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup");
producer.setNamesrvAddr("127.0.0.1:9876");
producer.start();
String topic = "TopicTest";
List<Message> messages = new ArrayList<>();
messages.add(new Message(topic, "TagA", "OrderID001", "Hello world 0".getBytes()));
messages.add(new Message(topic, "TagA", "OrderID002", "Hello world 1".getBytes()));
messages.add(new Message(topic, "TagA", "OrderID003", "Hello world 2".getBytes()));
try {
producer.send(messages);
} catch (Exception e) {
e.printStackTrace();
}
producer.shutdown();
}
}

接收逻辑并无不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class BatchConsumer {

public static void main(String[] args) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("ExampleConsumer");
consumer.setNamesrvAddr("127.0.0.1:9876");
consumer.subscribe("TopicTest", "*");
consumer.registerMessageListener((MessageListenerConcurrently) (messages, context) -> {
for (MessageExt message : messages) {
// Print approximate delay time period
System.out.println("Receive message[msgId=" + message.getMsgId() + "] " + (System.currentTimeMillis() - message.getStoreTimestamp()) + "ms later");
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
consumer.start();
}
}

注意我们上边提到的 4MB 的大小限制,如果消息列表中的消息变多了,很有可能会超过这个大小,这时最好对消息列表进行分割再发送:

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
public class BatchProducer {

public static void main(String[] args) throws MQClientException {
DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup");
producer.setNamesrvAddr("127.0.0.1:9876");
producer.start();
String topic = "TopicTest";
List<Message> messages = new ArrayList<>();
messages.add(new Message(topic, "TagA", "OrderID001", "Hello world 0".getBytes()));
messages.add(new Message(topic, "TagA", "OrderID002", "Hello world 1".getBytes()));
messages.add(new Message(topic, "TagA", "OrderID003", "Hello world 2".getBytes()));
try {
splitSend(producer, messages);
} catch (Exception e) {
e.printStackTrace();
}
producer.shutdown();
}

static void splitSend(MQProducer producer, List<Message> messages) {
//把大的消息分裂成若干个小的消息
ListSplitter splitter = new ListSplitter(messages);
while (splitter.hasNext()) {
try {
List<Message> listItem = splitter.next();
producer.send(listItem);
} catch (Exception e) {
e.printStackTrace();
//处理error
}
}
}

}
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class ListSplitter implements Iterator<List<Message>> {

private final int SIZE_LIMIT = 1024 * 1024 * 4;
private final List<Message> messages;
private int currIndex;

public ListSplitter(List<Message> messages) {
this.messages = messages;
}

@Override
public boolean hasNext() {
return currIndex < messages.size();
}

@Override
public List<Message> next() {
int startIndex = getStartIndex();
int nextIndex = startIndex;
int totalSize = 0;
for (; nextIndex < messages.size(); nextIndex++) {
Message message = messages.get(nextIndex);
int tmpSize = calcMessageSize(message);
if (tmpSize + totalSize > SIZE_LIMIT) {
break;
} else {
totalSize += tmpSize;
}
}
List<Message> subList = messages.subList(startIndex, nextIndex);
currIndex = nextIndex;
return subList;
}

private int getStartIndex() {
Message currMessage = messages.get(currIndex);
int tmpSize = calcMessageSize(currMessage);
while (tmpSize > SIZE_LIMIT) {
currIndex += 1;
Message message = messages.get(currIndex);
tmpSize = calcMessageSize(message);
}
return currIndex;
}

private int calcMessageSize(Message message) {
int tmpSize = message.getTopic().length() + message.getBody().length;
Map<String, String> properties = message.getProperties();
for (Map.Entry<String, String> entry : properties.entrySet()) {
tmpSize += entry.getKey().length() + entry.getValue().length();
}
tmpSize = tmpSize + 20; // 增加⽇日志的开销20字节
return tmpSize;
}
}

消息订阅

RocketMQ 支持用 SQL 语法的语句来“模糊”订阅消息,只有使用 push 模式的消费者才能用使用 SQL92 标准的 sql 语句,接口如下:

1
public void subscribe(finalString topic, final MessageSelector messageSelector)
  • 数值比较,比如:>,>=,<,<=,BETWEEN,=;
  • 字符比较,比如:=,<>,IN;
  • IS NULL 或者 IS NOT NULL;
  • 逻辑符号 AND,OR,NOT;

常量支持类型为:

  • 数值,比如:123,3.1415;
  • 字符,比如:’abc’,必须用单引号包裹起来;
  • NULL,特殊的常量
  • 布尔值,TRUE 或 FALSE

消息发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class TagProducer {

public static void main(String[] args) throws MQClientException, UnsupportedEncodingException, RemotingException, InterruptedException, MQBrokerException {
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
producer.setNamesrvAddr("localhost:9876");
producer.start();
Message msg = new Message("TopicTest",
"TagA",
("Hello RocketMQ").getBytes(RemotingHelper.DEFAULT_CHARSET)
);
// 设置一些属性
msg.putUserProperty("a", "2");
SendResult sendResult = producer.send(msg);
producer.shutdown();
}
}

消息消费:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class TagConsumer {

public static void main(String[] args) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_4");
consumer.setNamesrvAddr("localhost:9876");
// 只有订阅的消息有这个属性a, a >=0 and a <= 3
consumer.subscribe("TopicTest", MessageSelector.bySql("a between 0 and 3"));
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> {
System.out.println(new String(msgs.get(0).getBody()));
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
consumer.start();
}
}

事务消息

事务消息有3种状态:

  • TransactionStatus.CommitTransaction: 提交事务,它允许消费者消费此消息。
  • TransactionStatus.RollbackTransaction: 回滚事务,它代表该消息将被删除,不允许被消费。
  • TransactionStatus.Unknown: 中间状态,它代表需要检查消息队列来确定状态。

发送事务消息:

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
public class TransactionProducer {

public static void main(String[] args) throws MQClientException, InterruptedException {
TransactionListener transactionListener = new TransactionListenerImpl();
TransactionMQProducer producer = new TransactionMQProducer("please_rename_unique_group_name");
producer.setNamesrvAddr("localhost:9876");
ExecutorService executorService = new ThreadPoolExecutor(2, 5, 100, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2000), r -> {
Thread thread = new Thread(r);
thread.setName("client-transaction-msg-check-thread");
return thread;
});
producer.setExecutorService(executorService);
// 事务监听
producer.setTransactionListener(transactionListener);
producer.start();
String[] tags = new String[]{"TagA", "TagB", "TagC", "TagD", "TagE"};
for (int i = 0; i < 10; i++) {
try {
Message msg =
new Message("TopicTest", tags[i % tags.length], "KEY" + i,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
SendResult sendResult = producer.sendMessageInTransaction(msg, null);
System.out.printf("%s%n", sendResult);
Thread.sleep(10);
} catch (MQClientException | UnsupportedEncodingException e) {
e.printStackTrace();
}
}
for (int i = 0; i < 100000; i++) {
Thread.sleep(1000);
}
producer.shutdown();
}
}

实现事务监听接口,在发送半消息成功后会触发executeLocalTransaction方法来执行本地事务,checkLocalTransaction用于检查本地事务状态,并回应消息队列的检查请求:

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
public class TransactionListenerImpl implements TransactionListener {
private AtomicInteger transactionIndex = new AtomicInteger(0);
private ConcurrentHashMap<String, Integer> localTrans = new ConcurrentHashMap<>();
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
int value = transactionIndex.getAndIncrement();
int status = value % 3;
localTrans.put(msg.getTransactionId(), status);
return LocalTransactionState.UNKNOW;
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
Integer status = localTrans.get(msg.getTransactionId());
if (null != status) {
switch (status) {
case 0:
return LocalTransactionState.UNKNOW;
case 1:
return LocalTransactionState.COMMIT_MESSAGE;
case 2:
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
return LocalTransactionState.COMMIT_MESSAGE;
}
}

事务消息使用上的限制:

  • 事务消息不支持延时消息和批量消息。
  • 为了避免单个消息被检查太多次而导致半队列消息累积,我们默认将单个消息的检查次数限制为 15 次,但是用户可以通过 Broker 配置文件的 transactionCheckMax 参数来修改此限制。如果已经检查某条消息超过 N 次的话( N = transactionCheckMax ) 则 Broker 将丢弃此消息,并在默认情况下同时打印错误日志。用户可以通过重写 AbstractTransactionalMessageCheckListener 类来修改这个行为。
  • 事务消息将在 Broker 配置文件中的参数 transactionTimeout 这样的特定时间长度之后被检查。当发送事务消息时,用户还可以通过设置用户属性 CHECK_IMMUNITY_TIME_IN_SECONDS 来改变这个限制,该参数优先于 transactionTimeout 参数。
  • 事务性消息可能不止一次被检查或消费。
  • 提交给用户的目标主题消息可能会失败,目前这依日志的记录而定。它的高可用性通过 RocketMQ 本身的高可用性机制来保证,如果希望确保事务消息不丢失、并且事务完整性得到保证,建议使用同步的双重写入机制。
  • 事务消息的生产者 ID 不能与其他类型消息的生产者 ID 共享。与其他类型的消息不同,事务消息允许反向查询、MQ 服务器能通过它们的生产者 ID 查询到消费者。

RocketMQ的运维

海量数据处理方法

当数据量特别大时,首先排除直接加载到内存中的算法,最核心的思路往往是分治,比如排序时先将大文件分割成多个足以加载到内存中的小文件,然后利用内存排序算法分别排序得到有序的一些小文件,最后通过归并排序得到最终的结果。

阅读全文 »

MyBatis 整体结构

MyBatis结构

配置文件

配置类提供的功能几乎贯穿了整个处理过程:

  1. 解析 Xml 文件
  2. 创建 SQL 处理器 Executor
  3. 对语句进行缓存 MappedStatement

怎么定位路径

  • getResourceAsStream

怎么解析文件

xml 文件的解析方式有两种,一种 DOM 是直接读入整个 xml 文件,根据标签的嵌套关系构建一棵文档树;另一种方式叫 SAX(Simple API for XML),是一种事件驱动的文档解析方式,什么是事件驱动呢?比如说 SAX 驱动扫描到了起始标签,就代表发生了一个事件,它会转而调用某个由用户定义的函数(startElement)执行逻辑。
有一种设计原则叫好莱坞法则(Hollywood),形象地说就是“你不要 call 我,需要你时我会 call 你”,一个例子是异步调用,这是一种通信机制,客户端在发出请求后不必等待服务端处理完毕就可以返回处理自己的逻辑,等到服务端处理完毕后再将结果传回,这种方式一定程度上可以解决客户端长期阻塞的问题、改善用户体验,回调函数也是一个例子。
据网上的说法,DOM 需要一次构建整棵 DOM 树,所以比较占内存,不适合大的 xml 文档解析,但是由于 DOM 树上可以任意遍历,所以自由度很高,相对来说,SAX 是读到什么就调用什么回调函数,所以内存占用小,但是编程多少会复杂一些。

构建数据库连接

数据库连接

SqlSessionFactoryBuilder

应用了建造者模式,根据配置文件来创建 SqlSessionFactory,创建后其任务就结束了,生命周期在一个方法内。

SqlSessionFactory

创建和数据库连接的工具,在整个应用运行期间应该作为一个单例存在,或者使用依赖注入管理其生命周期。

SqlSession

代表和数据库的一次连接,在 MyBatis 中其实现是线程不安全的,生命周期最好控制在一次请求之间。

数据源

  • DBCP
  • C3P0
  • Druid
  • MyBatis 内置数据源(UNPOOLED、POOLED、JNDI)
  • 自定义数据源

映射器

  • mapper 文件
  • 注解

SQL 执行

SqlSession 本身是可以直接执行 sql 语句的,它的所有 update、query 等方法都是对语句进行了包装(MappedStatement),然后再调用 Executor 的相应方法,Executor 是执行器,是 MyBatis 的核心。
SQL 的执行是由 Executor 负责的,Executor 对象是和 SqlSession 同时创建的,SqlSessionFactory 会为 Executor 创建事务,事务类默认为 ManagedTransactionFactory,Executor 需要从事务对象获取数据库连接(包装上一层事务后扩展性更好),事务会从环境对象中获取 DataSource 对象,然后委托 DataSource 创建连接,并且可以根据事务等级来为连接设置事务。说白了,把 Config 对象传给新建的 Transaction,由 Transaction 创建连接。
Executor 并不是直接执行 SQL 语句,SQL 语句由 MappedStatement 包装,再交给 StatementHandler 执行

Executor

MyBatis 提供 4 种 Executor,他们都继承于 BaseExecutor
BaseExecutor 是一个抽象类,实现了延迟加载、一级缓存(PerpetualCache)等功能
SimpleExecutor 语句使用 PreparedStatement 保存,使用 StatementHandler 处理
ReuseExecutor 与 SimpleExecutor 的区别是它使用一个 Map<String, Statement>来缓存 SQL 语句对应的 Statement,如果某些 Sql 复杂且使用频繁的话可以使用这个执行器,因为这个 Map 不是静态的,并且 MyBatis 实际上会为每个新建的 SqlSession 创建一个 Executor,所以这个缓存只在同一个 Session 内有效

1
2
3
4
5
6
7
8
9
10
private final Map<String, Statement> statementMap = new HashMap<String, Statement>();
if (hasStatementFor(sql)) {
//如果缓存中已经有了,直接得到Statement
stmt = getStatement(sql);
} else {
//如果缓存没有找到,则和SimpleExecutor处理完全一样,然后加入缓存
Connection connection = getConnection(statementLog);
stmt = handler.prepare(connection);
putStatement(sql, stmt);
}

BatchExecutor(批量执行器) 将一些 SQL 语句放在一个 List 中,最后 doFlushStatements 一块执行,并且如果两个相邻的 SQL 语句是相同的,还会复用前一个 Statement 对象。
CachingExecutor(二级缓存执行器) 为什么说是二级缓存?一级缓存由 BaseExecutor 中的 PerpetualCache 实现,CachingExecutor 会先在二级缓存中查找,如果找不到再委托给 delegate 执行,delegate 是 BaseExecutor 的子类,当然有一级缓存的功能。

参数类型和返回值

我们很多时候会指定 parameterType 和 resultType 为复杂类型,怎么将这些类型和数据库表结构进行映射正是 orm 框架的任务之一。
parameterType 表示传入参数类型,在 sql 语句中可以使用#{参数名}来调用,比如

1
sqlSession.selectOne("com.tallate.UserMapper.selectUser", 1);

传入了一个 Integer 类型的参数 1,那么 PreparedStatementHandler 在准备语句时,应该对这个参数的类型进行判断,这个是由 ParameterHandler 负责的。
resultType 表示返回值类型,PreparedStatementHandler 在获得 ResultSet 后应该将查询到的表记录转换为 Java 对象,这个是由 ResultSetHandler 负责的,它最终会调用对应类的构造函数将查询出的结果传入。

动态代理

我们平常使用 MyBatis 时都会定义一个 XXMapper 接口,对应 mapper.xml 中的一个 namespace,而且我们也不必显示写出其实现类,调用过程都是由动态代理实现的。
一般来说,代理类和被代理类应该实现相同的接口,但是现在我们的被代理类是一个 xxmapper.xml 文件,所以问题现在变成了:怎么将 xxmapper.xml 文件转换成被代理类。
查看源码中的 MapperProxyFactory 和 MapperProxy 可以知道,MyBatis 实现 Mapper 接口其实是调用了 SqlSession 中的方法(select、selectOne 等,已经实现了),但是它们的方法名并不相同,比如 selectUser 怎么和 selectOne 关联上呢?
MapperProxy 的 invoke 方法并不是直接调用被代理对象的方法,而是使用 MapperMethod 来表示映射的方法,通过 MapperMethod 可以判断接口方法的返回值、方法名等来确定应该调用 SqlSession 的哪个方法

  1. 启动时 XMLConfigBuilder 会为 config.xml 中所有 mapper 节点扫描包下所有映射器
  2. 创建对应的映射 interface -> MapperProxyFactory
  3. 添加动态代理对象到 MapperRegistry 中(我为了方便,直接加到 Config 中了,其实是刚开始对 MapperRegistry 的功能理解错了…)
  4. 之后每次 getMapper,都可以根据接口名来找到对应的动态代理对象,调用方法时实际上是调用了相应的 MapperMethod

并发

有哪些资源是中心化的?如果是,会不会被多线程同时访问?在 web 环境中,假设每个用户代表一个线程,当他们同时访问服务器就会出现并发问题。

  • 线程池(数据源)
    如果线程池是使用链表(LinkedList)实现的,可以使用 Collections.synchronizedList 进行包装,或者直接使用 Vector
  • Map<String, MappedStatement> MappedStatements
    使用 Map 容器储存 MappedStatement,MappedStatement 表示调用语句到 sql 语句的映射,比如”namespace.selectUser”到 mapper.xml 中对应的 sql 语句(使用 SqlSource 包装)。
  • List environments
    表示 config.xml 中注册的所有环境对象列表
  • List mappers
    表示 config.xml 中注册的所有 mapper 的列表
  • Map<Method, MapperMethod> methodCache
    MapperProxyFactory 中的映射器方法缓冲是使用 ConcurrentHashMap 实现的

QA

  1. 一级缓存不够吗?为什么要有二级缓存?
    一级缓存是会话级缓存,在 BaseExecutor 中,是成员变量,生命周期在一个 SqlSession 内,连接断开就没了;
    二级缓存是语句级缓存,在 MappedStatement 中,可以跨多个 SqlSession,当一些数据不常发生变化或者允许偶尔的并发的时候,二级缓存可能更有效率。
  2. 为什么不推荐使用 MyBatis 中的缓存?
    一级缓存会产生脏数据。因为作用范围是会话,如果有俩会话,会话 1 加载数据到缓存,会话 2 修改该条数据,之后会话 1 读到的是缓存里的老数据。
    二级缓存同样会产生脏数据。二级缓存作用范围是语句,需要手动刷新或在 xml 中配置需要刷新,一般在写入操作和事务提交后都需要刷新一下。但是如果表 A 的 Amapper.xml 中关联了表 B,即使表 B 的数据有变更,我们在 Amapper.xml 中执行查询语句仍然会读到缓存中的脏数据。
  3. MyBatis 与 JDBC 对象之前的关联?
    ParameterStatement - ParameterStatementHandler
    SimpleStatement - SimpleStatementHandler
    ResultSet - ResultSetHandler

单机环境下的锁

单机环境下,资源竞争者都是来自机器内部(进程/线程),那么实现锁的方案只需要借助单机资源就可以了,比如借助磁盘、内存、寄存器来实现。

竞态条件(Race Condition)

计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。比如:

  1. 先检测(查询)后执行。执行依赖于检测的结果,而检测结果依赖于多个线程的执行时序,而多个线程的执行时序通常情况下是不固定不可判断的,从而导致执行结果出现各种问题。
  2. 延迟初始化(如单例的实例化)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class ObjFactory {  
    private Obj instance;

    public Obj getInstance(){
    if(instance == null){
    instance = new Obj();
    }
    return instance;
    }
    }
    如果两个线程同时调用 getInstance()就有可能出现:一个线程 A 创建了一个新对象 instance = obj1,立马被另一个线程 B 覆盖 instance = obj2,线程 A 返回了 obj1,线程 B 返回 obj2,于是 Obj 就相当于被实例化了两次。

锁的分类

  1. 悲观锁,前提是,一定会有并发抢占资源,强行独占资源,在整个数据处理过程中将数据处于锁定状态。
  2. 乐观锁,前提是,不会发生并发抢占资源,只有在执行修改时检查是否违反数据完整性。只能防止脏读后数据的提交,不能解决脏读

悲观锁

乐观锁

乐观锁一般有以下两种实现方法:

  1. 版本号:使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃再次尝试的策略。
  2. CAS:java 中的 compareandswap 即 cas,解决多线程并行情况下使用锁造成性能损耗的一种机制。CAS 操作包含三个操作数,内存位置(V),预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会西东将该位置值更新为新值。否则,处理器不做任何操作。

分布式锁

目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP 理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)可用性(Availability)分区容错性(Partition tolerance),最多只能同时满足其中两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。
有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java 中其实提供了很多并发处理相关的 API,但是这些 API 在分布式场景中就无能为力了。也就是说单纯的 Java Api 并不能提供分布式锁的能力。
对于分布式环境下,资源竞争者生存环境更复杂了,原有依赖单机的方案不再发挥作用,这时候就需要一个大家都认可的协调者出来,帮助解决竞争问题,那这个协调者称之为分布式锁。

实现分布式锁的需求(方法锁,以方法作为临界区,资源锁是类似的)

  1. 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
  2. 这把锁要是一把可重入锁(单线程可重复获取同一把锁,避免死锁)
  3. 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
  4. 有高可用的获取锁和释放锁功能
  5. 获取锁和释放锁的性能要好

基于数据库表

要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。
当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。
创建这样一张数据库表:

1
2
3
4
5
6
7
8
CREATE TABLE `methodLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

使用锁表实现方法锁

执行 SQL:

1
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)

因为我们对 method_name 做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
当方法执行完毕之后,想要释放锁的话,需要执行以下 Sql:

1
delete from methodLock where method_name ='method_name'

上面这种简单的实现有以下几个问题:

  • 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  • 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  • 这把锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  • 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据库中数据已经存在了。

当然,我们也可以有其他方式解决上面的问题。

  • 数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
  • 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  • 非阻塞的?搞一个 while 循环,直到 insert 成功再返回成功。
  • 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

使用数据库 X 锁(排他锁)实现分布式锁

除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。
我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。
基于 MySQL 的 InnoDB 引擎,可以使用以下方法来实现加锁操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public boolean lock(){
connection.setAutoCommit(false)
while(true){
try{
result = select * from methodLock where method_name = xxx for update;
if(result==null){
return true;
}
}catch(Exception e){
log.warn("加锁失败", e);
}
sleep(1000);
}
return false;
}

在查询语句后面增加 for update,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:

1
2
3
public void unlock(){
connection.commit();
}

通过 connection.commit()操作来释放锁。
这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。

  • 阻塞锁? for update 语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。
  • 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。
    但是还是无法直接解决数据库单点和可重入问题。

总结

总结一下使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。
数据库实现分布式锁的优点:

  1. 直接借助数据库,容易理解。

数据库实现分布式锁的缺点

  1. 会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
  2. 操作数据库需要一定的开销,性能问题需要考虑。

基于缓存

使用缓存中间件实现分布式锁的方法我已经在Redis 客户端中有过分析。

基于 ZooKeeper

基于 zookeeper 临时有序节点可以实现的分布式锁。
大致思想即为:每个客户端对某个方法加锁时,在 zookeeper 上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点
判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。
当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
来看下 Zookeeper 能不能解决前面提到的问题。

  • 锁无法释放?使用 Zookeeper 可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在 ZK 中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session 连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
  • 非阻塞锁?使用 Zookeeper 可以实现阻塞的锁,客户端可以通过在 ZK 中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper 会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
  • 不可重入?使用 Zookeeper 也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
  • 单点问题?使用 Zookeeper 可以有效的解决单点问题,ZK 是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。

使用 Curator 实现分布式锁

可以直接使用 zookeeper 第三方库 Curator 客户端,这个客户端中封装了一个可重入的锁服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
try {
return interProcessMutex.acquire(timeout, unit);
} catch (Exception e) {
e.printStackTrace();
}
return true;
}
public boolean unlock() {
try {
interProcessMutex.release();
} catch (Throwable e) {
log.error(e.getMessage(), e);
} finally {
executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);
}
return true;
}

Curator 提供的 InterProcessMutex 是分布式锁的实现。acquire 方法用户获取锁,release 方法用于释放锁。
使用 ZK 实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper 实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK 中创建和删除节点只能通过Leader服务器来执行,然后将数据同步到所有的 Follower 机器上。

总结

使用 Zookeeper 实现分布式锁的优点

  1. 有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。
  2. 实现起来较为简单。

使用 Zookeeper 实现分布式锁的缺点

  1. 性能上不如使用缓存实现分布式锁。
  2. 需要对 ZK 的原理有所了解。

分布式锁实现需要根据实际需要来选择,比如红锁是AP的,而ZooKeeper是CP的。

QA

  1. 怎么使用 Redis 实现分布式锁?
    set 命令带上 nx 和 ex 参数。
  2. 怎么使用 zk 实现分布式锁?
    先建一个代表锁的持久节点,然后每个线程要加锁就在该持久节点下创建临时有序节点,如果当前线程创建的节点是最小的,则说明可以获取到该锁,否则阻塞等待;释放锁就是将这个临时节点删除。

参考

  1. 分布式锁的几种实现方式
  2. 终极锁实战:单 JVM 锁+分布式锁

常用服务器配置

  • 启动选项和系统变量
    启动选项是运维启动 MySQL 时传入的一些参数,包括命令行启动选项和配置文件 my.cnf
    系统变量会影响 MySQL 进程的运行行为,大部分是由启动选项初始化的,有些是运行时自动生成的
  • 查看系统变量
    show [GLOBAL|SESSION] variables [like 匹配的模式];
  • 配置文件中配置组的概念
  • 配置作用范围
    1、GLOBAL 指配置文件或命令行启动选项设置的系统变量
    2、SESSION(LOCAL)刚连接时会被初始化为 GLOBAL 的变量,可以通过以下命令来设置
    SET [GLOBAL|SESSION] 系统变量名 = 值;
  • 状态变量
    指关于程序运行状态的变量,是只读的,不能手动修改
    比方说 Threads_connected 表示当前有多少客户端与服务器建立了连接,Handler_update 表示已经更新了多少行记录
    SHOW [GLOBAL|SESSION] STATUS [LIKE 匹配的模式];

InnoDB 统计数据

两种统计数据

InnoDB 中有两种统计数据:
1、永久性:服务器重启也不会消失,这些数据被存储到了innodb_table_statsinnodb_index_stats这两张表中;
2、非永久性:重启即消失。
可以通过服务器的innodb_stats_persistent变量来查看这个统计数据的方式。

innodb_table_stats 统计方式

1、n_rows(一个表中的记录行数)统计项的收集
按照一定算法选取几个叶子节点页面,计算每个页面中主键值记录数量,然后计算平均一个页面中主键值的记录数量乘以全部叶子节点的数量就算是该表的 n_rows 值
2、clustered_index_size 和 sum_of_other_index_sizes

  • 从数据字典里找到表的各个索引对应的根页面位置。
    系统表 SYS_INDEXES 里存储了各个索引对应的根页面信息。
  • 从根页面的 Page Header 里找到叶子节点段和非叶子节点段对应的 Segment Header。
    在每个索引的根页面的 Page Header 部分都有两个字段:
    PAGE_BTR_SEG_LEAF:表示 B+树叶子段的 Segment Header 信息。
    PAGE_BTR_SEG_TOP:表示 B+树非叶子段的 Segment Header 信息。
  • 从叶子节点段和非叶子节点段的 Segment Header 中找到这两个段对应的 INODE Entry 结构。
    这个是 Segment Header 结构:
  • 从对应的 INODE Entry 结构中可以找到该段对应所有零散的页面地址以及 FREE、NOT_FULL、FULL 链表的基节点。
    这个是 INODE Entry 结构:
  • 直接统计零散的页面有多少个,然后从那三个链表的 List Length 字段中读出该段占用的区的大小,每个区占用 64 个页,所以就可以统计出整个段占用的页面。
    这个是链表基节点的示意图:
  • 分别计算聚簇索引的叶子结点段和非叶子节点段占用的页面数,它们的和就是 clustered_index_size 的值,按照同样的套路把其余索引占用的页面数都算出来,加起来之后就是 sum_of_other_index_sizes 的值。

innodb_index_stats 统计方式

1
SELECT * FROM mysql.innodb_index_stats WHERE table_name = 'single_table';
  • n_leaf_pages:表示该索引的叶子节点占用多少页面。
  • size:表示该索引共占用多少页面。
  • n_diff_pfxNN:表示对应的索引列不重复的值有多少。其中的 NN 长得有点儿怪呀,啥意思呢?
    其实 NN 可以被替换为 01、02、03… 这样的数字。比如对于 idx_key_part 来说:
    n_diff_pfx01 表示的是统计 key_part1 这单单一个列不重复的值有多少。
    n_diff_pfx02 表示的是统计 key_part1、key_part2 这两个列组合起来不重复的值有多少。
    n_diff_pfx03 表示的是统计 key_part1、key_part2、key_part3 这三个列组合起来不重复的值有多少。
    n_diff_pfx04 表示的是统计 key_part1、key_part2、key_part3、id 这四个列组合起来不重复的值有多少。
  • 在计算某些索引列中包含多少不重复值时,需要对一些叶子节点页面进行采样,sample_size 列就表明了采样的页面数量是多少。

基于内存的非永久性统计数据

开启非永久性统计数据的方法:
1、将innodb_stats_persistent的值设置为 OFF;
2、直接在创建表或修改表时设置STATS_PERSISTENT属性的值为 0;

MySQL Server 统计数据

Server 层而不是 InnoDB(存储引擎层)统计数据。
1、查看连接数配置

1
show variables like '%max_connections%'

2、查看当前连接数

1
show full processlist;

数据恢复

数据的误删基本分以下几种情况:

  1. 使用 delete 语句误删数据行;
  2. 使用 drop table 或 truncate table 误删表;
  3. 使用 drop database 误删数据库;
  4. 使用 rm 命令误删整个 MySQL 实例。

误删行

使用 Flashback 工具通过闪回把数据恢复。
Flashback 恢复数据的原理,是修改 binlog 的内容(事务里的语句顺序颠倒、语句的语义颠倒比如 insert 变成 delete),拿回原库重放。而能够使用这个方案的前提是,需要确保 binlog_format=row 和 binlog_row_image=FULL。

误删库 / 表

误删库表的情况不能使用 Flashback 恢复,因为即使配置 binlog_format=row,truncate/drop 语句在 binlog 中也只会记录一条对应的语句,而用这些信息是无法恢复数据的。
这种情况下,恢复需要使用全量备份,加增量日志。这个方案要求线上有定期的全量备份,并且实时备份 binlog。

rm 删除数据

仅仅删除某个节点的数据的情况,HA 系统可以选出新的主库,从而保证整个集群的正常工作。
之后,我们可以在这个被删节点上把数据恢复回来,再接入整个集群。

中断查询

有时候因为查询耗时过长,或出现死锁等待,我们不得不提早终止执行 SQL 的线程,可以通过information_schema.processlistperformance_schema.threads这两张表来查看正在执行的线程:

  • processlist 表中每一行对应一个客户端连接,也对应一个线程;
  • threads 每一行对应一个线程。

kill query pid可以杀死线程,但是客户端的连接还在,可以看到被 kill 后该连接进入了 Sleep 状态:

1
2
# Id, User, Host, db, Command, Time, State, Info
'494633', 'beta', '192.168.19.142:56193', 'ds_0', 'Sleep', '26', '', NULL

kill pid可以中断连接,执行后再用processlist就找不到那个 pid 了。

在客户端 Ctrl + C 并不能中断服务器线程,只能中断客户端进程,

大表查询

Server 层

MySQL 使用缓存来保证一次性查询大量数据的情况下不会把服务器内存打满,服务器并不需要保存一个完整的结果集。取数据和发数据的流程如下:
MySQL-查询结果发送流程

  1. 获取一行,写到 net_buffer 中。这块内存的大小是由参数 net_buffer_length 定义的,默认是 16k。
  2. 重复获取行,直到 net_buffer 写满,调用网络接口发出去。
  3. 如果发送成功,就清空 net_buffer,然后继续取下一行,并写入 net_buffer。
  4. 如果发送函数返回 EAGAIN 或 WSAEWOULDBLOCK,就表示本地网络栈(socket send buffer)写满了,进入等待。直到网络栈重新可写,再继续发送。

从上面的流程可知,MySQL 一次查询占用的内存是有限的,最大是**min(net_buffer_length, socket send buffer)**,即不能超过 net_buffer_length 和 socket send buffer;

存储引擎层(InnoDB)

InnoDB 使用 Buffer Pool 管理内存数据页,如果 Buffer Pool 命中率足够高,那么大部分时候是不需要读磁盘的,直接从内存拿结果,可以加快查询速度。
执行 show engine innodb status ,可以看到“Buffer pool hit rate”字样,显示的就是当前的命中率,一般一个稳定服务的线上系统,要保证响应时间符合要求的话,内存命中率要在 99% 以上。
Buffer Pool 的空间是有限的,新旧页面的更替是通过 LRU 算法控制的,但 InnoDB 中的 LRU 并不是单纯的新页面替换老页面(因为这样相当于每次大查询都会把整个 Buffer Pool 都刷新一遍),而是将 LRU 链表分成了 young 区和 old 区,页面第一次被访问时会被添加到 old 区,old 区的页面如果是短期内被多次访问,则其不会被移动到链表的头部(young 区),会很快被淘汰掉。

临时表

1
2
3
4
create temporary table temp_t like t1;
alter table temp_t add index(b);
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

临时表特性:

  1. 不同 session 的临时表是可以重名的,常被用在复杂查询的优化过程中,比如有多个 session 同时执行 join 优化,不需要担心表名重复导致建表失败的问题。
  2. 不需要担心数据删除问题。如果使用普通表,在流程执行过程中客户端发生了异常断开,或者数据库发生异常重启,还需要专门来清理中间过程中生成的数据表。而临时表由于会自动回收,所以不需要这个额外的操作。

临时表的使用场景

union 语句

表 t1 在执行前已初始化插入了 1~1000 的数据。

1
(select 1000 as f) union (select id from t1 order by id desc limit 2);

MySQL-union执行流程
上面语句将两个子查询的结果合并去重,union 合并时会生成临时表,这可以通过 explain 来验证。

group by

1
select id%10 as m, count(*) as c from t1 group by m;

MySQL-groupby执行流程
上面语句先创建内存临时表,表里有 m 和 c 两个字段,主键是 m,扫描 t1 索引 a,将id%10的结果插入临时表,如果出现主键冲突则计算 c 值+1。

  1. 加索引
    默认情况下id%10是无序的,所以需要先在临时表中统计排序后再返回,但是如果原表本身就是有序的,则不需要临时表、也不需要额外排序了,实际上只要引入索引就可以解决这个问题,因为索引是有序的
  2. 如果不能加索引,也可以加一列 generated column
    MySQL5.7 支持 generated column 机制,并可以在该列上创建索引:
    1
    alter table t1 add column z int generated always as(id % 100), add index(z);
    上面的 group by 语句可以改成如下的形式:
    1
    select z, count(*) as c from t1 group by z;
  3. 如果不需要排序,可以显式声明忽略排序
    如果对 group by 语句的结果没有排序要求,要在语句后面加 order by null
  4. 数据量小时使用内存临时表
    如果 group by 需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大 tmp_table_size 参数,来避免用到磁盘临时表;
  5. 数据量大时使用磁盘临时表
    如果数据量较大,因为内存临时表的空间是有限的,当达到上限后就会转到磁盘内存表,与其这样转一下,不如直接使用磁盘内存表。
    因此,如果数据量实在太大,使用 SQL_BIG_RESULT 这个提示,来告诉优化器直接使用排序算法得到 group by 的结果。

Memory 引擎

Memory 引擎与 InnoDB 引擎区别

  1. 数据组织方式
    InnoDB 引擎采用 B+树来组织数据,主键是有序存储的。InnoDB 引擎把数据放在主键索引上,其他索引上保存的是主键 id。这种方式,我们称之为索引组织表(Index Organizied Table)
    Memory 引擎的数据和索引是分开的,数据以数组的方式单独存放,而主键索引是 hash 索引,存的是每个数据的位置,索引上的 key 并不是有序的:
    MySQL-Memory引擎数据组织
    Memory 引擎采用的是把数据单独存放,索引上保存数据位置的数据组织形式,我们称之为堆组织表(Heap Organizied Table)
  2. 存放顺序
    InnoDB 表的数据总是有序存放的,而内存表的数据就是按照写入顺序存放的;
  3. 当数据文件有空洞的时候,InnoDB 表在插入新数据的时候,为了保证数据有序性,只能在固定的位置写入新值,而内存表找到空位就可以插入新值;
  4. 数据位置发生变化的时候,InnoDB 表只需要修改主键索引,而内存表需要修改所有索引;
  5. InnoDB 表用主键索引查询时需要走一次索引查找,用普通索引查询的时候,需要走两次索引查找。而内存表没有这个区别,所有索引的“地位”都是相同的。
  6. InnoDB 支持变长数据类型,不同记录的长度可能不同;内存表不支持 Blob 和 Text 字段,并且即使定义了 varchar(N),实际也当作 char(N),也就是固定长度字符串来存储,因此内存表的每行数据长度相同。

hash 索引和 B-Tree 索引

内存表也支持 B-Tree 索引:

1
alter table t1 add index a_btree_index using btree (id);

MySQL-内存表B-Tree索引
可以查看以下两个语句的输出:

1
2
3
4
-- 命中索引a_btree_index,因此输出结果是有序的
select * from t1 where id < 5;
-- 强制使用主键id索引,因此是无序的
select * from t1 force index (primary) where id < 5;

不推荐在生产环境使用 Memory 引擎

  1. 锁粒度问题
    内存表不支持行锁,只支持表锁,只要这张表上有更新,就会堵住所有其他在这张表上的读写操作,因此在处理并发事务时性能也不会太好。
  2. 数据持久化问题
    因为数据被存放在内存中,数据库重启时所有的内存表都会被清空。

虽然一般情况下不适合使用内存表,但是还有一种情况可以考虑使用内存表:用户临时表,只是临时数据,如果数据可控,不会消耗过多内存的情况下,可以考虑使用内存表。
内存临时表(通过 create temporary table 语句创建)刚好可以无视内存表的两个不足,主要是下面的三个原因:

  1. 临时表不会被其他线程访问,没有并发性的问题;
  2. 临时表重启后也是需要删除的,清空数据这个问题不存在;
  3. 备库的临时表也不会影响主库的用户线程。

备份

  • 将数据导出成一组 insert 语句
    1
    mysqldump -h$host -P$port -u$user --add-locks=0 --no-create-info --single-transaction  --set-gtid-purged=OFF db1 t --where="a>900" --result-file=/client_tmp/t.sql
    恢复:
    1
    mysql -h127.0.0.1 -P13000  -uroot db2 -e "source /client_tmp/t.sql"
  • 导出 CSV 文件
    1
    select * from db1.t where a>900 into outfile '/server_tmp/t.csv';
    恢复,将数据导入到目标表 db2.t 中:
    1
    load data infile '/server_tmp/t.csv' into table db2.t;
  • 物理拷贝
    不能通过直接拷贝表的.frm 文件和.ibd 文件来实现物理拷贝,因为一个 InnoDB 表除了包含这两个物理文件外,还需要在数据字典中注册,直接拷贝的情况下系统不会识别。
    在 MySQL 5.6 版本引入了可传输表空间(transportable tablespace) 的方法,可以通过导出 + 导入表空间的方式,实现物理拷贝表的功能。
    1. 执行 create table r like t,创建一个相同表结构的空表;
    2. 执行 alter table r discard tablespace,这时候 r.ibd 文件会被删除;
    3. 执行 flush table t for export,这时候 db1 目录下会生成一个 t.cfg 文件;
    4. 在 db1 目录下执行 cp t.cfg r.cfg; cp t.ibd r.ibd;
    5. 这两个命令(这里需要注意的是,拷贝得到的两个文件,MySQL 进程要有读写权限);
    6. 执行 unlock tables,这时候 t.cfg 文件会被删除;
    7. 执行 alter table r import tablespace,将这个 r.ibd 文件作为表 r 的新的表空间,由于这个文件的数据内容和 t.ibd 是相同的,所以表 r 中就有了和表 t 相同的数据。

这三种方法各有优劣:

  1. 物理拷贝的方式速度最快,尤其对于大表拷贝来说是最快的方法。如果出现误删表的情况,用备份恢复出误删之前的临时库,然后再把临时库中的表拷贝到生产库上,是恢复数据最快的方法。但是,这种方法的使用也有一定的局限性:
    • 必须是全表拷贝,不能只拷贝部分数据;
    • 需要到服务器上拷贝数据,在用户无法登录数据库主机的场景下无法使用;……
    • 由于是通过拷贝物理文件实现的,源表和目标表都是使用 InnoDB 引擎时才能使用。
  2. 用 mysqldump 生成包含 INSERT 语句文件的方法,可以在 where 参数增加过滤条件,来实现只导出部分数据。这个方式的不足之一是,不能使用 join 这种比较复杂的 where 条件写法。
  3. 用 select … into outfile 的方法是最灵活的,支持所有的 SQL 写法。但,这个方法的缺点之一就是,每次只能导出一张表的数据,而且表结构也需要另外的语句单独备份。

MySQL 中的自增 ID

表的自增 id

我们经常给表的主键加上自增属性,用于唯一标识一条记录,但是因为自增值达到上限后再申请得到的值不变,因此自增字段的范围应该略大一些,尽可能创建成bigint unsigned

row_id

如果没有指定主键,InnoDB 会创建一个不可见的、长度为 6 字节的 row_id,超过上限后再申请时会得到 0,如果新写入的行的 row_id 在表中已存在,则会直接覆盖原有的行,因此,最好优先使用自增 ID 而不是 row_id。

Xid

Xid 用于唯一标识一个事务。Xid 的值由一个内存变量 global_query_id 给出,重启后清零,但是因为每次重启时 binlog 都会重新生成,所以 binlog 中的 Xid 也不会重复。global_query_id 的长度为 8 个字节,除非 MySQL 实例一直执行了2^64 - 1次查询且期间没有重启,不然不会出现 Xid 重复的情况。

max_trx_id

Xid 由 server 层维护。InnoDB 内部使用 Xid,就是为了能够在 InnoDB 事务和 server 之间做关联。但是,InnoDB 自己的 trx_id,是另外维护的。
InnoDB 内部维护了一个 max_trx_id 全局变量,每次需要申请一个新的 trx_id 时,就获得 max_trx_id 的当前值,然后并将 max_trx_id 加 1。

InnoDB 事务在读操作时不会申请 trx_id,trx_id 的值就是 0,只有在加锁或执行写操作时才会申请。
只读事务不申请 trx_id 的原因是只读事务不影响事务的可见性判断,且能减少 trx_id 的申请次数、减少并发事务申请 trx_id 的锁冲突。

MVCC 判断数据可见性的核心思想:每一行数据都记录了更新它的 trx_id,当一个事务读到一行数据的时候,判断这个数据是否可见的方法,就是通过事务的一致性视图与这行数据的 trx_id 做对比。

thread_id

系统保存一个全局变量 thread_id_counter,每新建一个连接就将 thread_id_counter 赋值给这个新连接的线程变量。

为什么使用 Netty

  1. 实现协议的局限性
    今天,我们使用通用的应用程序或者类库来实现互相通讯,比如,我们经常使用一个 HTTP 客户端库来从 web 服务器上获取信息,或者通过 web 服务来执行一个远程的调用。
    然而,有时候一个通用的协议或他的实现并没有很好的满足需求。比如我们无法使用一个通用的 HTTP 服务器来处理大文件、电子邮件以及近实时消息,比如金融信息和多人游戏数据。我们需要一个高度优化的协议来处理一些特殊的场景。例如你可能想实现一个优化了的 Ajax 的聊天应用、媒体流传输或者是大文件传输器,你甚至可以自己设计和实现一个全新的协议来准确地实现你的需求。
    另一个不可避免的情况是当你不得不处理遗留的专有协议来确保与旧系统的互操作性。在这种情况下,重要的是我们如何才能快速实现协议而不牺牲应用的稳定性和性能。
  2. 使用 Netty 可以有效改善这种情况
    Netty 是一个提供 asynchronous event-driven (异步事件驱动)的网络应用框架,是一个用以快速开发高性能、高可靠性协议的服务器和客户端。
    换句话说,Netty 是一个 NIO 客户端服务器框架,使用它可以快速简单地开发网络应用程序,比如服务器和客户端的协议。Netty 大大简化了网络程序的开发过程比如 TCP 和 UDP 的 socket 服务的开发。
    “快速和简单”并不意味着应用程序会有难维护和性能低的问题,Netty 是一个精心设计的框架,它从许多协议的实现中吸收了很多的经验比如 FTP、SMTP、HTTP、许多二进制和基于文本的传统协议.因此,Netty 已经成功地找到一个方式,在不失灵活性的前提下来实现开发的简易性,高性能,稳定性。
    有一些用户可能已经发现其他的一些网络框架也声称自己有同样的优势,所以你可能会问是 Netty 和它们的不同之处。答案就是 Netty 的哲学设计理念。Netty 从开始就为用户提供了用户体验最好的 API 以及实现设计。正是因为 Netty 的哲学设计理念,才让您得以轻松地阅读本指南并使用 Netty。

架构总览

Netty架构总览
Netty 的架构由三部分组成——缓冲(buffer),通道(channel),事件模型(event model)——所有的高级特性都构建在这三个核心组件之上。

NIO

  1. 想了解 Aio 与 Nio 的利弊,为什么 Netty 没有采用 Aio 实现?

NIO 基于传输层,可以自定义数据处理逻辑来作为应用层,或者基于现有的 HTTP 组件进行升级,在线上环境这样的升级会带来一些兼容性问题,HTTP 已有相应的协议升级机制:Protocol upgrade mechanism

NIO 相对 BIO 优势:

  1. 零拷贝
    零拷贝减少线程上下文切换次数,且数据直接拷贝到内核空间,不占用 JVM 堆空间;
  2. 减少线程资源浪费
    NIO 可以一个线程监听多个 Socket 的连接、读、写请求,而不是像 BIO 那样每个 Socket 创建一个线程,但是同时会有一个问题:

Netty 核心组件

  1. Channel 和 ChannelHandler
  2. ByteBuf
  3. Pipeline

服务端

Netty流程

代码

下面是一个启动Netty服务端的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup(), workerGroup())
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {

@Override
protected void initChannel(SocketChannel ch) throws Exception {
// 空闲检测
ch.pipeline().addLast("idleStateHandler", new IdleStateHandler(15, 0, 0,
TimeUnit.MINUTES));

// 半包/粘包分解器
ch.pipeline().addLast(
new DelimiterBasedFrameDecoder(2048, true, getFirstBytes()
));
ch.pipeline().addLast(其他Handler比如解码之类的);
}
}).option(ChannelOption.SO_BACKLOG, 1024);
bootstrap.bind(10885).sync()

创建EventLoop

在上面的代码中,出现了bossGroupworkerGroup,bossGroup主要负责监听连接,拿到连接后,交给workerGroup中的线程来监听读或写事件。
io.netty.util.concurrent.MultithreadEventExecutorGroup#MultithreadEventExecutorGroup
EventExecutorGroup会给每个线程创建一个EventLoop

io.netty.channel.nio.NioEventLoop#NioEventLoop
newChild()创建EventLoop实例,其默认实现是NioEventLoop

io.netty.util.concurrent.MultithreadEventExecutorGroup#MultithreadEventExecutorGroup(int, java.util.concurrent.Executor, io.netty.util.concurrent.EventExecutorChooserFactory, java.lang.Object...)
服务器初始化过程中创建了个线程池ThreadPerTaskExecutor

  • 每次执行任务都会构造一个线程执行
    io.netty.util.concurrent.ThreadPerTaskExecutor#execute

创建及初始化 ServerSocketChannel

Netty 有一个叫做 Channel 的统一的异步 I/O 编程接口,这个编程接口抽象了所有点对点的通信操作。也就是说,如果你的应用是基于 Netty 的某一种传输实现,那么同样的,你的应用也可以运行在 Netty 的另一种传输实现上。Netty 提供了几种拥有相同编程接口的基本传输实现:

  • 基于 NIO 的 TCP/IP 传输 (见 io.netty.channel.nio),
  • 基于 OIO 的 TCP/IP 传输 (见 io.netty.channel.oio),
  • 基于 OIO 的 UDP/IP 传输, 和
  • 本地传输 (见 io.netty.channel.local).

切换不同的传输实现通常只需对代码进行几行的修改调整,例如选择一个不同的 ChannelFactory 实现。
此外,你甚至可以利用新的传输实现没有写入的优势,只需替换一些构造器的调用方法即可,例如串口通信。而且由于核心 API 具有高度的可扩展性,你还可以完成自己的传输实现。

  1. 入口
    io.netty.bootstrap.AbstractBootstrap#bind(int)
    用户代码调用bind绑定端口时会触发Channel的创建和初始化

io.netty.bootstrap.ServerBootstrap#init
对Channel的使用可以追溯到这个init方法,包括Channel的创建、属性等的设置。

  1. 创建
    NioServerSocketChannel的构造方法 -> io.netty.channel.socket.nio.NioServerSocketChannel#newSocket
    可以看到,Netty中的ServerSocketChannel其实就对应JDK NIO中的ServerSocketChannel,在创建NioServerSocketChannel的同时创建了一个NIO中的ServerSocketChannel

  2. 初始化
    中间包含对childOptionschildAttrs等的设置。

  3. 添加一个连接处理器ServerBootstrapAcceptor

注册Selector

紧接着上面的初始化过程,接下来是注册NIO中的Selector。
io.netty.channel.EventLoopGroup#register(io.netty.channel.Channel)
总而言之最终还是使用NIO注册了 Selector。
io.netty.channel.nio.AbstractNioChannel#doRegister

启动 NioEventLoop

io.netty.bootstrap.AbstractBootstrap#doBind0
绑定端口号的同时,执行一个线程。
io.netty.util.concurrent.SingleThreadEventExecutor#startThread
NioEventLoop启动流程的最终启动了一个线程。
io.netty.channel.nio.NioEventLoop#run
该线程任务根据EventLoop的实现不同而有所不同,在NioEventLoop中,主要任务为以下3步:

  1. 接收事件(selectionKey)
    io.netty.channel.nio.NioEventLoop#select
    当检查没有需要处理的selectionKey时就会发生空轮询,Netty在轮询时会记录空轮询次数,当空轮询达到一定次数时,将之前注册的事件先取消,从而避免了NIO的空轮询Bug

  2. 检测新连接并创建NioSocketChannel
    io.netty.channel.nio.NioEventLoop#processSelectedKeys
    处理连接请求,并分发请求到pipeline
    io.netty.channel.nio.AbstractNioMessageChannel.NioMessageUnsafe#read

    • 每个连接创建一个ServerSocketChannel
      io.netty.channel.socket.nio.NioServerSocketChannel#doReadMessages
    • 读取数据并分发到pipeline
      io.netty.channel.ChannelPipeline#fireChannelReadComplete
  3. 执行线程任务
    io.netty.util.concurrent.SingleThreadEventExecutor#runAllTasks(long)

pipeline中的第一个ChannelHandler

pipeline的第一个Handler为ServerBootstrapAcceptor,它的主要任务包括:

  1. 将用户自定义ChannelHandler添加到pipeline

  2. 选择一个NioEventLoop传播事件
    io.netty.channel.MultithreadEventLoopGroup#register(io.netty.channel.Channel)

  3. 注册selector
    代码流程非常长,但是最终可以跟到doRegister这个方法,可以发现最后还是调用了JDK的SocketChannel注册Selector。
    io.netty.channel.AbstractChannel.AbstractUnsafe#register0 -> io.netty.channel.nio.AbstractNioChannel#doRegister

  4. 注册读事件
    代码最后判断第一次连接则触发连接激活事件,代码位置仍然是上边的register0
    io.netty.channel.AbstractChannel.AbstractUnsafe#register0
    继续往下看可以看到最终将读事件(selectionKey)注册到了Selector
    io.netty.channel.DefaultChannelPipeline.HeadContext#channelActive
    -> io.netty.channel.DefaultChannelPipeline.HeadContext#readIfIsAutoRead
    -> io.netty.channel.nio.AbstractNioChannel#doBeginRead

选择EventLoop:
io.netty.util.concurrent.MultithreadEventExecutorGroup#chooser
每当有客户端连接进来时,Netty需要决定选择哪个EventLoop,这个工作是由EventExecutorChooser负责的:

  • GenericEventExecutorChooser:循环选择。
  • PowerOfTwoEventExecutorChooser:也是循环选择,只不过GenericEventExecutorChooser使用了取模运算,而PowerOfTwoEventExecutorChooser是通过位运算实现的。

Pipeline

  1. 创建Pipeline
    创建NioSocketChannel时会创建Pipeline:
    io.netty.channel.AbstractChannel#AbstractChannel
    Pipeline本身是一个双向链表的结构,且有两个哨兵节点headtail

  2. 添加Pipeline
    添加到链表
    io.netty.channel.ChannelPipeline#addLast(io.netty.channel.ChannelHandler...)
    检查是否重复添加,如果加了@Sharable注解是可以重复添加的
    io.netty.channel.DefaultChannelPipeline#checkMultiplicity
    添加到链表末尾,也就是添加到tail节点的前面。
    io.netty.channel.DefaultChannelPipeline#addLast0

  3. 删除Pipeline
    有时候我们需要删除一个Pipeline上的某些ChannelHandler,比如已经进行过了授权校验,那下次就不需要再执行授权校验了,我们就可以直接把授权相关的那些ChannelHandler删除掉。
    首先遍历Pipeline找到目标ChannelHandler。
    io.netty.channel.DefaultChannelPipeline#getContextOrDie
    然后从Pipeline中移除。
    io.netty.channel.DefaultChannelPipeline#remove(AbstractChannelHandlerContext)

  4. inBound事件传播
    ChannelHandler中每个事件都有一个接口,ChannelInboundHandler专门处理输入事件,以channelRead为例。
    EventLoop会将读事件传给Pipeline,然后按责任链模式的逻辑从head节点开始传播事件。
    io.netty.channel.nio.AbstractNioByteChannel.NioByteUnsafe#read

  5. outBound事件传播
    ChannelOutboundHandler专门用于处理输出事件,以write为例。

    1
    2
    3
    4
    5
    6
    7
    public class EchoServerOutHandler extends ChannelOutboundHandlerAdapter {

    @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
    ctx.channel().write("Hello");
    }
    }

    当我们在Handler中调用Context的write方法时,就是将写事件传给了Pipeline,Pipeline会从tail节点开始往前传播。
    io.netty.channel.AbstractChannelHandlerContext#write

心跳检测

应用协议层的心跳是必须的,它和 tcp keepalive 是完全不同的概念。应用层协议层的心跳检测的是连接双方的存活性,兼而连接质量,而 keepalive 检测的是连接本身的存活性。而且后者的超时时间默认过长,完全不能适应现代的网络环境。
Netty 内置通过增加 IdleStateHandler 产生 IDLE 事件进行便捷的心跳控制。你要处理的,就是心跳超时的逻辑,比如延迟重连。但它的轮训时间是固定的,无法动态修改,高级功能需要自己定制。
不同场景下需要切换不同的保活机制,在一些客户端比如 Android,频繁心跳的唤起会浪费大量的网络和电量,它的心跳策略会更加复杂一些。

优雅退出

Java 的优雅停机通常通过注册 JDK ShutdownHook 来实现。
Runtime.getRuntime().addShutdownHook();
一般通过 kill -15 进行 java 进程的关闭,以便在进程死亡之前进行一些清理工作。

注意:kill -9 会立马杀死进程,不给遗言的机会,比较危险。

虽然 netty 做了很多优雅退出的工作,通过 EventLoopGroup 的 shutdownGracefully 方法对 nio 进行了一些状态设置,但在很多情况下,这还不够多。它只负责单机环境的优雅关闭。
流量可能还会通过外层的路由持续进入,造成无效请求。一种可行的做法是首先在外层路由进行一次本地实例的摘除,把流量截断,然后再进行 netty 本身的优雅关闭。

示例协议实现

不少中间件会实现自己的协议,比如 Redis、MySQL,MyCat、TiDB 用的就是 MySQL 协议。
netty 默认实现了 dns、haproxy、http、http2、memcache、mqtt、redis、smtp、socks、stomp、xml 等协议。
协议分为两种:

  • 文本协议在调试起来是比较直观和容易的,但安全性欠佳;
  • 二进制协议就需要依赖日志、wireshark 等其他方式进行分析,增加了开发难度。
  1. 示例协议 - echo
  2. 示例协议 - discard
  3. 示例协议 - uptime
  4. 示例二进制协议 - factorial
  5. 示例文本协议 - telnet

数据结构 - ByteBuf

Netty 使用自建的 buffer API,而不是使用 NIO 的 ByteBuffer 来表示一个连续的字节序列。与 ByteBuffer 相比这种方式拥有明显的优势。Netty 使用新的 buffer 类型 ByteBuf,被设计为一个可从底层解决 ByteBuffer 问题,并可满足日常网络应用开发需要的缓冲类型。这些很酷的特性包括:

  • 如果需要,允许使用自定义的缓冲类型。
  • 复合缓冲类型中内置的透明的零拷贝实现。
  • 开箱即用的动态缓冲类型,具有像 StringBuffer 一样的动态缓冲能力。
  • 不再需要调用的 flip()方法。
  • 正常情况下具有比 ByteBuffer 更快的响应速度。

ByteBuf结构
以上就是一个 ByteBuf 的结构图,从上面这幅图可以看到

  1. ByteBuf 是一个字节容器,容器里面的的数据分为三个部分,第一个部分是已经丢弃的字节,这部分数据是无效的;第二部分是可读字节,这部分数据是 ByteBuf 的主体数据, 从 ByteBuf 里面读取的数据都来自这一部分;最后一部分的数据是可写字节,所有写到 ByteBuf 的数据都会写到这一段。最后一部分虚线表示的是该 ByteBuf 最多还能扩容多少容量
  2. 以上三段内容是被两个指针给划分出来的,从左到右,依次是读指针(readerIndex)、写指针(writerIndex),然后还有一个变量 capacity,表示 ByteBuf 底层内存的总容量
  3. 从 ByteBuf 中每读取一个字节,readerIndex 自增 1,ByteBuf 里面总共有 writerIndex-readerIndex 个字节可读, 由此可以推论出当 readerIndex 与 writerIndex 相等的时候,ByteBuf 不可读
  4. 写数据是从 writerIndex 指向的部分开始写,每写一个字节,writerIndex 自增 1,直到增到 capacity,这个时候,表示 ByteBuf 已经不可写了
  5. ByteBuf 里面其实还有一个参数 maxCapacity,当向 ByteBuf 写数据的时候,如果容量不足,那么这个时候可以进行扩容,直到 capacity 扩容到 maxCapacity,超过 maxCapacity 就会报错

使用 ByteBuf 有以下好处:

  1. 可以有效地区分可读数据和可写数据,读写之间相互没有冲突
  2. Extensibility 可扩展性
    ByteBuf 具有丰富的操作集,可以快速的实现协议的优化。例如,ByteBuf 提供各种操作用于访问无符号值和字符串,以及在缓冲区搜索一定的字节序列。你也可以扩展或包装现有的缓冲类型用来提供方便的访问。自定义缓冲式仍然实现自 ByteBuf 接口,而不是引入一个不兼容的类型
  3. Transparent Zero Copy 透明的零拷贝
    网络应用中需要减少内存拷贝操作次数。你可能有一组缓冲区可以被组合以形成一个完整的消息。网络提供了一种复合缓冲,允许你从现有的任意数的缓冲区创建一个新的缓冲区而无需内存拷贝。例如,一个信息可以由两部分组成:header 和 body。在一个模块化的应用,当消息发送出去时,这两部分可以由不同的模块生产和装配。
    1
    2
    3
    +--------+------+
    | header | body |
    +--------+------+
    如果你使用的是 ByteBuffer ,你必须要创建一个新的大缓存区用来拷贝这两部分到这个新缓存区中。或者,你可以在 NIO做一个收集写操作,但限制你将复合缓冲类型作为 ByteBuffer 的数组而不是一个单一的缓冲区,这样打破了抽象,并且引入了复杂的状态管理。此外,如果你不从 NIO channel 读或写,它是没有用的。
    1
    2
    // 复合类型与组件类型不兼容。
    ByteBuffer[] message = new ByteBuffer[] { header, body };
    通过对比, ByteBuf 不会有警告,因为它是完全可扩展并有一个内置的复合缓冲区。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 复合类型与组件类型是兼容的。
    ByteBuf message = Unpooled.wrappedBuffer(header, body);
    // 因此,你甚至可以通过混合复合类型与普通缓冲区来创建一个复合类型。
    ByteBuf messageWithFooter = Unpooled.wrappedBuffer(message, footer);
    // 由于复合类型仍是 ByteBuf,访问其内容很容易,
    //并且访问方法的行为就像是访问一个单独的缓冲区,
    //即使你想访问的区域是跨多个组件。
    //这里的无符号整数读取位于 body 和 footer
    messageWithFooter.getUnsignedInt(
    messageWithFooter.readableBytes() - footer.readableBytes() - 1);
  4. Automatic Capacity Extension 自动容量扩展
    许多协议定义可变长度的消息,这意味着没有办法确定消息的长度,直到你构建的消息。或者,在计算长度的精确值时,带来了困难和不便。这就像当你建立一个字符串。你经常估计得到的字符串的长度,让 StringBuffer 扩大了其本身的需求。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 一种新的动态缓冲区被创建。在内部,实际缓冲区是被“懒”创建,从而避免潜在的浪费内存空间。
    ByteBuf b = Unpooled.buffer(4);
    // 当第一个执行写尝试,内部指定初始容量 4 的缓冲区被创建
    b.writeByte('1');
    b.writeByte('2');
    b.writeByte('3');
    b.writeByte('4');
    // 当写入的字节数超过初始容量 4 时,
    //内部缓冲区自动分配具有较大的容量
    b.writeByte('5');
  5. Better Performance 更好的性能
    最频繁使用的缓冲区 ByteBuf 的实现是一个非常薄的字节数组包装器(比如,一个字节)。与 ByteBuffer 不同,它没有复杂的边界和索引检查补偿,因此对于 JVM 优化缓冲区的访问更加简单。更多复杂的缓冲区实现是用于拆分或者组合缓存,并且比 ByteBuffer 拥有更好的性能。

粘包拆包和半包合并

基于流的传输比如 TCP/IP, 接收到数据是存在 socket 接收的 buffer 中。不幸的是,基于流的传输并不是一个数据包队列,而是一个字节队列。造成粘包的原因,主要是由于缓冲区的介入,所以需要严格约定去所传输的包的格式——何时开始何时结束。意味着,即使你发送了 2 个独立的数据包,操作系统也不会作为 2 个消息处理而仅仅是作为一连串的字节而言。因此这是不能保证你远程写入的数据就会准确地读取。举个例子,让我们假设操作系统的 TCP/TP 协议栈已经接收了 3 个数据包,在应用程序中读取数据的时候可能被分成下面的片段:
粘包和半包问题
因此,一个接收方不管他是客户端还是服务端,都应该把接收到的数据整理成一个或者多个更有意义并且能够让程序的业务逻辑更好理解的数据。
在没有 Netty 的情况下,用户如果自己需要拆包,基本原理就是不断从 TCP 缓冲区中读取数据,每次读取完都需要判断是否是一个完整的数据包

  • 半包:如果当前读取的数据不足以拼接成一个完整的业务数据包,那就保留该数据,继续从 TCP 缓冲区中读取,直到得到一个完整的数据包。
  • 粘包:如果当前读到的数据加上已经读取的数据足够拼接成一个数据包,那就将已经读取的数据拼接上本次读取的数据,构成一个完整的业务数据包传递到业务逻辑,多余的数据仍然保留,以便和下次读到的数据尝试拼接。

解码器 - ByteToMessageDecoder

入口:io.netty.handler.codec.ByteToMessageDecoder#channelRead

  1. 累加字节流
    累加器累加已读入的字节数,如果超过ByteBuf当前可读入的空间大小,则执行扩容。
    io.netty.handler.codec.ByteToMessageDecoder.Cumulator#cumulate
  2. 调用子类的decode方法进行解析(模板方法)
    io.netty.handler.codec.ByteToMessageDecoder#callDecode
  3. 将子类解析出的ByteBuf向下传播
    io.netty.handler.codec.ByteToMessageDecoder#fireChannelRead(io.netty.channel.ChannelHandlerContext, io.netty.handler.codec.CodecOutputList, int)

Netty中的一些拆箱即用的解码器

如果要自己实现所有协议的拆包无疑是非常麻烦的,实际上 Netty 已经自带了一些开箱即用的拆包器:

  1. 固定长度的拆包器 FixedLengthFrameDecoder
    如果你的应用层协议非常简单,每个数据包的长度都是固定的,比如 100,那么只需要把这个拆包器加到 pipeline 中,Netty 会把一个个长度为 100 的数据包 (ByteBuf) 传递到下一个 channelHandler。
  2. 行拆包器 LineBasedFrameDecoder
    从字面意思来看,发送端发送数据包的时候,每个数据包之间以换行符作为分隔,接收端通过 LineBasedFrameDecoder 将粘过的 ByteBuf 拆分成一个个完整的应用层数据包。
  3. 分隔符拆包器 DelimiterBasedFrameDecoder
    DelimiterBasedFrameDecoder 是行拆包器的通用版本,只不过我们可以自定义分隔符。
  4. 基于长度域拆包器 LengthFieldBasedFrameDecoder
    最后一种拆包器是最通用的一种拆包器,只要你的自定义协议中包含长度域字段,均可以使用这个拆包器来实现应用层拆包。由于上面三种拆包器比较简单,读者可以自行写出 demo,接下来,我们就结合我们小册的自定义协议,来学习一下如何使用基于长度域的拆包器来拆解我们的数据包。

编码 - MessageToByteEncoder

编码器是一个ChannelHandler,一般是第一个添加到Pipeline内,然后write的最后会将数据进行编码再输出。

  1. 匹配对象
    io.netty.handler.codec.MessageToByteEncoder#acceptOutboundMessage
  2. 内存分配
    io.netty.handler.codec.MessageToByteEncoder#allocateBuffer
  3. 调用子类的编码实现
    io.netty.handler.codec.MessageToByteEncoder#encode
  4. 释放内存
    io.netty.util.ReferenceCountUtil#release(java.lang.Object)
  5. 放到Pipeline里传播
    默认情况下会一直传播到head节点
    io.netty.channel.ChannelHandlerContext#write(java.lang.Object, io.netty.channel.ChannelPromise)
    io.netty.channel.Channel.Unsafe#write
  6. 输出
    将数据暂存到ByteBuf,将堆内对象转换为堆外内存
    io.netty.channel.nio.AbstractNioByteChannel#filterOutboundMessage
    插入写队列
    io.netty.channel.ChannelOutboundBuffer#addMessage
    TODO: 什么时候刷新buffer队列?

自定义数据处理逻辑

基于拦截链模式的事件模型 - pipeline

一个定义良好并具有扩展能力的事件模型是事件驱动开发的必要条件。Netty 具有定义良好的 I/O 事件模型。由于严格的层次结构区分了不同的事件类型,因此 Netty 也允许你在不破坏现有代码的情况下实现自己的事件类型。这是与其他框架相比另一个不同的地方。很多 NIO 框架没有或者仅有有限的事件模型概念;在你试图添加一个新的事件类型的时候常常需要修改已有的代码,或者根本就不允许你进行这种扩展。
在 Netty 中一条连接对应一个 Channel,该 Channel 的所有处理逻辑都在一个 ChannelPipeline 对象内,ChannelPipeline 是一个双向链表结构,在一个 ChannelPipeline 内部一个 ChannelEvent 被一组 ChannelHandler 处理。这个管道是 Intercepting Filter (拦截过滤器)模式的一种高级形式的实现,因此对于一个事件如何被处理以及管道内部处理器间的交互过程,你都将拥有绝对的控制力。例如,你可以定义一个从 socket 读取到数据后的操作:

1
2
3
4
5
6
7
8
9
public class MyReadHandler implements SimpleChannelHandler {
public void messageReceived(ChannelHandlerContext ctx, MessageEvent evt) {
Object message = evt.getMessage();
// Do something with the received message.
...
// And forward the event to the next handler.
ctx.sendUpstream(evt);
}
}

同时你也可以定义一种操作响应其他处理器的写操作请求:

1
2
3
4
5
6
7
8
9
public class MyWriteHandler implements SimpleChannelHandler {
public void writeRequested(ChannelHandlerContext ctx, MessageEvent evt) {
Object message = evt.getMessage();
// Do something with the message to be written.
...
// And forward the event to the next handler.
ctx.sendDownstream(evt);
}
}

ChannelHandler 分为两种:

  • ChannelInboundHandler
    处理读数据逻辑,核心方法是 channelRead。
  • ChannelOutBoundHandler
    处理些数据逻辑,核心方法是 write,在链式处理中总是位于 ChannelInboundHandler 之后。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
serverBootstrap
.childHandler(new ChannelInitializer<NioSocketChannel>() {
protected void initChannel(NioSocketChannel ch) {
// inBound,处理读数据的逻辑链
ch.pipeline().addLast(new InBoundHandlerA());
ch.pipeline().addLast(new InBoundHandlerB());
ch.pipeline().addLast(new InBoundHandlerC());

// outBound,处理写数据的逻辑链
ch.pipeline().addLast(new OutBoundHandlerA());
ch.pipeline().addLast(new OutBoundHandlerB());
ch.pipeline().addLast(new OutBoundHandlerC());
}
});

其执行顺序如下图所示:
pipeline执行顺序

异常处理

netty 由于其异步化的开发方式,以及其事件机制,在异常处理方面就显得异常重要。为了保证连接的高可靠性,许多异常需要静悄悄的忽略,或者在用户态没有感知。
netty 的异常会通过 pipeline 进行传播,所以在任何一层进行处理都是可行的,但编程习惯上,习惯性抛到最外层集中处理。
为了最大限度的区别异常信息,通常会定义大量的异常类,不同的错误会抛出不同的异常。发生异常后,可以根据不同的类型选择断线重连(比如一些二进制协议的编解码紊乱问题),或者调度到其他节点。

Codec 框架

我们可以使用 POJO 代替 ChannelBuffer,从业务逻辑代码中分离协议处理部分总是一个很不错的想法。然而如果一切从零开始便会遭遇到实现上的复杂性。你不得不处理分段的消息。一些协议是多层的(例如构建在其他低层协议之上的协议)。一些协议过于复杂以致难以在一台独立状态机上实现。
因此,一个好的网络应用框架应该提供一种可扩展,可重用,可单元测试并且是多层的 codec 框架,为用户提供易维护的 codec 代码。
Netty 提供了一组构建在其核心模块之上的 codec 实现,这些简单的或者高级的 codec 实现帮你解决了大部分在你进行协议处理开发过程会遇到的问题,无论这些协议是简单的还是复杂的,二进制的或是简单文本的。

SSL / TLS 支持

不同于传统阻塞式的 I/O 实现,在 NIO 模式下支持 SSL 功能是一个艰难的工作。你不能只是简单的包装一下流数据并进行加密或解密工作,你不得不借助于 javax.net.ssl.SSLEngine,SSLEngine 是一个有状态的实现,其复杂性不亚于 SSL 自身。你必须管理所有可能的状态,例如密码套件,密钥协商(或重新协商),证书交换以及认证等。此外,与通常期望情况相反的是 SSLEngine 甚至不是一个绝对的线程安全实现。
在 Netty 内部,SslHandler 封装了所有艰难的细节以及使用 SSLEngine 可 能带来的陷阱。你所做的仅是配置并将该 SslHandler 插入到你的 ChannelPipeline 中。同样 Netty 也允许你实现像 StartTlS 那样所拥有的高级特性,这很容易。

HTTP 实现

HTTP 无 疑是互联网上最受欢迎的协议,并且已经有了一些例如 Servlet 容器这样的 HTTP 实现。因此,为什么 Netty 还要在其核心模块之上构建一套 HTTP 实现?
与现有的 HTTP 实现相比 Netty 的 HTTP 实现是相当与众不同的。在 HTTP 消息的低层交互过程中你将拥有绝对的控制力。这是因为 Netty 的 HTTP 实现只是一些 HTTP codec 和 HTTP 消息类的简单组合,这里不存在任何限制——例如那种被迫选择的线程模型。你可以随心所欲的编写那种可以完全按照你期望的工作方式工作的客户端或服务器端代码。这包括线程模型,连接生命期,快编码,以及所有 HTTP 协议允许你做的,所有的一切,你都将拥有绝对的控制力。
由于这种高度可定制化的特性,你可以开发一个非常高效的 HTTP 服务器,例如:

  • 要求持久化链接以及服务器端推送技术的聊天服务(如,Comet )
  • 需要保持链接直至整个文件下载完成的媒体流服务(如,2 小时长的电影)
  • 需要上传大文件并且没有内存压力的文件服务(如,上传 1GB 文件的请求)
  • 支持大规模混合客户端应用用于连接以万计的第三方异步 web 服务。

WebSockets 实现

WebSockets 允许双向,全双工通信信道,在 TCP socket 中。它被设计为允许一个 Web 浏览器和 Web 服务器之间通过数据流交互。
WebSocket 协议已经被 IETF 列为 RFC 6455 规范。
Netty 已经实现了 WebSocket 和一些老版本的规范:http://netty.io/4.0/api/io/netty/handler/codec/http/websocketx/package-frame.html

Google Protocol Buffer 整合

Google Protocol Buffers 是快速实现一个高效的二进制协议的理想方案。通过使用 ProtobufEncoderProtobufDecoder,你可以把 Google Protocol Buffers 编译器 (protoc) 生成的消息类放入到 Netty 的 codec 实现中。请参考“LocalTime”实例,这个例子也同时显示出开发一个由简单协议定义 的客户及服务端是多么的容易。

性能优化

FastThreadLocal

重写了JDK的ThreadLocal,但是速度更快

Recycle

对象池

单机百万连接

Netty应用级别性能优化

QA

如何使用 Netty

Netty 是 Java 中的一个 NIO 框架:

  1. 易用的 API;
  2. NIO 模型相对 BIO 更高效。
  3. 解决了 Java 原生 NIO 接口存在的一些问题。
    包括粘包半包问题、心跳检测等问题。

ServerBootstrap - 默认情况下Netty服务端会起多个线程?又是什么时候启动这些线程的?

Netty中线程主要用于执行EventLoop的for循环任务,当ServerBootstrap
默认情况下创建2倍CPU核心线程数的线程。
io.netty.channel.MultithreadEventLoopGroup#MultithreadEventLoopGroup(int, java.util.concurrent.Executor, java.lang.Object...)
可以看到最终创建了个线程池ThreadPerTaskExecutor
io.netty.util.concurrent.MultithreadEventExecutorGroup#MultithreadEventExecutorGroup(int, java.util.concurrent.Executor, io.netty.util.concurrent.EventExecutorChooserFactory, java.lang.Object...)

ServerBootstrap - Netty是如何解决JDK的空轮询Bug的?

NioEventLoop

ServerBootstrap - Netty是如何保证异步串行无锁化的?

执行需要保证并发安全的操作时先判断是否是刚开始创建的线程,如果不是则放入一个单线程的线程池中执行。
线程创建位置:SingleThreadEventExecutor的构造方法
判断位置:io.netty.util.concurrent.AbstractEventExecutor#inEventLoop

NioEventLoop - Netty如何检测新连接的接入?

初始化ServerBootstrap时

NioEventLoop - 新连接怎样被注册NioEventLoop线程?

调用bind时会启动一个NioEventLoop线程,用于监听连接请求。

pipeline - Netty如何判断ChannelHandler类型?

ChannelHandler分为Inbound类型和Outbound类型,在Netty中将ChannelHandler添加到Pipeline时会判断这个ChannelHandler的类型,然后设置到一个bool类型的成员变量里,在传播时使用。
io.netty.channel.DefaultChannelHandlerContext#isInbound
io.netty.channel.DefaultChannelHandlerContext#isOutbound

pipeline - 对ChannelHandler的添加会遵循什么样的顺序?

根据Pipeline的传播逻辑可以看出,Inbound类型的ChannelHandler按添加顺序传播,而Outbound类型的ChannelHandler是按逆顺序传播的。

pipeline - 用户手动触发事件传播,不同的触发方式有什么区别?

如果是在Pipeline中间的某个ChannelHandler中调用了read,则就是从这个节点开始往后传播,如果是write,就是从这个节点开始往前传播。

ByteBuf - 内存的类别有哪些?

ByteBuf - 如何减少多线程之间内存分配的竞争?

ByteBuf - 不同大小的内存是如何进行分配的?

ByteBuf - 粘包半包问题是什么

解码器抽象的解码过程?

Netty里面有哪些拆箱即用的解码器?

如何把对象变成字节流,并最终写到socket底层?

如何使用Netty实现长短连接?

长连接是为了复用连接资源,长连接下,多个请求可以使用同一个连接传输数据包。

如何使用Netty实现长短轮询?

长轮询的特点是请求发到服务器上时若没有资源(比如库存),请求会被挂起,直到资源充足后才返回。

参考

  1. Netty 4.x 用户指南
  2. User guide for 4.x(上面这个文档的英文原版)
  3. github - netty / netty
  4. Netty Source Xref (4.0.56.Final)(同上为源码)
0%