Files
loggerx/compress.go
T

265 lines
7.5 KiB
Go
Raw Normal View History

2026-09-13 21:53:37 +08:00
package loggerx
import (
"compress/gzip"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// 按大小切割后的归档与压缩
//
// 滚动流程(全过程不阻塞写入):
// 1. 关掉当前句柄 —— 关句柄会把缓冲清空,归档文件因此是完整的
// 2. 把文件改名成 名字_N.log(先改名,保证任何时刻文件都存在,崩溃也不丢数据)
// 3. 交给后台协程压缩成 名字_N.log.gz,压缩成功后再删掉未压缩文件
// nextIndex 返回下一个归档序号
// 首次调用会先扫描目录里已有的 _N.log / _N.log.gz,避免覆盖上次运行的归档
func (l *Logger) nextIndex() int {
l.idxOnce.Do(func() {
l.idx = l.maxIndexOnDisk()
})
l.idxMu.Lock()
defer l.idxMu.Unlock()
l.idx++
return l.idx
}
// maxIndexOnDisk 扫描日志目录,找出已经用掉的最大序号
func (l *Logger) maxIndexOnDisk() int {
max := 0
_ = filepath.Walk(l.option.dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
name := info.Name()
name = strings.TrimSuffix(name, ".gz")
if !strings.HasSuffix(name, ".log") {
return nil
}
// 形如 2026-09-13_info_3.log
base := strings.TrimSuffix(name, ".log")
idx := strings.LastIndexByte(base, '_')
if idx < 0 || idx == len(base)-1 {
return nil
}
n, cerr := strconv.Atoi(base[idx+1:])
if cerr != nil {
return nil
}
if n > max {
max = n
}
return nil
})
return max
}
// archive 把日志文件改名成带序号的归档名,返回改名后的路径
// 调用方必须已经关闭该文件
//
// base 由调用方传入(日志句柄记下的规范基名),不在这里反推:
// 按小时切割的文件名本身就带 _数字(2026/09/13/06_info.log),
// 靠正则去猜尾部的 _N 是不是归档序号会误判,把正常日志当成归档
func (l *Logger) archive(base, fileName string) (string, error) {
dir := filepath.Dir(fileName)
base = filepath.Base(strings.TrimSuffix(base, ".log"))
for try := 0; try < 1000; try++ {
idx := l.nextIndex()
candidate := filepath.Join(dir, fmt.Sprintf("%s_%d.log", base, idx))
if _, err := os.Stat(candidate); err == nil {
continue // 已存在,换一个序号
}
if err := os.Rename(fileName, candidate); err != nil {
return "", fmt.Errorf("归档日志文件失败 %s: %w", fileName, err)
}
return candidate, nil
}
return "", fmt.Errorf("归档日志文件失败 %s: 找不到可用序号", fileName)
}
// scheduleCompress 把压缩任务丢到后台,不阻塞写入路径
// 不压缩时什么也不做,归档文件保留为 .log
func (l *Logger) scheduleCompress(path string) {
if !l.option.compress {
return
}
l.compWg.Add(1)
go func() {
defer l.compWg.Done()
if err := gzipFile(path, l.option.compressLvl); err != nil {
// 压缩失败就保留原始 .log,不影响数据可用性
log.Println("loggerx: 压缩日志文件失败:", path, err)
}
}()
}
// gzipFile 把 src 压缩成 src.gz,成功后删除 src
// 先写临时文件再改名,避免半截 .gz 被当成完整归档
func gzipFile(src string, level int) error {
in, err := os.Open(src)
if err != nil {
return err
}
// 注意:读完必须显式 Close,不能只靠 defer ——
// Windows 上「句柄还开着」会导致后面 os.Remove(src) 失败,
// 结果就是 .log 与 .log.gz 长期成对存在、磁盘占用翻倍
closeIn := func() {
_ = in.Close()
}
tmp := src + ".gz.tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
closeIn()
return err
}
zw, err := gzip.NewWriterLevel(out, level)
if err != nil {
closeIn()
_ = out.Close()
_ = os.Remove(tmp)
return err
}
if _, err := io.Copy(zw, in); err != nil {
closeIn()
_ = zw.Close()
_ = out.Close()
_ = os.Remove(tmp)
return err
}
if err := zw.Close(); err != nil {
closeIn()
_ = out.Close()
_ = os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
closeIn()
_ = os.Remove(tmp)
return err
}
closeIn() // 删除源文件之前一定要先释放读句柄
dst := src + ".gz"
if err := os.Rename(tmp, dst); err != nil {
_ = os.Remove(tmp)
return err
}
// 压缩成功后删除原始文件。
// Windows 上删除会因「别人短暂持有句柄」而失败(杀毒、索引、外部 tail 等),
// 这类都是瞬时的,退避重试几次基本都能成功;真的删不掉也不影响数据可用性,
// 只是会多占一份未压缩磁盘(下次压缩同目录时不会自动清理,需要人工/日志关注)
if err := removeWithRetry(src, 7); err != nil {
log.Println("loggerx: 压缩完成但无法删除原文件(已重试):", src, err)
}
return nil
}
// healCompressedLeftovers 清理「归档既存在 .gz 又存在 .log」的冗余文件
//
// gzipFile 正常情况下压缩成功后会删掉源文件;但如果当时被别的进程占用(杀毒、
// 索引、外部 tail)且重试也没成功,就会留下成对的 .log 与 .log.gz,白占一份磁盘。
// 清理协程每轮跑一次这个函数,把这类冗余的 .log 删掉。
//
// 安全性:只在 .gz 通过完整性校验(gzip 尾部合法)时才删 .log,
// 避免删掉一个损坏压缩包唯一的可读副本
func (l *Logger) healCompressedLeftovers() error {
if !l.option.compress {
return nil
}
var errs []error
_ = filepath.Walk(l.option.dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".gz") {
return nil
}
src := strings.TrimSuffix(path, ".gz")
if _, serr := os.Stat(src); serr != nil {
return nil // 源文件已经清掉了,正常情况
}
if !validGzip(path) {
// 压缩包不完整:保留 .log,别把唯一可读副本删了
fmt.Println("loggerx: 归档文件损坏,保留未压缩文件:", path)
return nil
}
if rerr := removeWithRetry(src, 2); rerr != nil {
errs = append(errs, rerr)
return nil
}
fmt.Println("清理冗余未压缩日志", src)
return nil
})
return joinErrors(errs)
}
// validGzip 校验 .gz 是否是完整的 gzip 流
// 做法是把整个文件读完:gzip 包尾 CRC 不对时 ReadAll 会报错
func validGzip(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
zr, err := gzip.NewReader(f)
if err != nil {
return false
}
defer zr.Close()
_, err = io.Copy(io.Discard, zr)
return err == nil
}
// removeWithRetry 删除文件,失败时短暂退避重试
// 只对「文件仍被占用」这类瞬时错误有意义,重试到位就放弃
func removeWithRetry(path string, tries int) error {
var err error
delay := 20 * time.Millisecond
for i := 0; i < tries; i++ {
if err = os.Remove(path); err == nil {
return nil
}
if !isTransientRemoveErr(err) {
return err // 权限等问题重试也没用
}
time.Sleep(delay)
if delay < 500*time.Millisecond {
delay *= 2
}
}
return err
}
// isTransientRemoveErr 判断删除失败是否属于「文件被占用」这类可重试错误
// 不依赖平台特有错误码,用错误文本做保守判断:匹配不上就当不可重试
func isTransientRemoveErr(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
for _, kw := range []string{
"being used by another process", // Windows: 文件被占用
"used by another process",
"sharing violation",
"resource busy",
"text file busy", // Linux
} {
if strings.Contains(msg, kw) {
return true
}
}
return false
}