本文是学习 “RoecketMQ技术内幕” 时的学习笔记,中间会贴上部分dubug源码的调试结果。
Broker消息服务器在启动时,向所有NameServer注册,Producer在发送消息之前从NameServer获取broker服务器地址列表,然后根据负载均衡算法从列表中选择一台消息服务器进行消息发送。NameServer和每台broker服务器保持长连接,每隔30s检测broker是否存活,如果监测到宕机则从路由注册表中将其移除。但是路由变动不会立刻通生产者,为了降低NameServer实现的复杂性,在消息发送端提供容错机制来保证消息发送的高可用。
NameServer彼此之间互不通信,NameServer服务器之间在某一时候的数据并不会完全相同。
final NamesrvConfig namesrvConfig = new NamesrvConfig();
final NettyServerConfig nettyServerConfig = new NettyServerConfig();
nettyServerConfig.setListenPort(9876);
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();
}
}
启动的时候,创建 NamesrvConfig 和 NettyServerConfig ,如果命令行有 -c 参数(加载配置文件),则读入参数,并把Netty 服务器在9876端口启动。
NamesrvConfig:源码
// rocketmq主路径
private String rocketmqHome = System.getProperty(MixAll.ROCKETMQ_HOME_PROPERTY, System.getenv(MixAll.ROCKETMQ_HOME_ENV));
// 存储KV配置属性的持久化路径
private String kvConfigPath = System.getProperty("user.home") + File.separator + "namesrv" + File.separator + "kvConfig.json";
// 默认config文件路径,不生效
private String configStorePath = System.getProperty("user.home") + File.separator + "namesrv" + File.separator + "namesrv.properties";
NettyServerConfig:配置
RocketmqNamesrvConsole - listenPort=9876
// serverWorkerThreads: Netty业务线程池线程个数
RocketmqNamesrvConsole - serverWorkerThreads=8
// Netty public任务线程池线程个数, Netty 网络设计,根据业务类型会创建不同的线程池,比如处理消息发送、消息消费、心跳检测等。如果该业务类型(RequestCode)未注册线程池, 则由 public线程池执行。
RocketmqNamesrvConsole - serverCallbackExecutorThreads=0
// IO线程池线程个数,主要是NameServer、Broker端解析请求、返回相应的线程个数,这类线程主要是处理网络请求的,解析请求包,然后转发到各个业务线程池完成具体的业务操作,然后将结果再返回调用方 。
RocketmqNamesrvConsole - serverSelectorThreads=3
// serverOnewaySemaphoreValue: send oneway 消息请求井发度(Broker端参数) 。
RocketmqNamesrvConsole - serverOnewaySemaphoreValue=256
// serverAsyncSemaphoreValue: 异步消息发送最大并发度( Broker 端参数) 。
RocketmqNamesrvConsole - serverAsyncSemaphoreValue=64
// serverChannelMaxldleTimeSeconds:网络连接最大空闲时间,默认120s。如果连接空闲时间超过该参数设置的值,连接将被关闭 。
RocketmqNamesrvConsole - serverChannelMaxIdleTimeSeconds=120
// serverSocketSndBufSize:网络 socket发送缓存区大小, 默认 64k。
RocketmqNamesrvConsole - serverSocketSndBufSize=65535
// serverSocketRcvBufSize:网络 socket接收缓存区大小 ,默认 64k。
RocketmqNamesrvConsole - serverSocketRcvBufSize=65535
// serverPooledByteBufAllocatorEnable: ByteBuffer是否开启缓存,建议开启 。
RocketmqNamesrvConsole - serverPooledByteBufAllocatorEnable=true
// useEpollNativeSelector: 是否启用EpollIO模型,Linux环境建议开启。
RocketmqNamesrvConsole - useEpollNativeSelector=false
public boolean initialize() {
this.kvConfigManager.load();
this.remotingServer = new NettyRemotingServer(this.nettyServerConfig, this.brokerHousekeepingService);
this.remotingExecutor =
Executors.newFixedThreadPool(nettyServerConfig.getServerWorkerThreads(), new ThreadFactoryImpl("RemotingExecutorThread_"));
this.registerProcessor();
// 下面两个定时任务都称为心跳检测
// 每隔十秒扫描不活跃的 broker 然后移除
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
NamesrvController.this.routeInfoManager.scanNotActiveBroker();
}
}, 5, 10, TimeUnit.SECONDS);
// nameServer 每隔10分钟打印一次 KV 配置
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
NamesrvController.this.kvConfigManager.printAllPeriodically();
}
}, 1, 10, TimeUnit.MINUTES);
return true;
}
Runtime.getRuntime().addShutdownHook(new ShutdownHookThread(log, new Callable<Void>() {
@Override
public Void call() throws Exception {
controller.shutdown();
return null;
}
}));
controller.start();
如果代码中使用了线程池,一种优雅的停机方式就是注册一个JVM钩子函数,在JVM进程关闭之前,先将线程池关闭,即时释放资源。
NameServer路由实现类:org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager
// topicQueueTable: Topic消息队列路由信息,消息发送时根据路由表进行负载均衡 。
private final HashMap<String/* topic */, List<QueueData>> topicQueueTable;
// brokerAddrTable : Broker 基础信息,包含 brokerName所属集群名称 、 主备Broker地址。
private final HashMap<String/* brokerName */, BrokerData> brokerAddrTable;
// clusterAddrTable: Broker 集群信息,存储集群中所有 Broker 名称 。
private final HashMap<String/* clusterName */, Set<String/* brokerName */>> clusterAddrTable;
// brokerLiveTable: Broker 状态信息 。 NameServer 每次 收到心跳包时会 替换该信 息 。
private final HashMap<String/* brokerAddr */, BrokerLiveInfo> brokerLiveTable;
// filterServerTable : Broker上的 FilterServer列表,用于类模式消息过滤
private final HashMap<String/* brokerAddr */, List<String>/* Filter Server */> filterServerTable;
class BrokerLiveInfo {
private long lastUpdateTimestamp; // 上次broker心跳时间,超过120s没跟新则移除broker路由信息
private DataVersion dataVersion;
private Channel channel;
private String haServerAddr;
RMQ路由注册是通过Broker与NameServer的心跳功能实现的。Broker启动时向集群中所有的NameServer发送心跳语句,每隔30s向集群中所有NameServer发送心跳包,NameServer收到Broker心跳包时会更新 brokerLiveTable 缓存中 BrokerLiveInfo 的 lastUpdateTimestamp,然后NameServer每隔10s扫描 brokerLiveTable,如果连续120s没有收到心跳包,NameServer将移除该 Broker 的路由信息同时关闭 Socket 连接。
Broker发送心跳包
BrokerController #start
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
try {
BrokerController.this.registerBrokerAll(true, false, brokerConfig.isForceRegister());
} catch (Throwable e) {
log.error("registerBrokerAll Exception", e);
}
}
}, 1000 * 10, Math.max(10000, Math.min(brokerConfig.getRegisterNameServerPeriod(), 60000)), TimeUnit.MILLISECONDS);
BrokerOuterAPI #registerBrokerAll
if (nameServerAddressList != null && nameServerAddressList.size() > 0) {
for (final String namesrvAddr : nameServerAddressList) {
brokerOuterExecutor.execute(new Runnable() {
@Override
public void run() {
try {
RegisterBrokerResult result = registerBroker(namesrvAddr,oneway, timeoutMills,requestHeader,body);
if (result != null) {
registerBrokerResultList.add(result);
}
log.info("register broker[{}]to name server {} OK", brokerId, namesrvAddr);
} catch (Exception e) {
log.warn("registerBroker Exception, {}", namesrvAddr, e);
} finally {
countDownLatch.countDown();
}
}
});
}
try {
countDownLatch.await(timeoutMills, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
}
}
遍历NameServer列表,Broker消息服务器依次向NameServer发送心跳包
BrokerOuterAPI #registerBroker (网络发送代码)
private RegisterBrokerResult registerBroker(
final String namesrvAddr,
final boolean oneway,
final int timeoutMills,
final RegisterBrokerRequestHeader requestHeader,
final byte[] body
) throws Exception {
RemotingCommand request = RemotingCommand.createRequestCommand(RequestCode.REGISTER_BROKER, requestHeader);
request.setBody(body);
if (oneway) {
try {
this.remotingClient.invokeOneway(namesrvAddr, request, timeoutMills);
} catch (RemotingTooMuchRequestException e) { // Ignore }
return null;
}
RemotingCommand response = this.remotingClient.invokeSync(namesrvAddr, request, timeoutMills);
心跳包具体逻辑,首先封装请求包头(Header)
NameServer处理心跳包
RouteInfoManager #registerBroker clusterAddrTable
this.lock.writeLock().lockInterruptibly();
Set<String> brokerNames = this.clusterAddrTable.get(clusterName);
if (null == brokerNames) {
brokerNames = new HashSet<String>();
this.clusterAddrTable.put(clusterName, brokerNames);
}
brokerNames.add(brokerName);
路由 注册需要加写锁 ,防止并发修改 RoutelnfoManager 中的路由表 。首先判断 Broker 所属集群是否存在,如果不存在,则创建,然后将 broker 名加入到集群Broker 集合中。
P40
RocketMQ主要存储的文件包括Commitlog文件,ConsumeQueue文件,IndexFile文件。RMQ把所有主题的消息存储在同一个文件中,确保消息发送时顺序写文件。为了提高效率引入了ConsumeQueue消息队列文件,每个消息主题包含多个消息消费队列,每一个消息队列有一个消息文件。IndexFile索引文件,其主要设计理念就是为了加速消息的检索性能,根据消息的属性快速从Commitlog文件中检索消息。
消息存储实现类:org.apache.rocketmq.store.DefaultMessageStore,它是存储模块里面最重要的一个类,包含了很多对存储文件的操作API。
// 消息存储配置属性
private final MessageStoreConfig messageStoreConfig;
// CommitLog 文件的存储实现类
private final CommitLog commitLog;
// 消息队列存储缓存表,按消息主题分组
private final ConcurrentMap<String/* topic */, ConcurrentMap<Integer/* queueId */, ConsumeQueue>> consumeQueueTable;
// ConsumeQueue刷盘线程
private final FlushConsumeQueueService flushConsumeQueueService;
// 清除CommitLog文件服务
private final CleanCommitLogService cleanCommitLogService;
// 清除ConsumeQueue文件服务
private final CleanConsumeQueueService cleanConsumeQueueService;
// 索引文件实现类
private final IndexService indexService;
// MappedFile分配服务
private final AllocateMappedFileService allocateMappedFileService;
// CommitLog消息分发,根据CommitLog文件构建ConsumeQueue,IndexFile文件
private final ReputMessageService reputMessageService;
// 存储HA机制
private final HAService haService;
// 消息堆内存缓存
private final TransientStorePool transientStorePool;
// 消息拉取长轮询模式消息达到监听器
private final MessageArrivingListener messageArrivingListener;
// Broker属性配置类
private final BrokerConfig brokerConfig;
// 文件刷盘监测点
private StoreCheckpoint storeCheckpoint;
// CommitLog文件转发请求
private final LinkedList<CommitLogDispatcher> dispatcherList;
Step1:如果Broker停止工作或者为Slave,则拒绝消息写入。如果消息长度超过256个字符,消息属性长度超过65536个字符将拒绝改消息的写入。
Step2:如果消息的延迟级别大于0,将消息的原主题名称与原消息队列ID存入消息属性中,用延迟消息主题SCHEDULE_TOPIC、消息队列ID更新原先消息的主题与队列,这是并发消息消费重试关键的一步。
Step3:获取当前可以写入的Commitlog文件。每一个文件默认1G,一个文件写满后再创建另外一个,以该文件中第一个偏移量为文件名,偏移量小于20位用0补齐。第一个文件初始偏移量为0,第二个文件的偏移量是1073741824,代表该文件中的第一条消息的物理偏移量为1073741824,这样就能快速定位到消息。
Step4:在写入CommitLog之前,先申请putMessageLock,也就是将消息存储到CommitLog文件中是串行的。
Step5:设置消息的存储时间,如果mappedFile为空,表明 /commitlog 目录下不存在任何文件,说明本次消息是第一次消息发送,用偏移量0创建第一个 commit 文件。
messageExtBatch.setStoreTimestamp(beginLockTimestamp);
if (null == mappedFile || mappedFile.isFull()) {
mappedFile = this.mappedFileQueue.getLastMappedFile(0); // Mark: NewFile may be cause noise
}
if (null == mappedFile) {
log.error("Create mapped file1 error, topic: {} clientAddr: {}", messageExtBatch.getTopic(), messageExtBatch.getBornHostString());
beginTimeInLock = 0;
return new PutMessageResult(PutMessageStatus.CREATE_MAPEDFILE_FAILED, null);
}
Step6:将消息追加到 MappedFile 中。首先先获取 MappedFile 当前写指针,如果currentPos大于或等于文件大小则表明文件已写满,抛出UNKNOWN_ERROR。如果currentPos小于文件大小,通过 slice() 方法创建一个与MappedFile的贡献内存区,并设置position为当前指针。
int currentPos = this.wrotePosition.get();
if (currentPos < this.fileSize) {
ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
byteBuffer.position(currentPos);
AppendMessageResult result;
if (messageExt instanceof MessageExtBrokerInner) {
result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBrokerInner) messageExt);
} else if (messageExt instanceof MessageExtBatch) {
result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBatch) messageExt);
} else {
return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
}
this.wrotePosition.addAndGet(result.getWroteBytes());
this.storeTimestamp = result.getStoreTimestamp();
return result;
}
Step7:创建全局唯一消息ID,消息ID有16字节,消息ID前4字节是IP,中间4字节是端口号,最后8字节是消息偏移量。
long wroteOffset = fileFromOffset + byteBuffer.position();
this.resetByteBuffer(storeHostHolder, storeHostLength);
String msgId = MessageDecoder.createMessageId(this.msgIdMemory, msgInner.getStoreHostBytes(storeHostHolder), wroteOffset);
但是为了消息ID可读性,返回给应用程序的msgId为字符类型,可以通过UtilAll.byte2string方法将msgId字节数组转换成字符串,通过UtilAll.string2bytes方法将msgId字符串还原成16个字节的字节数组,从而根据提取消息偏移量,可以快速通过msgId找到消息内容。
Step8:获取该消息在消息队列的偏移量,CommitLog中保存了当前所有消息队列的当前待写入偏移量。
// Record ConsumeQueue information
keyBuilder.setLength(0);
keyBuilder.append(msgInner.getTopic());
keyBuilder.append('-');
keyBuilder.append(msgInner.getQueueId());
String key = keyBuilder.toString();
Long queueOffset = CommitLog.this.topicQueueTable.get(key);
if (null == queueOffset) {
queueOffset = 0L;
CommitLog.this.topicQueueTable.put(key, queueOffset);
}
Step9:根据消息体的长度、主题的长度、属性的长度结合消息存储格式计算消息的总长度。
protected static int calMsgLength(int sysFlag, int bodyLength, int topicLength, int propertiesLength) {
final int msgLen = 4 //TOTALSIZE:该消息条目总长度,4字节
+ 4 //MAGICCODE:魔数,4字节。固定值0xdaa320a7
+ 4 //BODYCRC:消息体crc校验码,4字节
+ 4 //QUEUEID:消息消息队列ID,4字节
+ 4 //FLAG:消息FLAG,RMQ不做处理,供应用程序使用,默认4字节
+ 8 //QUEUEOFFSET:消息在消息消费队列的偏移量,8字节
+ 8 //PHYSICALOFFSET:消息在CommitLog文件中偏移量,8字节
+ 4 //SYSFLAG:消息系统Flag,例如是否压缩,是否是事务消息等,4字节
+ 8 //BORNTIMESTAMP:消息生产者调用消息发送API的时间戳,8字节
+ bornhostLength //BORNHOST:发送者IP,端口,8字节
+ 8 //STORETIMESTAMP:消息存储时间戳,8字节
+ storehostAddressLength //STOREHOSTADDRESS:BrokerIP和端口,8字节
+ 4 //RECONSUMETIMES:消息重试次数,4字节
+ 8 //Prepared Transaction Offset:事务消息物理偏移量,8字节
+ 4 + (bodyLength > 0 ? bodyLength : 0) //BODY:消息体长度,4字节
+ 1 + topicLength //TOPIC:主题存储长度,1字节
+ 2 + (propertiesLength > 0 ? propertiesLength : 0) //propertiesLength:消息属性,长度为PropertiesLength中存储的值
+ 0;
return msgLen;
}
上述表明CommitLog条目是不定长的,每一个条目的长度存储在前4个字节中。
Step10:如果消息长度 + END_FILE_MIN_BLANK_LENGTH 大于 CommitLog文件的空闲空间,则返回AppendMessageStatus.END_OF_FILE,Broker会创建一个新的CommitLog文件来存储该消息。从这里可以看出,每个CommitLog文件最少会空闲8字节,高4字节存储当前文件剩余空间,低四字节存储魔数:CommitLog.BLANK_MAGIC_CODE。
final long beginTimeMills = CommitLog.this.defaultMessageStore.now();
// Write messages to the queue buffer
byteBuffer.put(this.msgStoreItemMemory.array(), 0, msgLen);
AppendMessageResult result = new AppendMessageResult(AppendMessageStatus.PUT_OK, wroteOffset, msgLen, msgId, msgInner.getStoreTimestamp(), queueOffset, CommitLog.this.defaultMessageStore.now() - beginTimeMills);
Step11:将消息内容存储到ByteBuffer中,然后创建AppendMessageResult。把消息存储在MappedFile对应的内存映射Buffer中,并没有刷写到磁盘。
// Return code
private AppendMessageStatus status;
// Where to start writing
private long wroteOffset;
// Write Bytes
private int wroteBytes;
// Message ID
private String msgId;
// Message storage timestamp
private long storeTimestamp;
// Consume queue's offset(step by one)
private long logicsOffset;
private long pagecacheRT = 0;
private int msgNum = 1; //批量发送消息条数
/**
* When write a message to the commit log, returns code
*/
public enum AppendMessageStatus {
PUT_OK,
END_OF_FILE, //超过文件大小
MESSAGE_SIZE_EXCEEDED, //消息长度超过最大允许长度
PROPERTIES_SIZE_EXCEEDED, //消息属性超过最大允许长度
UNKNOWN_ERROR,
}
Step12:更新消息队列逻辑偏移量
Step13:处理完消息追加逻辑后将释放putMessageLock锁
Step14:DefaultAppendMessageCallback#doAppend只是将消息追加在内存中,需要根据同步刷盘还是异步刷盘方式,将内存中的数据持久化道磁盘,然后执行HA主从同步复制。
handleDiskFlush(result, putMessageResult, msg);
handleHA(result, putMessageResult, msg);
return putMessageResult;
RMQ通过使用内存映射文件来提高IO访问性能,无论是CommitLog、ConsumeQueue还是IndexFile,单个文件都被设计成固定长度,如果一个文件写满后再创建一个新文件,文件名就是第一条消息的偏移量。
RMQ使用MappedFile、MappedFileQueue来封装存储文件,一个MappedFileQueue可以包含多个MappedFile。