Files
Havline/internal/logstore/rotate.go
T

192 lines
5.8 KiB
Go
Raw Normal View History

package logstore
import (
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Nginx 自己不会轮转日志,长期运行的 NAS 上 access.log 会一直长。
// 这里的策略刻意保持简单:
// - 超过 MaxBytes 才轮转,避免一天切一堆小文件;
// - 轮转时把 access.log 改名成 access-<时间戳>.log.gz,再让 Nginx 重开文件;
// - 保留最近 Keep 份,更旧的删掉。
//
// 用「rename + 信号重开」而不是 truncate:truncate 会丢掉读侧(流量采集)还没读到的内容。
const (
// DefaultMaxBytes 单个日志文件的上限(200MB)。NAS 上反代日志增长很快,
// 太大则读取接口变慢,太小则轮转频繁、压缩开销占比升高。
DefaultMaxBytes int64 = 200 << 20
// DefaultKeep 每个日志保留的归档份数。
DefaultKeep = 5
)
// Rotator 负责单个日志文件的轮转与清理。
type Rotator struct {
// Path 当前正在写入的日志路径(例如 <DataDir>/logs/access.log)
Path string
// MaxBytes 超过它就轮转;<= 0 用 DefaultMaxBytes
MaxBytes int64
// Keep 保留份数;<= 0 用 DefaultKeep
Keep int
// MaxAgeDays 归档保留天数;<= 0 表示不按时间清理(只按份数)。
// 对应「设置 → 常规 → 日志保留天数」,填了才生效。
MaxAgeDays int
// reopen 通知写日志的进程重新打开文件(Nginx 用 USR1)
reopen func() error
}
// NewRotator 装配一个日志轮转器。reopen 用于通知写日志的进程重新打开文件
// (Nginx 用 USR1);传 nil 表示该日志由本进程自己写,不需要重开。
func NewRotator(path string, maxAgeDays int, reopen func() error) *Rotator {
return &Rotator{Path: path, MaxAgeDays: maxAgeDays, reopen: reopen}
}
// RotateIfNeeded 在文件超过阈值时轮转,返回是否真的轮转了。
// 未超阈值时不产生任何副作用,可以安全地高频调用。
func (r *Rotator) RotateIfNeeded(now time.Time) (bool, error) {
info, err := os.Stat(r.Path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
limit := r.MaxBytes
if limit <= 0 {
limit = DefaultMaxBytes
}
if info.Size() < limit {
return false, nil
}
target := r.archivePath(now)
if err := compressRename(r.Path, target); err != nil {
return false, err
}
// 文件被改名后,Nginx 仍持有旧 inode 继续往里写。必须让它重开,
// 否则新内容会写进已归档的那份文件,轮转等于没做。
if r.reopen != nil {
if err := r.reopen(); err != nil {
return true, fmt.Errorf("日志已归档,但通知重开失败:%w", err)
}
}
// 归档成功后再清理旧份数:清理失败不该让本轮轮转算失败
r.prune()
return true, nil
}
// archivePath 生成归档名:access.log → access-20261024-1305.log.gz。
// 时间戳精确到分钟,同一分钟内重复轮转时补序号,避免覆盖。
func (r *Rotator) archivePath(now time.Time) string {
dir := filepath.Dir(r.Path)
base := strings.TrimSuffix(filepath.Base(r.Path), ".log")
stamp := now.Format("20060102-1504")
candidate := filepath.Join(dir, fmt.Sprintf("%s-%s.log.gz", base, stamp))
for i := 2; ; i++ {
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
candidate = filepath.Join(dir, fmt.Sprintf("%s-%s-%d.log.gz", base, stamp, i))
}
}
// prune 先按保留天数删超期归档,再按份数保留最近的。
// 两个条件都生效:天数保证「长期不轮转也不会无限留」,份数保证「高频轮转不会堆积」。
func (r *Rotator) prune() {
archives := r.archives()
if r.MaxAgeDays > 0 {
cutoff := time.Now().Add(-time.Duration(r.MaxAgeDays) * 24 * time.Hour)
kept := archives[:0]
for _, path := range archives {
info, err := os.Stat(path)
if err == nil && info.ModTime().Before(cutoff) {
_ = os.Remove(path)
continue
}
kept = append(kept, path)
}
archives = kept
}
keep := r.Keep
if keep <= 0 {
keep = DefaultKeep
}
for i := 0; i < len(archives)-keep; i++ {
_ = os.Remove(archives[i])
}
}
// archives 列出该日志的归档文件,按旧→新排序。
func (r *Rotator) archives() []string {
dir := filepath.Dir(r.Path)
base := strings.TrimSuffix(filepath.Base(r.Path), ".log")
prefix := base + "-"
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
out := make([]string, 0, len(entries))
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".log.gz") {
continue
}
out = append(out, filepath.Join(dir, name))
}
sort.Strings(out)
return out
}
// compressRename 把 src 压缩写到 dst,成功后删除 src。
// 先写 .tmp 再改名:压缩中途失败不会留下一个看起来能用的归档。
func compressRename(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
tmp := dst + ".tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
if err != nil {
_ = in.Close()
return err
}
gz := gzip.NewWriter(out)
if _, err := io.Copy(gz, in); err != nil {
_ = gz.Close()
_ = out.Close()
_ = in.Close()
_ = os.Remove(tmp)
return err
}
// 关闭顺序与错误都要检查:gzip 的尾部写在 Close 里,漏了就是半截归档
if err := gz.Close(); err != nil {
_ = out.Close()
_ = in.Close()
_ = os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
_ = in.Close()
_ = os.Remove(tmp)
return err
}
// 必须显式关闭源文件再删:Windows 上句柄没放掉时 Remove/改名都会失败
if err := in.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Rename(tmp, dst); err != nil {
_ = os.Remove(tmp)
return err
}
// 归档成功才删原文件;顺序反了会在压缩失败时丢日志
return os.Remove(src)
}