Files
loggerx/compress.go
2026-09-14 00:14:30 +08:00

276 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}