package loggerx import ( "compress/gzip" "fmt" "io" "log" "os" "path/filepath" "strconv" "strings" "time" ) // 按大小切割后的归档与压缩 // // 滚动流程(全过程不阻塞写入): // 1. 关掉当前句柄 —— 关句柄会把缓冲清空,归档文件因此是完整的 // 2. 把文件改名成 名字_N.log(先改名,保证任何时刻文件都存在,崩溃也不丢数据) // 3. 交给后台协程压缩成 名字_N.log.gz,压缩成功后再删掉未压缩文件 // 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)) // 占用判断必须同时看 .log 和 .log.gz: // 压缩成功后只剩 .gz,只看 .log 会以为序号空着, // 于是归档名撞上已有归档,后面 gzip 重命名会把老归档覆盖掉 if indexInUse(base, idx) { continue } if err := os.Rename(fileName, candidate); err != nil { return "", fmt.Errorf("归档日志文件失败 %s: %w", fileName, err) } return candidate, nil } return "", fmt.Errorf("归档日志文件失败 %s: 找不到可用序号", fileName) } // indexInUse 序号 idx 是否已被占用(.log 或 .log.gz 任一存在即算占用) func indexInUse(base string, idx int) bool { name := fmt.Sprintf("%s_%d.log", strings.TrimSuffix(base, ".log"), idx) if _, err := os.Stat(name); err == nil { return true } if _, err := os.Stat(name + ".gz"); err == nil { return true } return false } // 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) } }() } // gzipTempSuffix 压缩临时文件后缀 const gzipTempSuffix = ".gz.tmp" // 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.Stat(dst); err == nil { _ = os.Remove(tmp) return fmt.Errorf("归档目标已存在,拒绝覆盖: %s", dst) } 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 }