前面一篇博客 nsq中diskqueue详解 - 第二篇_YZF_Kevin的博客-CSDN博客 我们讲了diskqueue的两种文件存储格式,diskqueue的启动入口,元数据文件的读取和写入,如果你还没了解过,强烈建议先看一下
这篇博客,我们重点讲diskqueue的定义,以及最核心的ioloop循环,写入消息的处理,读取消息的处理等等
先看下diskqueue的源码定义,如下(已加详细注释)
// FIFO的持久化队列
type diskQueue struct {
// 64bit atomic vars need to be first for proper alignment on 32bit platforms
readPos int64 // 读文件的读取点
writePos int64 // 写文件的写入点(也是当前文件的总写入字节数)
readFileNum int64 // 当前要读取的文件编号
writeFileNum int64 // 当前要写入的文件编号
depth int64 // 总的消息数(注意不是当前文件的写入消息数,而是全部的)
sync.RWMutex // 读写锁,对exitFlag等标记的读写时需加锁
name string // 队列实例名字(只是打印时用,用来跟其他队列有所区别,nsq中使用topic+channel来标识一个队列)
dataPath string // 文件所在路径
maxBytesPerFile int64 // 一个文件的最大字节数(外部传入)
maxBytesPerFileRead int64 // 当前文件最大可读字节数(跟maxBytesPerFile不同,因为一个文件限定100M,可能实际只写了99.97M,读取时只能以99.97M为准)
minMsgSize int32 // msg的最小字节数(外部传入)
maxMsgSize int32 // msg的最大字节数(外部传入)
syncEvery int64 // 读写多少次触发刷到磁盘
syncTimeout time.Duration // 同步到文件的间隔(触发时还会判断是否有变化,有变化才会真的刷)
exitFlag int32 // 退出标记,1表正在退出,不再接收新数据写入,ioloop()协程也退出
needSync bool // 标记需同步到磁盘
nextReadPos int64 // 下次应该读的位置(不直接更新读位置是因为消息还没投递给外部,不算真正读完成,如果外部没接收,下次还得从readPos读。
// 只有外部接收完成,才算消息投递完成,才可以把 readPos 更新为 nextReadPos
nextReadFileNum int64 // 下次应该读的文件号(原因同上)
readFile *os.File // 读文件的文件指针
writeFile *os.File // 写文件的文件指针
reader *bufio.Reader // 读文件对象的reader
writeBuf bytes.Buffer // 写文件对象的buffer
readChan chan []byte // 只读的通道,通过ReadChan()返回给外面使用(无缓冲,压入数据后只能等外部使用者取走后才能继续)
peekChan chan []byte // 查看的通道,通过PeekChan()返回给外面使用
// 内部使用的通道
depthChan chan int64 // 存放当前总消息数的通道
writeChan chan []byte // 接收外部数据的通道(无缓冲,所以压入后阻塞等待ioloop()循环的处理)
writeResponseChan chan error // 接收外部数据后回应的通道(无缓冲)
emptyChan chan int // 清空队列信号的通道(当外部调用Empty()时,会往该通道写1,ioloop()循环中读取后会执行清空操作)
emptyResponseChan chan error // 清空队列信号结果的通道
exitChan chan int // 退出通道
exitSyncChan chan int // 退出结果的通道
logf AppLogFunc // 日志函数(外部传入)
}
都是一些变量定义,已经加了详细的注释,这里不再一一介绍,只是总结下
1. 前面的5个变量 readPos, writePos, readFileNum, writeFileNum, depth不用多介绍,上一篇博客已经讲过了,都是元数据的字段,描述队列的整体信息
2. 至于name, dataPath,maxBytesPerFile, minMsgSize, maxMsgSize, syncEvery, syncTimieout,这7个变量都是diskqueue启动时外部传入的,主要是设置队列属性
3. maxBytesPerFileRead 不同于 maxBytesPerFile:
maxBytesPerFile 是所有消息数据文件的最大写入字节数,是个限制值,比如100M
maxBytesPerFileRead 是当前读文件的最大可读字节数,这个是实际值,每当读一个新文件时都会获取文件的实际大小,实际只能读这么多,比如文件实际是99.97M,那只能读这么多
4. needSync是标记是否需同步磁盘,读写次数超过限定的syncEvery时,或需新建写文件时都要置为true,ioloop()循环就会执行一次强制刷新到磁盘
5. nextReadPos和nextReadFileNum,我们一般会认为,数据读取后不就可以后移读位置了么?其实不是这样的,因为这里读取完毕后仅仅是尝试压入readChan,不代表外部真的接收了。举例:这里读取成功后尝试压入到readChan,readChan是个无缓冲的通道,但外部一直没接收最后还关闭了自己,下次再从这里读消息时我们还要投递这个消息,也就是说读取位置不能变;nextReadFileNum同理,只有上一个文件全部读取且外部接收成功了,这里才能读新文件
6. readChan和peekChan,对外提供的ReadChan()是获取一个只读通道,这里读取一个消息后压入readChan,等待外部接收后才返回,后移读位置,再读取下一个消息;对外提供的PeekChan()也是获取一个只读通道,等待外部接收后才返回,但是不后移读位置,也不会触发读下一个消息,也就是说PeekChan()正如其名,仅仅提供查看功能
我们先说下ioloop()函数的运作机制,这样大家先做到心里有数,再看源码就很轻松了
1. 建立一个定时器,触发时间为外部传入的syncTimeout,定时器触发时检测有没有读写发生,有则同步一次磁盘
2. writeChan是接收外部写入的通道,外部写入后,本函数会在select中读取到,调用writeOne()写入到当前的写文件(如果发现文件即将超上限,就新开一个文件写),count值+1
3. readChan是给外部读取的通道,本函数总是会读取一个消息往readChan中压入,等待外部接收,外部接收成功后,本函数会在select中返回,就再读取下一个消息(如果发现当前读文件已读完,就读下一个文件),count值+1
4. 每读取一个消息,每写入一个消息,count都会加1,cout值达到外部传入的syncEvery,就同步一次磁盘
5. emptyChan 用来接收外部的清空信号,本函数的select中接收到就进行清空操作
6. exitChan 用来接收外部的退出信号,本函数的select中接收到就进行退出操作
好了,上面的6个操作就是ioloop()函数的核心了,现在贴代码(已添加详细注释)
// 独立协程运行
func (d *diskQueue) ioLoop() {
var dataRead []byte // 存储每次读取的数据
var err error
var count int64 // 每read,write一次,count值+1,满syncEvery则往磁盘同步一次
var r chan []byte // 对外的读消息通道(为nil时说明没有数据可读)
var p chan []byte // 对外的查看消息通道(为nil时说明没有数据可读)
// 同步定时器
syncTicker := time.NewTicker(d.syncTimeout)
for {
// count值够了就标记需同步
if count == d.syncEvery {
d.needSync = true
}
// 发现需同步
if d.needSync {
err = d.sync() // 同步到磁盘(该函数在同步磁盘后会把needSync置为false)
if err != nil {
d.logf(ERROR, "DISKQUEUE(%s) failed to sync - %s", d.name, err)
}
count = 0 // count重新从0开始计
}
// 有消息可读的条件:读的是旧文件 或者 读的位置比写的位置小
if (d.readFileNum < d.writeFileNum) || (d.readPos < d.writePos) {
// 读位置已经移动(说明外部取走了数据),才可读下一个消息
if d.nextReadPos == d.readPos {
dataRead, err = d.readOne()
if err != nil {
d.logf(ERROR, "DISKQUEUE(%s) reading at %d of %s - %s", d.name, d.readPos, d.fileName(d.readFileNum), err)
// 读出错的处理
d.handleReadError()
continue
}
}
// 赋值通道
r = d.readChan
p = d.peekChan
} else { // 不可读的时候,把r,p置为nil,确保下面的select会直接跳过,外部使用者在select中判断时也会跳过
r = nil
p = nil
}
// go中通道的特性决定:select中对为nil的chan的读/写操作会直接跳过,
// 所以只有有数据可读的时候,p,r才有值(p指向peekChan,r指向readChan)
select {
case p <- dataRead:
// 注意,这里什么都没做,因为p仅仅是查看通道,消息不算取走
case r <- dataRead: // 把读出来的这个消息压入到readChan,外部从readChan取走以后,这里会立即返回
// 每取走一个消息,count值+1
count++
// 重新计算下次读位置,读文件号
d.moveForward()
case d.depthChan <- d.depth: // 把最新的未读消息数压入对外通道
// 什么都不做,外部取走未处理消息数跟本协程没关系
case <-d.emptyChan: // 收到清空队列的信号
d.emptyResponseChan <- d.deleteAllFiles() // 删除所有的文件(清空队列),并把结果压入返回通道,因为外部调用者还在等结果
count = 0 // 重新从0计数
case dataWrite := <-d.writeChan: // 新接收到消息
count++ // 每收到一个消息,count值+1
d.writeResponseChan <- d.writeOne(dataWrite) // 消息写入到文件缓冲区,结果压入返回通道
case <-syncTicker.C:// 同步定时器
if count == 0 {
continue // 虽然时间到了,但数据没有变化,也不用同步
}
d.needSync = true // 这里仅标记,下次for循环会进行同步操作
case <-d.exitChan: // 退出信号
goto exit
}
}
exit:
d.logf(INFO, "DISKQUEUE(%s): closing ... ioLoop", d.name)
syncTicker.Stop()
// 退出完成,往exitSyncChan发信号,因为主协程还在等
d.exitSyncChan <- 1
}
上面已经讲了ioloop()函数的运作机制,代码也添加了详细的注释,相信大家都能轻松看懂
我再啰嗦下几个值得注意的点
1. diskqueue的运作流程是一边新写入消息,一边读取处理,像生产者消费者机制一样
如果读慢写快,结果就是消息文件会一直新增,这倒没什么,反正消息已保存进文件了,后面再处理就是了
如果读快写慢,那么只要持续的时间够长,读的位置一定会追上写的位置,造成无消息可读,等于发生了读写追尾,这个时候读操作就要停止
所以总结下可读操作的条件:
要么读的文件号较小,写的文件号较大,读写不是同一个文件;
要么读写的是同一个文件,但读的位置比写的位置小;
2. 函数内:r表可读的通道,p表查看的通道,当不可读的时候,r,p均赋值为nil,本函数的select会直接跳过对值为nil通道的写入,外部接受者的select也会跳过对值为nil通道的读取,这是golang通道的特性之一,大家注意
3. diskqueue在退出/删除时会调用exit()函数,该函数会关闭通道exitChan,此时ioloop()的select就会返回,关闭ioloop的协程。注意:关闭通道时,所有监听该通道的select都会收到消息,这也是golang通道的特性之一
diskqueue对外提供的写入消息接口为
Put([]byte) error
这个接口的实现不复杂,就是把对应的数据写入到当前在写文件,如果发现写入数据后,当前在写文件会超上限,那就不写了,关闭当前的写文件,新开一个写入文件从0位置开始写
源码如下(已添加详细注释)
// 把指定数据压入接收队列
func (d *diskQueue) Put(data []byte) error {
d.RLock()
defer d.RUnlock()
// 如果队列正在退出,返回吧
if d.exitFlag == 1 {
return errors.New("exiting")
}
d.writeChan <- data // 压入接收队列,因为无缓冲,所以会阻塞等待ioloop()循环中取走才会返回
return <-d.writeResponseChan// 阻塞等待结果,ioloop()从writeChan读取执行后会立马出结果
}
可以看到,函数操作很简单,核心操作就是往d.writeChan中压入数据,由于d.writeChan是无缓冲通道,所以会阻塞等待ioloop()函数中select的取走
ioloop()函数中select对此的处理如下
case dataWrite := <-d.writeChan: // 新接收到消息 count++ // 每收到一个消息,count值+1 d.writeResponseChan <- d.writeOne(dataWrite) // 消息写入到文件缓冲区,结果压入返回通道
select对此的处理也很简单,count值加1后,调用了d.writeOne(dataWrite),然后把writeOne()的结果写入到writeResponseChan
我们看下writeOne()函数的处理,源码如下(已添加详细注释)
// 写入一个消息到缓冲区(函数内部会自动创建新文件)
func (d *diskQueue) writeOne(data []byte) error {
var err error
dataLen := int32(len(data)) // 数据长度
totalBytes := int64(4 + dataLen) // 本次写入的总字节数(4字节的数据长度 + 真正数据部分)
// 数据大小检测
if dataLen < d.minMsgSize || dataLen > d.maxMsgSize {
return fmt.Errorf("invalid message write size (%d) minMsgSize=%d maxMsgSize=%d", dataLen, d.minMsgSize, d.maxMsgSize)
}
// 如果加上本次写入量会超过文件最大限制,就关闭当前文件,创建新的
if d.writePos > 0 && d.writePos+totalBytes > d.maxBytesPerFile {
// 如果当前已经在读这个文件
if d.readFileNum == d.writeFileNum {
d.maxBytesPerFileRead = d.writePos // 标识最大可读字节数即writePos(因为不再写入这个文件了,下面会往新文件里面写了)
}
d.writeFileNum++ // 新文件编号
d.writePos = 0 // 新文件的写入起始点
// 当前文件的内容刷到磁盘
err = d.sync()
if err != nil {
d.logf(ERROR, "DISKQUEUE(%s) failed to sync - %s", d.name, err)
}
// 关闭当前文件
if d.writeFile != nil {
d.writeFile.Close()
d.writeFile = nil
}
}
// 要写的文件还不存在,新建
if d.writeFile == nil {
// 格式化文件名
curFileName := d.fileName(d.writeFileNum)
// 创建文件
d.writeFile, err = os.OpenFile(curFileName, os.O_RDWR|os.O_CREATE, 0600)
if err != nil {
return err
}
d.logf(INFO, "DISKQUEUE(%s): writeOne() opened %s", d.name, curFileName)
// 如果已有写入位置
if d.writePos > 0 {
_, err = d.writeFile.Seek(d.writePos, 0) // 偏移文件游标,0表从文件开头进行偏移
if err != nil {
d.writeFile.Close()
d.writeFile = nil
return err
}
}
}
d.writeBuf.Reset()
// 先把数据长度(4字节)写入buf
err = binary.Write(&d.writeBuf, binary.BigEndian, dataLen)
if err != nil {
return err
}
// 再把数据写入buf
_, err = d.writeBuf.Write(data)
if err != nil {
return err
}
// 把buf写入(注意这里其实是写入到文件的缓冲区,并没有刷到磁盘中,只有调用writeFile.fsync()才是真正刷到磁盘)
_, err = d.writeFile.Write(d.writeBuf.Bytes())
if err != nil {
d.writeFile.Close()
d.writeFile = nil
return err
}
d.writePos += totalBytes // 更新写入位置
d.depth += 1 // 更新消息数
return err
}