Как эффективно прочитать последние N строк из большого лог-файла в Go?

Ответ

Чтение последних строк из файла — частая задача, особенно при работе с логами. Существует два основных подхода: неэффективный, но простой, и эффективный, но более сложный.

1. Неэффективный подход: Чтение всего файла

Этот метод заключается в чтении всего файла построчно и хранении последних N строк в памяти. Он прост в реализации, но потребляет много памяти и времени для больших файлов.

// Подходит только для небольших файлов!
func readLastLinesNaive(filename string, n int) ([]string, error) {
    file, err := os.Open(filename)
    if err != nil {
        return nil, err
    }
    defer file.Close()

    scanner := bufio.NewScanner(file)
    var lines []string
    for scanner.Scan() {
        lines = append(lines, scanner.Text())
        // Когда буфер превышает N, удаляем самый старый элемент
        if len(lines) > n {
            lines = lines[1:]
        }
    }
    return lines, scanner.Err()
}

2. Эффективный подход: Чтение с конца файла (Seek)

Этот метод является предпочтительным для больших файлов. Идея состоит в том, чтобы переместить курсор в конец файла (os.SeekEnd) и читать файл небольшими блоками в обратном направлении, подсчитывая символы новой строки (n).

import (
    "bufio"
    "bytes"
    "io"
    "os"
)

// Эффективно читает последние N строк
func readLastLines(filename string, n int) ([]string, error) {
    file, err := os.Open(filename)
    if err != nil {
        return nil, err
    }
    defer file.Close()

    // Получаем информацию о файле, чтобы узнать его размер
    stat, err := file.Stat()
    if err != nil {
        return nil, err
    }

    lines := make([]string, 0, n)
    var lineCount int
    var buffer bytes.Buffer

    // Читаем файл с конца блоками по 1024 байта
    const chunkSize = 1024
    for offset := stat.Size(); offset > 0 && lineCount < n; {
        readSize := int64(chunkSize)
        if offset < readSize {
            readSize = offset
        }
        offset -= readSize

        chunk := make([]byte, readSize)
        _, err := file.ReadAt(chunk, offset)
        if err != nil && err != io.EOF {
            return nil, err
        }

        // Ищем переносы строк в прочитанном блоке
        for i := len(chunk) - 1; i >= 0; i-- {
            if chunk[i] == 'n' {
                // Переворачиваем буфер, чтобы получить строку в правильном порядке
                lineBytes := buffer.Bytes()
                for j, k := 0, len(lineBytes)-1; j < k; j, k = j+1, k-1 {
                    lineBytes[j], lineBytes[k] = lineBytes[k], lineBytes[j]
                }
                lines = append(lines, string(lineBytes))
                buffer.Reset()
                lineCount++
                if lineCount >= n {
                    break
                }
            } else {
                buffer.WriteByte(chunk[i])
            }
        }
    }

    // Не забываем про самую первую строку файла, если она попала в буфер
    if lineCount < n && buffer.Len() > 0 {
        lineBytes := buffer.Bytes()
        for j, k := 0, len(lineBytes)-1; j < k; j, k = j+1, k-1 {
            lineBytes[j], lineBytes[k] = lineBytes[k], lineBytes[j]
        }
        lines = append(lines, string(lineBytes))
    }

    // Переворачиваем итоговый срез, чтобы строки были в хронологическом порядке
    for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
        lines[i], lines[j] = lines[j], lines[i]
    }

    return lines, nil
}

Вывод: Для производственного кода всегда используйте второй, эффективный подход, так как он не зависит от размера файла и имеет предсказуемое потребление памяти.