diskqueue第五篇 - 追尾检测,错误处理,如何正常关闭

diskqueue是nsq消息持久化的核心,内容较多,故分为多篇

1. diskqueue第一篇 - 是什么,为什么需要它,整体架构图,对外接口

2. diskqueue第二篇 - 元数据文件,数据文件,启动入口,元数据文件的读写及保存

3. diskqueue第三篇 - 数据定义详解,运转核心ioloop()源码详解

4. diskqueue第四篇 - 怎么写入消息,怎么对外发送消息

5. diskqueue第五篇 - 追尾检测,错误处理,如何正常关闭

6. diskqueue第六篇 - 如何使用diskqueue

经过前面四篇的介绍,相信大家对diskqueue的整体架构,运转逻辑应该有很清楚的了解了,这篇博客做个总结,先给大家看下diskqueue内部如何进行追尾检测,错误处理,最后给大家讲下如何正常关闭一个diskqueue

1. diskqueue追尾检测

在第一篇博客中我们就讲了diskqueue的整体架构图,也就是前面写,后面读,如下

diskqueue第五篇 - 追尾检测,错误处理,如何正常关闭_第1张图片

写快读慢时,结果是消息积累,消息文件越来越多

写慢读快时,只要持续的时间够长,读的位置一定会追上写的位置,也就是我们要讲的追尾

在运转核心 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,都属于严重错误,删除所有数据吧

2. diskqueue的错误处理

因为整个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. 强制同步一次(如果读写是不同的文件,写操作同步到文件以供读取),再检测一次是否追尾

3. 如何正常关闭一个diskqueue

结论:调用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
}

4. 本篇总结

本篇博客从源码角度讲了diskqueue对读写时追尾的检测和处理,对读出错时的处理方法,希望让大家明白对于文件操作错误的处理方法和技巧。最后介绍了如何正常关闭一个diskqueue,也就是调用Close()函数,并分析了Close()函数的原理

你可能感兴趣的:(nsq,diskqueue详解,diskqueue追尾,diskqueue错误处理)