diskqueue是nsq消息持久化的核心,内容较多,故分为多篇
1. diskqueue第一篇 - 是什么,为什么需要它,整体架构图,对外接口
2. diskqueue第二篇 - 元数据文件,数据文件,启动入口,元数据文件的读写及保存
3. diskqueue第三篇 - 数据定义详解,运转核心ioloop()源码详解
4. diskqueue第四篇 - 怎么写入消息,怎么对外发送消息
5. diskqueue第五篇 - 追尾检测,错误处理,如何正常关闭
6. diskqueue第六篇 - 如何使用diskqueue
经过前面四篇的介绍,相信大家对diskqueue的整体架构,运转逻辑应该有很清楚的了解了,这篇博客做个总结,先给大家看下diskqueue内部如何进行追尾检测,错误处理,最后给大家讲下如何正常关闭一个diskqueue
在第一篇博客中我们就讲了diskqueue的整体架构图,也就是前面写,后面读,如下
写快读慢时,结果是消息积累,消息文件越来越多
写慢读快时,只要持续的时间够长,读的位置一定会追上写的位置,也就是我们要讲的追尾
在运转核心 ioloop()函数中,我们可以看到,读取的数据会压入通道readChan,当外部从readChan取走后,select中会响应,代码如下(这里仅给出核心代码片段)
case r <- dataRead: // 把读出来的这个消息压入到readChan,外部从readChan取走以后,这里会立即返回 // 每取走一个消息,count值+1 count++ // 重新计算下次读位置,读文件号 d.moveForward()
响应操作
1. count值加1
2. 调用moveForward()函数,该函数内部会重新设置readPos的值,readFileNum的值
实现如下(已添加详细注释)
// 向前移动一个消息,前进读位置和读文件号(读出来的消息成功投递给外部后,会调用本函数)
func (d *diskQueue) moveForward() {
oldReadFileNum := d.readFileNum
d.readFileNum = d.nextReadFileNum // 下次要读的文件号
d.readPos = d.nextReadPos // 下次要读的位置
d.depth -= 1 // 未处理消息数减1
// 如果旧的读文件号和新的读文件号不一致,说明旧文件已读完,可以删除了,下次开始读新文件
if oldReadFileNum != d.nextReadFileNum {
d.needSync = true // 当我们开始读新文件的时候,强制把写的数据同步一次磁盘
fn := d.fileName(oldReadFileNum)// 旧文件名字
err := os.Remove(fn) // 删除旧文件
if err != nil {
d.logf(ERROR, "DISKQUEUE(%s) failed to Remove(%s) - %s", d.name, fn, err)
}
}
// 检测有没有追尾问题
d.checkTailCorruption(d.depth)
}
可以看到,刷新完readPos,readFileNum,读完消息删除文件后
最后调用了checkTailCorruption()函数,也就是我们要讲的追尾函数,代码如下(已添加详细注释)
// 检测追尾问题
func (d *diskQueue) checkTailCorruption(depth int64) {
// 读文件比写文件的编号小 或 读位置比写位置小,说明没追尾,中间还有消息,这是正常情况
if d.readFileNum < d.writeFileNum || d.readPos < d.writePos {
return
}
// 到这里:要么消息已全部读完(即发生追尾),要么出错
if depth != 0 { // depth应该为0,如果不为0,说明出错,强制置零吧
if depth < 0 {
d.logf(ERROR, "DISKQUEUE(%s) negative depth at tail (%d), metadata corruption, resetting 0...", d.name, depth) // depth<0说明元数据错了
} else if depth > 0 {
d.logf(ERROR, "DISKQUEUE(%s) positive depth at tail (%d), data loss, resetting 0...", d.name, depth) // depth>0说明消息丢了
}
d.depth = 0 // 消息数强制置0
d.needSync = true // 强制同步一次磁盘
}
// 读文件号不等于写文件号 或者 读位置不等于写位置,说明严重错误
if d.readFileNum != d.writeFileNum || d.readPos != d.writePos {
// 读的文件号 > 写的文件号
if d.readFileNum > d.writeFileNum {
d.logf(ERROR,"DISKQUEUE(%s) readFileNum > writeFileNum (%d > %d), corruption, skipping to next writeFileNum and resetting 0...", d.name, d.readFileNum, d.writeFileNum)
}
if d.readPos > d.writePos {
d.logf(ERROR,"DISKQUEUE(%s) readPos > writePos (%d > %d), corruption, skipping to next writeFileNum and resetting 0...", d.name, d.readPos, d.writePos)
}
d.skipToNextRWFile()// 删除所有的消息文件
d.needSync = true // 强制同步一次磁盘
}
}
对上面的代码解释下
1. 正常可读(即没有发生追尾)的两个条件:
要么readFileNum(读文件号)小于writeFileNum(写文件号),说明还有很多消息
要么readFile等于writeFileNum,即读写同一个文件,但需readPos(读位置)小于writePos(写位置)
2. 如果不满足上面的两个可读条件,那很可能就是发生追尾(读写同一个文件,且读写位置一样,且depth为0,也就是未处理消息数为0)
3. 也有可能是其他错误
3.1 如果depth不为0,无论是>0,还是<0,都强制置零
3.2 如果 readFileNum > writeFileNum,readPos>writePos,都属于严重错误,删除所有数据吧
因为整个diskqueue是建立在多协程读写的基础上,读写分离,且元数据和消息数据分离,所以很可能会发生各种各样的异常,都会造成无法正常运转的错误,因此错误处理必不可少
刚才讲的追尾处理函数checkTailCorruption()中也有对错误的处理,比如发生追尾时,depth值错误,readFileNum>writeFileNm,readPos>writePos等等
现在我们开始讲读取消息时的错误处理,先看下触发位置,在ioloop函数中,代码如下
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
}
}
}
在读取消息函数readOne()之后,发现返回的errr不为nil,则调用函数handleReadError()进行读错误处理
我们先总结下函数readOne()有哪些地方可能返回错误,切记,函数readOne()是正常未追尾的情况下才会调用
在博客 diskqueue第四篇 - 怎么写入消息,怎么对外发送消息 中有对函数readOne()的详解
这里不再重复,直接对函数readOne()的错误给出总结
1、打开读文件出错,跳转文件指针位置出错
2、读取消息大小错误,消息大小范围错误
3、读取消息数据错误
大家可以看下上面的几种错误,不能通过重试或数据修正来解决,也就是说没法挽救,所以diskqueue对此处理也很简单,既然挽救不了,那就不要这个文件了,直接跳过
现在看下diskqueue的handleReadError()函数,内部实现就是放弃这个文件,打印下错误日志,读下一个文件,源码如下(已添加详细注释)
// 读文件出错时的处理
func (d *diskQueue) handleReadError() {
// 处理规则:把当前读的文件重命名为xxx.bad,读下一个文件
// 如果读写的是同一个文件,又是出错,那写文件也新开一个写吧
if d.readFileNum == d.writeFileNum {
// 关闭当前写文件
if d.writeFile != nil {
d.writeFile.Close()
d.writeFile = nil
}
// 新开一个写文件(后面发现writeFile为nil时会创建文件)
d.writeFileNum++
d.writePos = 0
}
badFn := d.fileName(d.readFileNum) // 当前读的文件名
badRenameFn := badFn + ".bad"
d.logf(WARN,"DISKQUEUE(%s) jump to next file and saving bad file as %s", d.name, badRenameFn)
err := os.Rename(badFn, badRenameFn)// 把当前读的文件重命名为xxx.bad
if err != nil {
d.logf(ERROR,"DISKQUEUE(%s) failed to rename bad diskqueue file %s to %s", d.name, badFn, badRenameFn)
}
// 新开一个读文件
d.readFileNum++
d.readPos = 0
d.nextReadFileNum = d.readFileNum
d.nextReadPos = 0
// 重大的状态变更, 下一步同步一次文件
d.needSync = true
// 再检测一次是否追尾
d.checkTailCorruption(d.depth)
}
对上面的代码,再解释下
1. 如果读写的是同一个文件,又是出错,那写操作也新开一个文件重新写
2. 打印一行错误,把这个读出错的文件加个.bad后缀,标明是出错的文件,文件也不删除,留给用户自己解决吧
3. 新打开一个读文件,从0开始读
4. 强制同步一次(如果读写是不同的文件,写操作同步到文件以供读取),再检测一次是否追尾
结论:调用Close()可以正常关闭一个diskqueue
Close()函数的实现也很简单,关闭协程ioloop(),关闭读写文件,最后保存
给大家看下Close()函数内部做了哪些操作,源码如下
// 关闭队列(有保存数据)
func (d *diskQueue) Close() error {
err := d.exit(false) // 退出
if err != nil {
return err
}
return d.sync() // 保存数据
}
Close()函数第一步:调用了exit()函数进行退出操作
exit()函数源码如下(已添加详细注释)
// 退出操作
func (d *diskQueue) exit(deleted bool) error {
d.Lock()
defer d.Unlock()
// 标记正在进行退出操作
d.exitFlag = 1
// 你会发现本函数内deleted标记仅仅只是打印的区别,并没有真正执行什么删除操作,是因为Close()在exit()之后又调用了sync()进行保存,而Delete()仅调用exit()
if deleted {
d.logf(INFO, "DISKQUEUE(%s): deleting", d.name)
} else {
d.logf(INFO, "DISKQUEUE(%s): closing", d.name)
}
// 关闭退出通道(ioloop()协程的的select都会收到信号,处理就是退出其协程)
close(d.exitChan)
// 阻塞等待ioloop()协程退出
<-d.exitSyncChan
// 关闭消息个数通道(如果此时外部取消息个数,因为从通道读取失败,会直接返回d.depth)
close(d.depthChan)
// 关闭读文件
if d.readFile != nil {
d.readFile.Close()
d.readFile = nil
}
// 关闭写文件
if d.writeFile != nil {
d.writeFile.Close()
d.writeFile = nil
}
return nil
}
对上面的代码解释下
1. 因为需把exitFlag置1,有读写操作,所以加了写锁
2. 关闭通道exitChan,协程ioloop()会收到信号,退出其协程
3. 关闭读文件,写文件
Close()函数第二步:调用sync()函数,保存消息数据,保存元数据
sync()函数的源码如下(已添加详细注释)
// 把所有数据同步到磁盘
func (d *diskQueue) sync() error {
// 保存消息部分
if d.writeFile != nil {
err := d.writeFile.Sync() // 真正刷新到磁盘
if err != nil {
d.writeFile.Close() // 如果出错,也要即时关闭写入文件
d.writeFile = nil
return err
}
}
// 保存元数据部分
err := d.persistMetaData()
if err != nil {
return err
}
// 标记本次同步已完成,后面不需要再同步了
d.needSync = false
return nil
}
本篇博客从源码角度讲了diskqueue对读写时追尾的检测和处理,对读出错时的处理方法,希望让大家明白对于文件操作错误的处理方法和技巧。最后介绍了如何正常关闭一个diskqueue,也就是调用Close()函数,并分析了Close()函数的原理