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,压缩成功后再删掉未压缩文件
|
|
|
|
|
|
|
|
|
|
|
|
// 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))
|
2026-09-14 00:14:30 +08:00
|
|
|
|
// 占用判断必须同时看 .log 和 .log.gz:
|
|
|
|
|
|
// 压缩成功后只剩 .gz,只看 .log 会以为序号空着,
|
|
|
|
|
|
// 于是归档名撞上已有归档,后面 gzip 重命名会把老归档覆盖掉
|
|
|
|
|
|
if indexInUse(base, idx) {
|
|
|
|
|
|
continue
|
2026-09-13 21:53:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
if err := os.Rename(fileName, candidate); err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("归档日志文件失败 %s: %w", fileName, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return candidate, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", fmt.Errorf("归档日志文件失败 %s: 找不到可用序号", fileName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 00:14:30 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 21:53:37 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 00:14:30 +08:00
|
|
|
|
// gzipTempSuffix 压缩临时文件后缀
|
|
|
|
|
|
const gzipTempSuffix = ".gz.tmp"
|
|
|
|
|
|
|
2026-09-13 21:53:37 +08:00
|
|
|
|
// 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"
|
2026-09-14 00:14:30 +08:00
|
|
|
|
// 绝不覆盖已有归档:序号复用或并发写入时,覆盖等于直接销毁历史日志
|
|
|
|
|
|
if _, err := os.Stat(dst); err == nil {
|
|
|
|
|
|
_ = os.Remove(tmp)
|
|
|
|
|
|
return fmt.Errorf("归档目标已存在,拒绝覆盖: %s", dst)
|
|
|
|
|
|
}
|
2026-09-13 21:53:37 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|