一、背景
kafka的基本存储单元是分区(Partition),分区可以分成若干片段(segment),segment是kafka在磁盘上的最小存储单元,每个Segment包括两个文件,一个是存储实际消息的数据文件,另一个是索引文件。在broker往Partition写入数据时,如果达到segment上限(数据量达到设定的阈值),就关闭当前文件,并打开一个新文件。
当前正在写入数据的segment叫作活跃segment,活跃的segment永远不会被删除。
消息存储文件格式
kafka的消息和偏移量一起保存在文件中,保存在磁盘上的数据格式与从生产者发送过来或者发送给消费者的消息格式是一样,因为使用了相同的消息格式进行磁盘存储和网络传输,kafka可以使用零复制技术给消费者发送消息。
索引文件
为了帮助broker更快定位到指定的偏移量,kafka为每个分区维护了一个索引,索引把偏移量映射到片段文件和偏移量在文件里的位置。
清理
针对下面的场景需要策略将旧数据从磁盘中释放:
场景一:针对已经超过设置的保存时间的数据,是需要把超过时效的旧数据删除掉。
场景二:针对某些消息,如果进行修改了,那么只有最新的消息记录才是有效的。
Kafka 通过改变主题的保留策略来满足这些使用场景。早于保留时间的旧事件会被删除, 为每个键保留最新的值,从而达到清理的效果。
二、Jocko中物理存储的实现
在jocko中也是按照上面的设计理念来构建消息的物理存储。下面从最小存储单位来分析数据结构,然后从提交日志到最小存储单元来分析基本存储单元的操作流程。
1、数据结构
1.1)segment
go
type Segment struct {
writer io.Writer // 消息日志文件
reader io.Reader
log *os.File
Index *Index // 索引文件
BaseOffset int64 // 在索引文件中的记录
NextOffset int64
Position int64 // 在数据文件中的偏移
maxBytes int64
path string
suffix string
sync.Mutex
}
segment结构体中包含了对消息日志文件(.log)和消息索引文件(.index)的两个文件的操作。
其中索引文件通常记录了消息的偏移量(offset)和消息在日志文件中的物理位置(position),数据结构如下:
go
type Entry struct {
Offset int64 // kafka分区中的逻辑偏移量
Position int64 // 日志文件中的物理位置
}
该结构体中包含了两个字段:
- 偏移量(Offset) :它是指消息在 Kafka 分区中的逻辑偏移量。Kafka中的每条消息都有一个唯一的连续偏移量,它代表消息在分区中的顺序。
- 位置(Position) :它是指消息在分区日志文件中的物理位置,即消息数据在日志文件中的起始字节位置。这通过对日志文件进行随机访问可以帮助快速定位消息内容。
1.2) commitLog
go
type CommitLog struct {
Options
cleaner Cleaner
name string
mu sync.RWMutex
segments []*Segment
vActiveSegment atomic.Value
}
这个结构体表示的是broker中某一个分区的消息提交日志(commit log),里面包含了当前分区的全部segments、当前活跃的segment字段(使用原子操作来保证并发安全),同时还包含了cleaner,即对旧数据的清理策略。
2、核心操作
分区中提交日志(commit log)操作接口如下:
scss
type CommitLog interface {
Delete() error
NewReader(offset int64, maxBytes int32) (io.Reader, error)
Truncate(int64) error
NewestOffset() int64
OldestOffset() int64
Append([]byte) (int64, error)
}
下面针对核心操作进行分析,其中包含初始化、写操作、读操作、清理操作。
2.1) 初始化
从commitlog的初始化化开始,代码如下:
go
func New(opts Options) (*CommitLog, error) {
if opts.CleanupPolicy == "" {
opts.CleanupPolicy = DeleteCleanupPolicy
}
var cleaner Cleaner
if opts.CleanupPolicy == DeleteCleanupPolicy {
cleaner = NewDeleteCleaner(opts.MaxLogBytes)
} else {
cleaner = NewCompactCleaner()
}
path, _ := filepath.Abs(opts.Path)
l := &CommitLog{
Options: opts,
name: filepath.Base(path),
cleaner: cleaner,
}
if err := l.init(); err != nil {
return nil, err
}
if err := l.open(); err != nil {
return nil, err
}
return l, nil
}
根据cleaner的策略创建清理(cleaner)对象,默认的清理策略为删除策略(DeleteCleanupPolicy),构建commitlog对象,调用init()方法根据传入的路径来构建(在目录不存在的情况下)这个分区日志所在的路径(XXX/data/partitonid),最后调用open方法开始处理路径下的文件。
go
func (l *CommitLog) open() error {
files, err := ioutil.ReadDir(l.Path)
... ...
for _, file := range files {
if strings.HasSuffix(file.Name(), IndexFileSuffix) {
_, err := os.Stat(filepath.Join(l.Path, strings.Replace(file.Name(), IndexFileSuffix, LogFileSuffix, 1)))
if os.IsNotExist(err) {
if err := os.Remove(file.Name()); err != nil {
return err
}
} else if err != nil {
return errors.Wrap(err, "stat file failed")
}
} else if strings.HasSuffix(file.Name(), LogFileSuffix) {
offsetStr := strings.TrimSuffix(file.Name(), LogFileSuffix)
baseOffset, err := strconv.Atoi(offsetStr)
if err != nil {
return err
}
segment, err := NewSegment(l.Path, int64(baseOffset), l.MaxSegmentBytes)
if err != nil {
return err
}
l.segments = append(l.segments, segment)
}
}
if len(l.segments) == 0 {
segment, err := NewSegment(l.Path, 0, l.MaxSegmentBytes)
if err != nil {
return err
}
l.segments = append(l.segments, segment)
}
l.vActiveSegment.Store(l.segments[len(l.segments)-1])
return nil
}
如果指定路径下面存在文件,那么根据文件存储的内容进行以下处理:
- 如果路径下的文件后缀名存在.index,说明这个文件是用来存储索引,
- 如果路径下的文件名后缀存在.log,说明这个文件是用来存储消息,其中文件的名称包含了该文件中存储的消息初始offset
如果指定路径下不存在文件,那么需要构建新的segment,最后把得到的全部segment加入到commitlog的segments字段中,并且取最后一个segment当做activate segment。
下面分析segment的初始化:
lua
func NewSegment(path string, baseOffset, maxBytes int64, args ...interface{}) (*Segment, error) {
var suffix string
if len(args) != 0 {
suffix = args[0].(string)
}
s := &Segment{
maxBytes: maxBytes,
BaseOffset: baseOffset,
NextOffset: baseOffset,
path: path,
suffix: suffix,
}
// 创建.log文件
log, err := os.OpenFile(s.logPath(), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
return nil, errors.Wrap(err, "open file failed")
}
s.log = log
s.writer = log
s.reader = log
// 创建.index文件
err = s.SetupIndex()
return s, err
}
scss
func (s *Segment) SetupIndex() (err error) {
s.Index, err = NewIndex(options{
path: s.indexPath(),
baseOffset: s.BaseOffset,
})
if err != nil {
return err
}
return s.BuildIndex()
}
创建index的逻辑如下:
go
func NewIndex(opts options) (idx *Index, err error) {
// 检查配置选项中的bytes是否为0,如果是给个默认值=10MB
if opts.bytes == 0 {
opts.bytes = 10 * 1024 * 1024
}
if opts.path == "" {
return nil, errors.New("path is empty")
}
idx = &Index{
options: opts,
}
// 以读写方式|新建一个文件
idx.file, err = os.OpenFile(opts.path, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
return nil, errors.Wrap(err, "open file failed")
}
fi, err := idx.file.Stat()
if err != nil {
return nil, errors.Wrap(err, "stat file failed")
} else if fi.Size() > 0 {
idx.position = fi.Size()
}
// 尝试调整文件大小到 opts.bytes 最接近的 entryWidth 的整数倍。
// entryWidth 代表每个索引条目的固定大小。
if err := idx.file.Truncate(roundDown(opts.bytes, entryWidth)); err != nil {
return nil, err
}
// 设置文件描述符的内存保护级别以及共享类型
// 将文件映射到内存中来操作文件内容
idx.mmap, err = gommap.Map(idx.file.Fd(), gommap.PROT_READ|gommap.PROT_WRITE, gommap.MAP_SHARED)
if err != nil {
return nil, errors.Wrap(err, "mmap file failed")
}
return idx, nil
}
每一步的操作都备有注解,这里看到使用了一个开源库gommap完成索引文件的内存映射。
go
func (s *Segment) BuildIndex() (err error) {
// 前置检查,确保索引的状态是健康的
if err = s.Index.SanityCheck(); err != nil {
return err
}
// 重置索引文件
if err := s.Index.TruncateEntries(0); err != nil {
return err
}
//从log中读取数据
_, err = s.log.Seek(0, 0)
if err != nil {
return err
}
b := new(bytes.Buffer)
nextOffset := s.BaseOffset
position := int64(0)
loop:
for {
// get offset and size
_, err = io.CopyN(b, s.log, 8)
if err != nil {
break loop
}
_, err = io.CopyN(b, s.log, 4)
if err != nil {
break loop
}
size := int64(Encoding.Uint32(b.Bytes()[8:12]))
_, err = io.CopyN(b, s.log, size)
if err != nil {
break loop
}
// Reset the buffer to not get an overflow
b.Truncate(0)
entry := Entry{
Offset: nextOffset,
Position: position,
}
err = s.Index.WriteEntry(entry)
if err != nil {
break loop
}
position += size + msgSetHeaderLen
nextOffset++
_, err = s.log.Seek(size, 1)
if err != nil {
break loop
}
}
if err == io.EOF {
s.NextOffset = nextOffset
s.Position = position
return nil
}
return err
}
从log文件中重新构建index内容,log中每个消息的前12个字节保存了元数据,其中前8个保留了偏移,后4个保留了这条消息的长度,根据得到的长度来构建index entity,从而实现消息索引的构建。
2.2) 写操作
写操作核心用来更新.log和.index 日志,下面是操作代码:
go
func (l *CommitLog) Append(b []byte) (offset int64, err error) {
ms := MessageSet(b)
//返回一个活跃的segment
if l.checkSplit() {
if err := l.split(); err != nil {
return offset, err
}
}
// 找到活跃segment的偏移量
position := l.activeSegment().Position
offset = l.activeSegment().NextOffset
ms.PutOffset(offset)
// 将当前数据写入到这个activeSegment中
if _, err := l.activeSegment().Write(ms); err != nil {
return offset, err
}
// 更新索引文件信息
e := Entry{
Offset: offset,
Position: position,
}
if err := l.activeSegment().Index.WriteEntry(e); err != nil {
return offset, err
}
return offset, nil
}
上面代码给出了详细的注释,下面对需要注意的地方详细阐
1、尝试从vActiveSegment atomic.Value中获取activateSegment的时候,判断其长度是否超过最大长度要求
go
func (s *Segment) IsFull() bool {
s.Lock()
defer s.Unlock()
return s.Position >= s.maxBytes
}
如果超过最大长度要求,那么就需要创建新的segment
go
func (l *CommitLog) split() error {
segment, err := NewSegment(l.Path, l.NewestOffset(), l.MaxSegmentBytes)
if err != nil {
return err
}
l.mu.Lock()
defer l.mu.Unlock()
segments := append(l.segments, segment)
segments, err = l.cleaner.Clean(segments)
if err != nil {
return err
}
l.segments = segments
l.vActiveSegment.Store(segment)
return nil
}
在创建新的segment的时候,执行了清理操作,稍后下面的清理将具体分析。
2、写入到.log文件中的消息携带了偏移量(offset)这个元数据。
scss
offset = l.activeSegment().NextOffset
ms.PutOffset(offset)
// 将当前数据写入到这个activeSegment中
if _, err := l.activeSegment().Write(ms); err != nil {
return offset, err
}
在写入之后,更新了当前segment的下一个消息的偏移量和在.log文件的起始位置字段
go
// 写入log文件中
func (s *Segment) Write(p []byte) (n int, err error) {
s.Lock()
defer s.Unlock()
n, err = s.writer.Write(p) // segment的写入
if err != nil {
return n, errors.Wrap(err, "log write failed")
}
s.NextOffset++ // 更新segment中下一个消息的偏移量
s.Position += int64(n) // 更新segment中下一个消息的在.log文件的起始位置
return n, nil
}
3、在写入index文件中,首先通过newRelEntry 函数创建基于基础offset的 relEntry,这个操作是从实际的日志偏移量 e.Offset 减去基础偏移量 baseOffset。在分段索引文件中储存的每个偏移量是相对于分段的起始偏移量来算的,每个段的索引都是从0开始的。
go
//写入index文件中
func (idx *Index) WriteEntry(entry Entry) (err error) {
b := new(bytes.Buffer)
relEntry := newRelEntry(entry, idx.baseOffset)
if err = binary.Write(b, Encoding, relEntry); err != nil {
return errors.Wrap(err, "binary write failed")
}
idx.WriteAt(b.Bytes(), idx.position)
idx.mu.Lock()
idx.position += entryWidth // 更新.log文件的起始偏移,下一个内容的写入开始位置
idx.mu.Unlock()
return nil
}
func newRelEntry(e Entry, baseOffset int64) relEntry {
return relEntry{
Offset: int32(e.Offset - baseOffset), // offset
Position: int32(e.Position),
}
}
func (idx *Index) WriteAt(p []byte, offset int64) (n int) {
idx.mu.Lock()
defer idx.mu.Unlock()
return copy(idx.mmap[offset:offset+entryWidth], p)
}
2.3) 读操作
读操作的流程:根据offset先找到属于哪个segment,然后再从segment中查找,因为都是有序的,所以可以采用二分查找。
首先进入NewReader函数中,该函数的处理逻辑如下:
go
func (l *CommitLog) NewReader(offset int64, maxBytes int32) (io.Reader, error) {
var s *Segment
var idx int
if offset == 0 {
// TODO: seems hackish, should at least check if segments are set.
s, idx = l.Segments()[0], 0
} else {
s, idx = findSegment(l.Segments(), offset)
}
if s == nil {
return nil, errors.Wrapf(ErrSegmentNotFound, "segments: %d, offset: %d", len(l.Segments()), offset)
}
e, err := s.findEntry(offset)
if err != nil {
return nil, err
}
return &Reader{
cl: l,
idx: idx,
pos: e.Position,
}, nil
}
最终这个方法返回结构体Reader,它里面实现了io.Reader接口,完成对segment中指定内容的读取。
下面分析根据offset在segment的查找过程:
1、根据offset找到对应的segment
ini
if offset == 0 {
// TODO: seems hackish, should at least check if segments are set.
s, idx = l.Segments()[0], 0
} else {
s, idx = findSegment(l.Segments(), offset)
}
go
func findSegment(segments []*Segment, offset int64) (*Segment, int) {
n := len(segments)
idx := sort.Search(n, func(i int) bool {
return segments[i].NextOffset > offset
})
if idx == n {
return nil, idx
}
return segments[idx], idx
}
根据offset的大小来获取segment:
- 如果offset==0,获取commitlog中segments中第一个
- 如果offset>0,那么根据每个segment的NextOffset字段,二分查找得到对应的segment文件
2、从segment的索引文件获取消息在消息日志文件中的偏移
go
func (s *Segment) findEntry(offset int64) (e *Entry, err error) {
s.Lock()
defer s.Unlock()
e = &Entry{}
n := int(s.Index.bytes / entryWidth)
idx := sort.Search(n, func(i int) bool {
_ = s.Index.ReadEntryAtFileOffset(e, int64(i*entryWidth))
return e.Offset >= offset || e.Offset == 0
})
if idx == n {
return nil, errors.New("entry not found")
}
_ = s.Index.ReadEntryAtFileOffset(e, int64(idx*entryWidth))
return e, nil
}
func (idx *Index) ReadEntryAtFileOffset(e *Entry, fileOffset int64) (err error) {
p := make([]byte, entryWidth) //
if _, err = idx.ReadAt(p, fileOffset); err != nil {
return err
}
b := bytes.NewReader(p)
rel := &relEntry{}
err = binary.Read(b, Encoding, rel)
if err != nil {
return errors.Wrap(err, "binary read failed")
}
idx.mu.RLock()
rel.fill(e, idx.baseOffset)
idx.mu.RUnlock()
return nil
}
func (idx *Index) ReadAt(p []byte, offset int64) (n int, err error) {
idx.mu.RLock()
defer idx.mu.RUnlock()
if idx.position < offset+entryWidth {
return 0, io.EOF
}
n = copy(p, idx.mmap[offset:offset+entryWidth])
return n, nil
}
findEntry 方法中,在 Segment 结构中定义,用来查找偏移量大于等于指定 offset 的最接近的条目,在二分搜索中查找逻辑中在给定的匿名函数中,使用 ReadEntryAtFileOffset 方法读取位于 i*entryWidth 处的条目,如果读取到的条目 Offset 大于等于给定的 offset 或者 Offset 为 0(可能表示开始位置),匿名函数返回 true。
ReadEntryAtFileOffset和ReadAt,在Index结构体中定义,用来从index读取数据,并更新匿名函数中entry的offset和position,用来与请求的offset对比。
到目前为止,已经得到要查找的消息所在的segment和对应的position,那么可以构建Reader结构体
go
type Reader struct {
cl *CommitLog
idx int
mu sync.Mutex
pos int64
}
该结构体实现的reader接口才是真正读取数据日志文件的地方
go
func (r *Reader) Read(p []byte) (n int, err error) {
r.mu.Lock()
defer r.mu.Unlock()
segments := r.cl.Segments()
segment := segments[r.idx]
var readSize int
for {
readSize, err = segment.ReadAt(p[n:], r.pos)
n += readSize
r.pos += int64(readSize)
if readSize != 0 && err == nil {
continue
}
if n == len(p) || err != io.EOF {
break
}
if len(segments) <= r.idx+1 {
err = io.EOF
break
}
r.idx++
segment = segments[r.idx]
r.pos = 0
}
return n, err
}
这里是一直从.log文件中读取数据,何时停止是由调用方决定的。
2.4) 清理操作
当处理写操作的时候,activate segment超过最大字节限制,commitlog创建新的segment的时候,会进行清理操作。kafka中清理操作是异步的,jocko中实现的清理是同步。
ini
segments, err = l.cleaner.Clean(segments)
清理操作分为两种:delete、compact。在源码中的结构体如下:
go
type DeleteCleaner struct {
Retention struct {
Bytes int64
}
}
type CompactCleaner struct {
// map from key hash to offset
m map[uint64]int64
}
这两个结构体实现了cleaner接口中的clean方法来实现各自的清理策略。
- DeleteCleaner的清理策---根据配置的保留字节大小来清理文件段,保留了只有在这个字节范围内的数据会被保留,超出这个范围的旧数据将会被删除以释放空间。
- CompactCleaner的清理策略---为每个消息键创建映射,记录了该键的最新偏移量,对于每个消息,判断其偏移量是否等于该键最新的偏移量映射中记录的值,如果是,则表示该消息是该键的最后一条(或唯一一条)消息,应被保留。
deletecleaner的clean方法代码如下:
go
func (c *DeleteCleaner) Clean(segments []*Segment) ([]*Segment, error) {
if len(segments) == 0 || c.Retention.Bytes == -1 {
return segments, nil
}
cleanedSegments := []*Segment{segments[len(segments)-1]}
totalBytes := cleanedSegments[0].Position
if len(segments) > 1 {
var i int
for i = len(segments) - 2; i > -1; i-- {
s := segments[i]
totalBytes += s.Position // 通过偏移量得到目前遍历到的消息大小
if totalBytes > c.Retention.Bytes { // 如果超过了阈值,那么直接返回
break
}
// 对于没有超过阈值的数据进行保存
cleanedSegments = append([]*Segment{s}, cleanedSegments...)
}
if i > -1 {
for ; i > -1; i-- {
s := segments[i]
if err := s.Delete(); err != nil {
return nil, err
}
}
}
}
return cleanedSegments, nil
}
函数的详细步骤说明如下:
- 如果传入的日志文件段切片segments为空,或者DeleteCleaner的保留字节属性(c.Retention.Bytes)被设置为-1,函数将不执行删除操作,并原样返回这些段。
- 清理操作的起点是日志文件段的尾部,也就是最新的段,并逐片向前工作,直到达到指定的数据保留大小c.Retention.Bytes。
- 初始化一个新切片cleanedSegments,包含最后一个segments项,即最新的段文件。
- 变量totalBytes被设定为这最后一个段文件当前的字节位置Position。
- 如果segments包含多于一个文件段,函数会从倒数第二个文件段开始向前遍历:
-
- 对于每个文件段s,将该文件段的Position加到totalBytes累积字节总量上。
- 如果累积的totalBytes超出了保留字节大小c.Retention.Bytes,则停止添加更多段到cleanedSegments切片。
- 否则,将该段添加到cleanedSegments切片的开头。
- 如果在完成上述过程后仍有未遍历的文件段,那么对于这些文件段:
-
- 会调用文件段s的Delete方法,试图删除它们。
- 如果在删除过程中遇到错误,将返回nil和相应的错误。
- 当所有超出保留大小c.Retention.Bytes的文件段都处理完毕后,函数返回已清理并符合保留策略的文件段的数组cleanedSegments。
CompactCleaner的clean方法如下:
go
func (c *CompactCleaner) Clean(segments []*Segment) (cleaned []*Segment, err error) {
if len(segments) == 0 {
return segments, nil
}
var ss *SegmentScanner
var ms MessageSet
var offset int64
for _, segment := range segments {
ss = NewSegmentScanner(segment)
for ms, err = ss.Scan(); err == nil; ms, err = ss.Scan() {
offset = ms.Offset()
for _, msg := range ms.Messages() {
//
c.m[Hash(msg.Key())] = offset
}
}
}
for _, ds := range segments {
ss = NewSegmentScanner(ds)
cs, err := NewSegment(ds.path, ds.BaseOffset, ds.maxBytes, cleanedSuffix)
if err != nil {
return nil, err
}
for ms, err = ss.Scan(); err == nil; ms, err = ss.Scan() {
var retain bool
offset = ms.Offset()
for _, msg := range ms.Messages() {
if c.m[Hash(msg.Key())] <= offset {
retain = true
}
}
if retain {
if _, err = cs.Write(ms); err != nil {
return nil, err
}
}
}
if err = cs.Replace(ds); err != nil {
return nil, err
}
cleaned = append(cleaned, cs)
}
return cleaned, nil
}
函数的详细步骤如下:
- 检查段是否为空:如果 segments 为空,即没有要处理的数据段,则直接返回原始的 segments 切片和 nil 错误。
- 构建键到最新偏移量的映射:对于传入的所有数据段 segments,使用一个叫 SegmentScanner 的辅助对象来扫描每个段(Segment)中的消息集合(MessageSet)。通过扫描,它会更新一个内部映射 c.m,该映射保存了每个消息键的最新偏移量。每次遇到一个键时,都会使用该键的哈希值作为映射的键,并将其偏移量更新为当前的偏移量。
- 然后处理各个数据段:方法开始处理每一个数据段 ds。
-
- 对于每个数据段,它创建一个新的 SegmentScanner 来扫描段中的消息。
- 同时,它使用原始数据段路径 ds.path,基础偏移量 ds.BaseOffset,最大字节限制 ds.maxBytes,以及清理后缀 cleanedSuffix 来创建一个新的数据段 cs,这是用于存储清理后数据的新段。
- 决定保留哪些消息:对于当前数据段 ds 中的每个消息,清理策略会检查其消息键在映射 c.m 中的最新偏移量是否小于等于当前消息的偏移量。如果是,说明这个消息是该键的最后一条消息(或者只有一条),应该被保留。
- 写入保留消息:保留的消息将被写入新创建的数据段 cs。
- 替换原始数据段:完成写入后,新的数据段 cs 将替换掉旧的数据段 ds。
- 加入清理后的数据段:新创建并填充了数据的段 cs 会被加入到 cleaned 切片中,这个切片最终会包含所有清理后的数据段。
- 返回清理后的数据段和错误:方法的最终结果是一个包含了所有经过清理的数据段的切片 cleaned,如果过程中没有错误发生,则错误 err 为 nil