系列文章目录
【RocketMQ 源码学习】00 - 序章 RokcetMQ 入门
【RocketMQ 源码学习】01 - RocketMQ 项目结构分析
gitee: https://gitee.com/apache/rocketmq/tree/4.9.x/docs/cn
github: https://github.com/apache/rocketmq/tree/4.9.x/docs/cn
官网: https://rocketmq.apache.org/zh/docs/4.x/
基于分支 4.9.x
,该分支目前还处于维护状态,部分代码可能有调整
一、启动脚本分析
本节只分析 shell 脚本 , 脚本位于 distribution 模块下的 bin/mqnamesrv
# -z 命令的含义后面字符串为空时返回true。意味着环境变量 ROCKETMQ_HOME 未设置时进入下面条件
if [ -z "$ROCKETMQ_HOME" ] ; then
## shell中 $0 代表脚本本身的名字,接下来的代码是处理 $0 可能是一个 link 的情况
PRG="$0"
# -h 表示判断所指是否是一个 link,如果是,则如下所示一直定位到实际地址
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG="`dirname "$PRG"`/$link"
fi
done
# 记录当前目录,用于之后返回
saveddir=`pwd`
# 即 ROCKETMQ_HOME 为 mqnamesrv 文件所在文件目录(dirname "$PRG")的上一级 (/..)
ROCKETMQ_HOME=`dirname "$PRG"`/..
# cd到前面所指的上一级目录,然后pwd完成来定位所在目录
ROCKETMQ_HOME=`cd "$ROCKETMQ_HOME" && pwd`
# 返回执行的目录
cd "$saveddir"
fi
export ROCKETMQ_HOME
# 执行脚本 runserver.sh, 并通过 $@ 传递本执行脚本命令时的参数
sh ${ROCKETMQ_HOME}/bin/runserver.sh org.apache.rocketmq.namesrv.NamesrvStartup $@
接下来进入 runserver.sh
脚本,先是设置 JAVA 环境变量
error_exit ()
{
echo "ERROR: $1 !!"
exit 1
}
# 没有 JAVA_HOME 的话会尝试从 $HOME/jdk/java、/usr/java 查找
[ ! -e "$JAVA_HOME/bin/java" ] && JAVA_HOME=$HOME/jdk/java
[ ! -e "$JAVA_HOME/bin/java" ] && JAVA_HOME=/usr/java
[ ! -e "$JAVA_HOME/bin/java" ] && error_exit "Please set the JAVA_HOME variable in your environment, We need java(x64)!"
export JAVA_HOME
export JAVA="$JAVA_HOME/bin/java"
# 这个目录也就是 xxx/bin/..,也就是定位到了 rocketmq_home
export BASE_DIR=$(dirname $0)/..
# 因为是用 java -cp 命令启动,这里指定了需要加载的jar包地址和配置文件地址,以及可能预先就有的CLASSPATH环境变量地址
export CLASSPATH=.:${BASE_DIR}/conf:${BASE_DIR}/lib/*:${CLASSPATH}
设置 gc 目录,其中 Darwin
实际就是指 mac 系统,它创建了一个内存盘 /Volumes/RAMDisk,但输出结果指到 /dev/null 也就是丢弃。其它系统上若存在 /dev/shm (也是一个内存盘) 则用它,否则就放到 ${BASE_DIR}
choose_gc_log_directory()
{
case "`uname`" in
Darwin)
if [ ! -d "/Volumes/RAMDisk" ]; then
# create ram disk on Darwin systems as gc-log directory
DEV=`hdiutil attach -nomount ram://$((2 * 1024 * DIR_SIZE_IN_MB))` > /dev/null
diskutil eraseVolume HFS+ RAMDisk ${DEV} > /dev/null
echo "Create RAMDisk /Volumes/RAMDisk for gc logging on Darwin OS."
fi
GC_LOG_DIR="/Volumes/RAMDisk"
;;
*)
# check if /dev/shm exists on other systems
if [ -d "/dev/shm" ]; then
GC_LOG_DIR="/dev/shm"
else
GC_LOG_DIR=${BASE_DIR}
fi
;;
esac
}
特意看了这样写的目的,查看提交记录以及 issues (https://github.com/apache/rocketmq/issues/1344
),发现原来的版本是固定输出到 /dev/shm/rmq_srv_gc.log,现在到网上查,都还有不少 mac 启动不了 rocketmq 的解决方案 (😀)
然后通过 java -version 命令识别版本号,这里因为 9 之前输出版本号是 1.8.x 这样的,所以看到第一句话注释值有:‘1’, ‘9’, ‘10’, ‘11’, …, ‘1’ 代表小于 jdk9。可以看到 9 之后主要是用 G1GC,之前版本是用 CMS
choose_gc_options()
{
# Example of JAVA_MAJOR_VERSION value : '1', '9', '10', '11', ...
# '1' means releases befor Java 9
JAVA_MAJOR_VERSION=$("$JAVA" -version 2>&1 | sed -r -n 's/.* version "([0-9]*).*$/\1/p')
if [ -z "$JAVA_MAJOR_VERSION" ] || [ "$JAVA_MAJOR_VERSION" -lt "9" ] ; then
JAVA_OPT="${JAVA_OPT} -server -Xms4g -Xmx4g -Xmn2g -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m"
JAVA_OPT="${JAVA_OPT} -XX:+UseConcMarkSweepGC -XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=70 -XX:+CMSParallelRemarkEnabled -XX:SoftRefLRUPolicyMSPerMB=0 -XX:+CMSClassUnloadingEnabled -XX:SurvivorRatio=8 -XX:-UseParNewGC"
JAVA_OPT="${JAVA_OPT} -verbose:gc -Xloggc:${GC_LOG_DIR}/rmq_srv_gc_%p_%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps"
JAVA_OPT="${JAVA_OPT} -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=30m"
else
JAVA_OPT="${JAVA_OPT} -server -Xms4g -Xmx4g -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m"
JAVA_OPT="${JAVA_OPT} -XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:G1ReservePercent=25 -XX:InitiatingHeapOccupancyPercent=30 -XX:SoftRefLRUPolicyMSPerMB=0"
JAVA_OPT="${JAVA_OPT} -Xlog:gc*:file=${GC_LOG_DIR}/rmq_srv_gc_%p_%t.log:time,tags:filecount=5,filesize=30M"
fi
}
接下来就是 执行 java -cp 命令启动程序了
choose_gc_log_directory
choose_gc_options
JAVA_OPT="${JAVA_OPT} -XX:-OmitStackTraceInFastThrow"
JAVA_OPT="${JAVA_OPT} -XX:-UseLargePages"
JAVA_OPT="${JAVA_OPT} ${JAVA_OPT_EXT}"
JAVA_OPT="${JAVA_OPT} -cp ${CLASSPATH}"
$JAVA ${JAVA_OPT} $@
二、NamesrvController 创建
根据启动脚本,我们知道启动入口函数 org.apache.rocketmq.namesrv.NamesrvStartup
跟进 main 方法,第一个执行的方法是 createNamesrvController
2.1 版本号(不重要)
第一行代码即为设置 rocketmq 的版本号
System.setProperty(RemotingCommand.REMOTING_VERSION_KEY, Integer.toString(MQVersion.CURRENT_VERSION));
跟踪常量 RemotingCommand.REMOTING_VERSION_KEY
,它主要会在 RemotingCommand#setCmdVersion
组装协议内容时设置版本号
private static void setCmdVersion(RemotingCommand cmd) {
if (configVersion >= 0) {
cmd.setVersion(configVersion);
} else {
String v = System.getProperty(REMOTING_VERSION_KEY);
if (v != null) {
int value = Integer.parseInt(v);
cmd.setVersion(value);
configVersion = value;
}
}
}
跟踪 version 字段,发现有几处地方的代码,会根据版本号来执行不同的逻辑
Version brokerVersion = MQVersion.value2Version(request.getVersion());
if (brokerVersion.ordinal() >= MQVersion.Version.V3_0_11.ordinal()) {
return this.registerBrokerWithFilterServer(ctx, request);
} else {
return this.registerBroker(ctx, request);
}
版本号枚举类 org.apache.rocketmq.common.MQVersion.Version
,发现内部已经预定义了非常多的版本了,版本号的比较就只用根据枚举的 ordinal 比较即可。
这里有个小彩蛋,最末尾的 MQVersion.Version#HIGHER_VERSION
枚举,有个可爱(😊摸鱼?) 的 assert,你们确定版本会码到 Integer.max = 2147483647
(21亿) 这么多?
@Test
public void testValue2Version_HigherVersion() throws Exception {
assertThat(MQVersion.value2Version(Integer.MAX_VALUE)).isEqualTo(MQVersion.Version.HIGHER_VERSION);
}
2.2 命令行参数分析
上一篇文章中就有提到过,在 rocketmq-srvutil 模块中引入了 commons-cli
来辅助处理命令行,以下代码设置了解析 h n c p
四个参数成对象。
Options options = ServerUtil.buildCommandlineOptions(new Options());
commandLine = ServerUtil.parseCmdLine("mqnamesrv", args, buildCommandlineOptions(options), new PosixParser());
if (null == commandLine) {
System.exit(-1);
return null;
}
跟进 parseCmdLine
方法,它发现含有 h
命令时,就会打印全部的命令简介并退出程序
if (commandLine.hasOption('h')) {
hf.printHelp(appName, options, true);
System.exit(0);
}
手动用 -h 命令试验,输出以下内容
usage: mqnamesrv [-c <arg>] [-h] [-n <arg>] [-p]
-c,--configFile <arg> Name server config properties file
-h,--help Print help
-n,--namesrvAddr <arg> Name server address list, eg: '192.168.0.1:9876;192.168.0.2:9876'
-p,--printConfigItem Print all config items
Disconnected from the target VM, address: '127.0.0.1:58024', transport: 'socket'
回到主干代码继续跟进,在 new 实例化 NamesrvConfig
和 NettyServerConfig
设置监听端口号 9876 后,就开始解析 c
命令
if (commandLine.hasOption('c')) {
String file = commandLine.getOptionValue('c');
if (file != null) {
InputStream in = new BufferedInputStream(new FileInputStream(file));
properties = new Properties();
properties.load(in);
MixAll.properties2Object(properties, namesrvConfig);
MixAll.properties2Object(properties, nettyServerConfig);
namesrvConfig.setConfigStorePath(file);
System.out.printf("load config properties file OK, %s%n", file);
in.close();
}
}
可以看到通过 -c 指定一个 properties 文件地址,读取后,会用配置的值覆盖 namesrvConfig、nettyServerConfig
MixAll#properties2Object
具体实现逻辑是,解析对象的 set
方法,将 properties 文件中同名的属性调用 set 去覆盖。
紧接着解析 -p
(printConfigItem) 命令,它用于打印两个 config 对象的参数,然后退出程序,可用于调试当前参数配置信息
if (commandLine.hasOption('p')) {
InternalLogger console = InternalLoggerFactory.getLogger(LoggerName.NAMESRV_CONSOLE_NAME);
MixAll.printObjectProperties(console, namesrvConfig);
MixAll.printObjectProperties(console, nettyServerConfig);
System.exit(0);
}
接下来有行代码让人疑惑,看作用本来是解析 option ,将和 namesrvConfig 同名的 option 的值设置给 namesrvConfig
MixAll.properties2Object(ServerUtil.commandLine2Properties(commandLine), namesrvConfig);
public static Properties commandLine2Properties(final CommandLine commandLine) {
Properties properties = new Properties();
Option[] opts = commandLine.getOptions();
if (opts != null) {
for (Option opt : opts) {
String name = opt.getLongOpt();
String value = commandLine.getOptionValue(name);
if (value != null) {
properties.setProperty(name, value);
}
}
}
return properties;
}
但是之前的 ServerUtil.parseCmdLine
里已经约束住了只能解析上述提到的 4 个参数,这里应该是无效代码。
或许笔者技术不精,望大佬指正。
接下来校验 ROCKETMQ_HOME 环境变量
if (null == namesrvConfig.getRocketmqHome()) {
System.out.printf("Please set the %s variable in your environment to match the location of the RocketMQ installation%n", MixAll.ROCKETMQ_HOME_ENV);
System.exit(-2);
}
// 优先读取系统参数 rocketmq.home.dir,然后再取环境变量 ROCKETMQ_HOME
private String rocketmqHome = System.getProperty(MixAll.ROCKETMQ_HOME_PROPERTY, System.getenv(MixAll.ROCKETMQ_HOME_ENV));
2.3 初始化日志框架
以下是 slf4j - logback 的初始化代码。略过不分析
LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
JoranConfigurator configurator = new JoranConfigurator();
configurator.setContext(lc);
lc.reset();
可以看到,这里设置了加载的logback配置文件名,已经本初始化类的 log 是 RocketmqNamesrv
。
configurator.doConfigure(namesrvConfig.getRocketmqHome() + "/conf/logback_namesrv.xml");
log = InternalLoggerFactory.getLogger(LoggerName.NAMESRV_LOGGER_NAME);
接下来调用了2次 MixAll.printObjectProperties
通过 log 打印 config 对象的参数
2.4 创建 NamesrvController 对象
final NamesrvController controller = new NamesrvController(namesrvConfig, nettyServerConfig);
跟进代码,可以发现初始化了如 KVConfigManager 等默认的成员,这个之后分析。
public NamesrvController(NamesrvConfig namesrvConfig, NettyServerConfig nettyServerConfig) {
this.namesrvConfig = namesrvConfig;
this.nettyServerConfig = nettyServerConfig;
this.kvConfigManager = new KVConfigManager(this);
this.routeInfoManager = new RouteInfoManager();
this.brokerHousekeepingService = new BrokerHousekeepingService(this);
this.configuration = new Configuration(
log,
this.namesrvConfig, this.nettyServerConfig
);
this.configuration.setStorePathFromConfig(this.namesrvConfig, "configStorePath");
}
先来分析 Configuration
,初始化时传入了 log 、namesrvConfig、nettyServerConfig。把两个 config 对象放入 configObjectList 中,并且把配置全部放入 allConfigs 这个内部 Properites 对象中,同名则覆盖。
然后设置了 storePathFromConfig = true,storePathObject = namesrvConfig, storePathField = configStorePath
笔者没有看懂这种脱了裤子放屁的操作,个人很反感这种伪扩展,写一堆复杂且没有卵用的代码?
接下来的操作也很迷惑,注释翻译:记住所有配置以防止丢弃??
实际上这里只是把之前 -c 指定的 properties 内容覆盖 Configuration 对象里的 allConfig,然而之前的操作中已经将值赋给了两个 config 对象,这里应该是做了无用功,如有疏漏麻烦各位大佬指出。
// remember all configs to prevent discard
controller.getConfiguration().registerConfig(properties);
三、NamesrvController 启动
start 方法主体流程很简单:调用 NamesrvController 初始化、校验结果、注册关闭勾子、正式启动
public static NamesrvController start(final NamesrvController controller) throws Exception {
//
boolean initResult = controller.initialize();
if (!initResult) {
controller.shutdown();
System.exit(-3);
}
Runtime.getRuntime().addShutdownHook(new ShutdownHookThread(log, (Callable<Void>) () -> {
controller.shutdown();
return null;
}));
controller.start();
return controller;
}
跟进 initialize 方法,接着分析每行代码
3.1 kvConfigManager load
public void load() {
String content = null;
try {
content = MixAll.file2String(this.namesrvController.getNamesrvConfig().getKvConfigPath());
} catch (IOException e) {
log.warn("Load KV config table exception", e);
}
if (content != null) {
KVConfigSerializeWrapper kvConfigSerializeWrapper =
KVConfigSerializeWrapper.fromJson(content, KVConfigSerializeWrapper.class);
if (null != kvConfigSerializeWrapper) {
this.configTable.putAll(kvConfigSerializeWrapper.getConfigTable());
log.info("load KV config table OK");
}
}
}
看代码得知是先读取一个指定位置的文件,在把文件内容json解析成 KVConfigSerializeWrapper,放入 KVConfigManager 内的 configTable。
先看看读取的文件名在 ${user.home}/namesrv/kvConfig.json
,第一次启动是无的
private String kvConfigPath = System.getProperty("user.home") + File.separator + "namesrv" + File.separator + "kvConfig.json";
接着进入到 KVConfigSerializeWrapper 中查看json格式,内部只有一个具有 get set 的成员 configTable
private HashMap<String/* Namespace */, HashMap<String/* Key */, String/* Value */>> configTable;
对于 KVConfigManager 的其它方法,无非就是就是对 configTable 中的内容做更新删除和持久化到文件,这里有个有趣的点,它使用读写锁,将用 write 锁的 update、delete 操作,执行完需要同步写文件的操作 persist()
采用 read 锁。一定程度上减少了更新操作锁住的时间。
但实际上可以有性能更好的方式,目前的代码存在锁粒度太大,读写锁并发控制不严谨等问题,但确实没必要抓住这个不是性能瓶颈的老代码不放了
3.2 初始化 NettyRemotingServer
关于 Remoting 模块章节的详细解析将放到后续分析,这里只看初始化涉及的部分,重点关注传入的 NettyServerConfig 是可以控制哪些配置。
第一个地方是进来的第一行代码,配置了 2 个属性用于初始化 Semaphore 的 permits,分别为 256 和 64
super(nettyServerConfig.getServerOnewaySemaphoreValue(), nettyServerConfig.getServerAsyncSemaphoreValue());
public NettyRemotingAbstract(final int permitsOneway, final int permitsAsync) {
this.semaphoreOneway = new Semaphore(permitsOneway, true);
this.semaphoreAsync = new Semaphore(permitsAsync, true);
}
接着跟踪2个 semaphore 的用处,分别是在 invokeOnewayImpl
和 invokeAsyncImpl
执行时用于控制最大同时执行数量
接下来能控制的参数是 ServerCallbackExecutorThreads,它负责控制 NettyServerPublicExecutor
线程池的数量,默认为4。
此线程池的作用后续分析
int publicThreadNums = nettyServerConfig.getServerCallbackExecutorThreads();
if (publicThreadNums <= 0) {
publicThreadNums = 4;
}
this.publicExecutor = Executors.newFixedThreadPool(publicThreadNums, new ThreadFactory() {
private AtomicInteger threadIndex = new AtomicInteger(0);
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "NettyServerPublicExecutor_" + this.threadIndex.incrementAndGet());
}
});
同样的,nettyServerConfig.getServerSelectorThreads() ,可以控制 Selector 线程池的数量,默认为 3。也就是对应 netty 的 主从reactor多线程模型的 从reactor线程
if (useEpoll()) {
// ***
this.eventLoopGroupSelector = new EpollEventLoopGroup(nettyServerConfig.getServerSelectorThreads(), new ThreadFactory() {
// ***
});
} else {
// ***
this.eventLoopGroupSelector = new NioEventLoopGroup(nettyServerConfig.getServerSelectorThreads(), new ThreadFactory() {
// ***
});
}
然后执行 loadSslContext()
,略过后续分析。
3.3 初始化 remotingExecutor
初始化业务processor处理线程池,通过 serverWorkerThreads 设置,默认为8
this.remotingExecutor = Executors.newFixedThreadPool(nettyServerConfig.getServerWorkerThreads(), new ThreadFactoryImpl("RemotingExecutorThread_"));
如果看到这里,对这些 Executors 已经记不清的小伙伴,可以回顾doc文档中 设计的2.4 Reactor多线程设计章节:
3.4 初始化 NettyRequestProcessor
private void registerProcessor() {
if (namesrvConfig.isClusterTest()) {
this.remotingServer.registerDefaultProcessor(new ClusterTestRequestProcessor(this, namesrvConfig.getProductEnvName()),
this.remotingExecutor);
} else {
this.remotingServer.registerDefaultProcessor(new DefaultRequestProcessor(this), this.remotingExecutor);
}
}
默认情况是使用 DefaultRequestProcessor
,而 ClusterTestRequestProcessor
文档中暂无介绍,翻看代码继承 DefaultRequestProcessor,重写了 getRouteInfoByTopic
方法
不同点在于如果未获取到 topicRouteData 则会调用 adminExt.examineTopicRouteInfo(requestHeader.getTopic())。但是看代码,ClusterTestRequestProcessor 缺少了右图(DefaultRequestProcessor) 相关新特性的支持,估计已被官方遗忘了
进入 DefaultRequestProcessor 可以看到,主要封装了 namesrv 处理各种请求的逻辑,后续章节分析
3.5 设置定时任务
定时任务 routeInfoManager
的扫码不可用Broker任务(10秒一次) 和 kvConfigManager
的打印当前配置(10分钟一次)
this.scheduledExecutorService.scheduleAtFixedRate(NamesrvController.this.routeInfoManager::scanNotActiveBroker, 5, 10, TimeUnit.SECONDS);
this.scheduledExecutorService.scheduleAtFixedRate(NamesrvController.this.kvConfigManager::printAllPeriodically, 1, 10, TimeUnit.MINUTES);
关于 RouteInfoManager
将在后续章节分析
3.6 初始化 tls 文件监听
在 TLS 非禁用模式下(默认都不是禁用的),开启 FileWatchService 监听 TlsSystemConfig 指定的 3 个环境变量设置的路径下文件
if (TlsSystemConfig.tlsMode != TlsMode.DISABLED) {
// Register a listener to reload SslContext
try {
fileWatchService = new FileWatchService(
new String[] {
TlsSystemConfig.tlsServerCertPath,
TlsSystemConfig.tlsServerKeyPath,
TlsSystemConfig.tlsServerTrustCertPath
},
new FileWatchService.Listener() {
boolean certChanged, keyChanged = false;
@Override
public void onChanged(String path) {
if (path.equals(TlsSystemConfig.tlsServerTrustCertPath)) {
log.info("The trust certificate changed, reload the ssl context");
reloadServerSslContext();
}
if (path.equals(TlsSystemConfig.tlsServerCertPath)) {
certChanged = true;
}
if (path.equals(TlsSystemConfig.tlsServerKeyPath)) {
keyChanged = true;
}
if (certChanged && keyChanged) {
log.info("The certificate and private key changed, reload the ssl context");
certChanged = keyChanged = false;
reloadServerSslContext();
}
}
private void reloadServerSslContext() {
((NettyRemotingServer) remotingServer).loadSslContext();
}
});
} catch (Exception e) {
log.warn("FileWatchService created error, can't load the certificate dynamically");
}
}
ssl 后续文章分析
四、运行 NamesrvController
public void start() throws Exception {
this.remotingServer.start();
if (this.fileWatchService != null) {
this.fileWatchService.start();
}
}
就是启动 remotingServer 和 fileWatchService,先来简单分析 remotingServer
4.1 初始化 work 线程
可以再回顾 3.3 节下方附上的图,namesrv 中此线程默认 8 个,用于 SSL验证、编解码、空闲检查、网络连接管理 等工作,可以在后续的 ch.pipeliine().addLast
中看到将此线程池传入,绑定各种 handler
this.defaultEventExecutorGroup = new DefaultEventExecutorGroup(
nettyServerConfig.getServerWorkerThreads(),
new ThreadFactory() {
private AtomicInteger threadIndex = new AtomicInteger(0);
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "NettyServerCodecThread_" + this.threadIndex.incrementAndGet());
}
});
4.2 初始化可共享的 netty handler 组件
也就是标注 @ChannelHandler.Sharable
的 handler
private void prepareSharableHandlers() {
handshakeHandler = new HandshakeHandler(TlsSystemConfig.tlsMode);
encoder = new NettyEncoder();
connectionManageHandler = new NettyConnectManageHandler();
serverHandler = new NettyServerHandler();
}
HandshakeHandler
: 从命名来看,它叫 握手Handler ,故名思意,它在第一次的 channelRead0 触发后就会从 pipeline 移除自身。它的作用是识别通信是否是启用了 ssl ,如果是,则会添加 ssl 处理相关的 SslHandler 和 FileRegionEncoder。
NettyEncoder
: 负责编码 RemotingCommand ,可发现编码后协议的格式也对应文档中的描述
NettyConnectManageHandler
: 作用1:打印日志;作用2:处理 IdleStateEvent 事件(后续会将到);作用3:发送内部定义的 NettyEvent 事件,供 ChannelEventListener 对应的 2 个在 namesrv 或 broker 中的实现类使用如管理连接维护内部状态表
NettyServerHandler
:处理 RemotingCommand 业务 请求/响应。作为服务端处理请求时,实际上请求还是交给了业务处理线程池,跑这个handler的work线程仅仅处理从 processorTable 中找到对应命令的处理器和线程池(broker中会注册多个,而 namesrv 没有注册,所以使用了默认的 DefaultRequestProcessor, 前面的代码中 registerProcessor 里注册)
后续的分析 netty网络 部分章节将详细分析
4.3 netty 相关的初始化
先来看整体代码:
ServerBootstrap childHandler =
this.serverBootstrap.group(this.eventLoopGroupBoss, this.eventLoopGroupSelector)
.channel(useEpoll() ? EpollServerSocketChannel.class : NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, nettyServerConfig.getServerSocketBacklog())
.option(ChannelOption.SO_REUSEADDR, true)
.option(ChannelOption.SO_KEEPALIVE, false)
.childOption(ChannelOption.TCP_NODELAY, true)
.localAddress(new InetSocketAddress(this.nettyServerConfig.getListenPort()))
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline()
.addLast(defaultEventExecutorGroup, HANDSHAKE_HANDLER_NAME, handshakeHandler)
.addLast(defaultEventExecutorGroup,
encoder,
new NettyDecoder(),
new IdleStateHandler(0, 0, nettyServerConfig.getServerChannelMaxIdleTimeSeconds()),
connectionManageHandler,
serverHandler
);
}
});
if (nettyServerConfig.getServerSocketSndBufSize() > 0) {
log.info("server set SO_SNDBUF to {}", nettyServerConfig.getServerSocketSndBufSize());
childHandler.childOption(ChannelOption.SO_SNDBUF, nettyServerConfig.getServerSocketSndBufSize());
}
if (nettyServerConfig.getServerSocketRcvBufSize() > 0) {
log.info("server set SO_RCVBUF to {}", nettyServerConfig.getServerSocketRcvBufSize());
childHandler.childOption(ChannelOption.SO_RCVBUF, nettyServerConfig.getServerSocketRcvBufSize());
}
if (nettyServerConfig.getWriteBufferLowWaterMark() > 0 && nettyServerConfig.getWriteBufferHighWaterMark() > 0) {
log.info("server set netty WRITE_BUFFER_WATER_MARK to {},{}",
nettyServerConfig.getWriteBufferLowWaterMark(), nettyServerConfig.getWriteBufferHighWaterMark());
childHandler.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(
nettyServerConfig.getWriteBufferLowWaterMark(), nettyServerConfig.getWriteBufferHighWaterMark()));
}
if (nettyServerConfig.isServerPooledByteBufAllocatorEnable()) {
childHandler.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
}
对于熟悉网络编程或 netty 的同学很好理解上述配置含义,下面简述作用,注意看是对 netty 的哪个 主从reactor 设置的:
- SO_BACKLOG:影响 TCP 完成3次握手时,内核等待进程调用 accept 连接的 accept队列大小,linux 下一般还受到服务器参数 somaxconn 影响取2者最小值。rocketmq 默认 1024。
(图片来源于小林coding网络部分 https://xiaolincoding.com/network/3_tcp/tcp_interview.html)
- SO_REUSEADDR:主要处理这种场景:服务端异常退出而主动断开TCP连接 (主动发起 FIN)时,一般内核要经过 2MSL(linux 下一个 MSL 默认为30秒,2个也就是 1分钟) 才会从 TIME_WAIT 变成关闭,此时如果服务器有自动重启脚本执行重启,往往会报异常端口占用异常。开启此参数,将可以重复绑定使用 TIME_WAIT 的端口
-
SO_KEEPALIVE : rocketmq 中关闭了 tcp keepalive。因为 tcp keepalive 主要场景是用于清理长期不通信的连接,它在 linux 下默认处理的是 2 个小时以上的连接,所以并不适用于此 中间件服务器的场景。 rocketmq 的心跳机制由自身应用实现
-
TCP_NODELAY:true 代表禁用 Nagle 算法。Nagle 算法通过
1. 没有已发送未确认报文时,立刻发送数据。 2. 存在未确认报文时,直到「没有已发送未确认报文」或「数据长度达到 MSS 大小」时,再发送数据
的策略,减少网络报文的传输,但对于实时性场景,显然是需要关闭的
(下图来自 小林coding 中描述的客户端发送报文时开启关闭 Nagel 算法的简单示例)
-
SO_SNDBUF、SO_RCVBUF:默认0不生效,属于可选设置;含义是 TCP 发送缓冲区和接收缓冲区的大小,一般不用调整,除非想达到最大的网络吞吐量可以尝试调整。
-
WRITE_BUFFER_WATER_MARK:可选的一个属于 netty 的配置,即水位线 WriteBufferWaterMark。netty 默认是 32K ~ 64K, 当缓存超过了高水位,Channel 调用 isWritable() 方法会返回 false,但实际不影响调用 write 方法写入,而是起一个提醒作用。 rocketmq 的 broker 在选择 channel 时,就使用到了
channel.isActive() && channel.isWritable()
来辅助选择。在 netty 的中,改变可读标志时,还会调用 pipiline 从 head 发出fireChannelWritabilityChanged
的入站事件,像 http2 的相关 handler,会在收到此事件时触发 flush 操作 -
ALLOCATOR:默认true,即使用
PooledByteBufAllocator.DEFAULT
,优先用 直接内存+池化管理 的 ByteBuf
然后关注点来到 pipeline 添加的 channel 处理器上, 前面已经分析过共享的(@ChannelHandler.Sharable) handeler 了
-
NettyDecoder: 解码器,基于
LengthFieldBasedFrameDecoder
, 理解里面核心的 4 个参数可以看类顶部的注释例子。NettyDecoder 默认是首部开始的 4 个 bytes (1个int)表示报文长度,组成的报文将提出首部的长度内容(结合 NettyEncoder 部分阅读)。 -
IdleStateHandler: netty 提供的一个便捷检测连接的组件,rocketmq中,默认设置 120 秒没有发生 read 或 write,则触发 IdleStateEvent,在上文提到的 NettyConnectManageHandler 方法 userEventTriggered 处理事件,及发送一个rocketmq内部维护的 NettyEvent 到 eventQueue,然后有个线程不断去轮询取消息触发对应逻辑。在 namesrv 中最终会调用 RouteInfoManager.onChannelDestroy()
然后正式启动运行 netty
try {
ChannelFuture sync = this.serverBootstrap.bind().sync();
InetSocketAddress addr = (InetSocketAddress) sync.channel().localAddress();
this.port = addr.getPort();
} catch (InterruptedException e1) {
throw new RuntimeException("this.serverBootstrap.bind().sync() InterruptedException", e1);
}
4.4 其它启动工作
// 运行netty事件处理线程,不断 poll eventQueue,拉取事件来执行
if (this.channelEventListener != null) {
this.nettyEventExecutor.start();
}
// 开启定时扫描过期或弃用的请求
this.timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
try {
NettyRemotingServer.this.scanResponseTable();
} catch (Throwable e) {
log.error("scanResponseTable exception", e);
}
}
}, 1000 * 3, 1000);
五、总结
本章节主要是了解 namesrv 启动过程,并了解哪些参数是可以调节的。
同时,分析 NamesrvController 启动过程中,涉及到了 NettyRemotingServer 启动部分,这块也是在 broker 中启动依赖的,需要理解它是通过什么扩展出在 namesrv 和 broker 中不同的行为:
- rocketmq 中通过构建 NettyRemotingServer 时传入的
ChannelEventListener
作为扩展点来实现并通过内部的 event 方式去解耦 IO 线程来处理CONNECT, CLOSE, IDLE, EXCEPTION
的事件。 - 通过 NettyRemotionServer 的 registerDefaultProcessor 或 registerProcessor 注册对应 RemotingCommand 的 code (见 RequestCode) 的处理方法,以及执行 executor 组成 pair,放入 processorTable 中(k: code ;v:pair )。其中 namesrv 只注册了个 DefaultRequestProcessor
其次,分析过程中,需要重点对照文档中 设计 design - 2. 通信机制
章节,重点理解 rockermq 的 reactor 线程设计
以及 publicExecutor
(用作执行 ResponseFuture 的 callback 线程池 或者 NettyRequestProcessor 的默认线程池(namesrv) ),NettyEventExecutor
(一个不断轮询 eventQueue 事件并调用 ChannelEventListener 执行的线程)