添加自动文件压缩的实现

This commit is contained in:
yun
2026-09-13 21:53:37 +08:00
parent ab11fa71a0
commit 316c6420a7
21 changed files with 2037 additions and 66 deletions
+9 -1
View File
@@ -1 +1,9 @@
log
log
# 测试/基准跑出来的日志文件(默认目录是 ./log,包级 logger 与部分测试会落到仓库根)
20??-*.log
20??-*.log.gz
*.log.gz.tmp
# 本地构建缓存
.tmp-gocache
+264
View File
@@ -0,0 +1,264 @@
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
}
+156 -12
View File
@@ -6,11 +6,15 @@ import (
"io"
"os"
"path/filepath"
"strings"
"time"
)
// 文件操作
// errClosed 日志实例已关闭,不再接受新的文件写入
var errClosed = errors.New("loggerx: 日志已关闭")
// 每个文件的写缓冲大小:越大越省系统调用,代价是崩溃时可能丢最后一批日志
const fileBufSize = 32 * 1024
@@ -18,10 +22,17 @@ const fileBufSize = 32 * 1024
// 自己管理写缓冲(不用 bufio):bufio.Writer 在底层短写时会永久置位内部错误状态,
// 之后所有写入和 Flush 都会失败,已攒下的一批日志会整批丢掉
type logFile struct {
file *os.File
buf []byte
pending int
file *os.File
buf []byte
pending int
// written 已写进文件的字节数(内存计数)
// 按大小切割要判断是否该滚动:每条日志都 Stat 一次要花约 8µs,
// 而写了多少字节自己最清楚,只在打开文件时 Stat 一次做基准即可
written int64
fileName string
// baseName 是 fileNameIn 的结果(不带大小切割的 _N 序号),
// 用于判断是否跨了时间切割边界,以及归档时取规范基名
baseName string
}
// Write 写入缓冲,满了就落盘一次
@@ -41,6 +52,29 @@ func (f *logFile) Write(b []byte) (int, error) {
return written, nil
}
// sizeOnDisk 当前文件实际占用的字节数(内存计数,不是每次 Stat)
func (f *logFile) sizeOnDisk() int64 {
return f.written
}
// initSize 打开文件后记录一次真实大小作为计数基准(追加打开时文件可能非空)
func (f *logFile) initSize() {
if f.file == nil {
return
}
if st, err := f.file.Stat(); err == nil {
f.written = st.Size()
}
}
// full 加上即将写入的 n 字节后是否会超过上限
func (f *logFile) full(limit, n int) bool {
if limit <= 0 || f.file == nil {
return false
}
return f.written+int64(f.pending)+int64(n) > int64(limit)
}
// writeFull 保证把整块数据写进文件(允许底层短写,循环补齐)
func (f *logFile) writeFull(p []byte) error {
for len(p) > 0 {
@@ -65,10 +99,18 @@ func (f *logFile) Flush() error {
// 保留未写成功的部分,下次继续
return err
}
f.written += int64(f.pending)
f.pending = 0
return nil
}
// hasPending 缓冲里还有没有未落盘的数据
// 定时刷盘靠它跳过空闲文件:否则每次 tick 都会对所有句柄做一次 Sync(fsync),
// 高频间隔下光 fsync 就能把吞吐拖垮
func (f *logFile) hasPending() bool {
return f != nil && f.pending > 0
}
// Sync 把缓冲刷到文件并 fsync
func (f *logFile) Sync() error {
var errs []error
@@ -104,6 +146,14 @@ func joinErrors(errs []error) error {
// 获取最新的文件名
func (l *Logger) nowFileName(event string) string {
return l.fileNameIn(l.channel, event)
}
// fileNameIn 按指定 channel 计算文件名
// channel 由调用方传入而不是取 l.channel:异步消费协程用的是「创建队列时的那个
// logger」,它的 channel 是空的,必须按任务里记的 channel 落盘
// (否则所有 channel 的日志都会混进根目录文件)
func (l *Logger) fileNameIn(channel, event string) string {
prefix := ""
switch l.option.fileSplit {
@@ -128,15 +178,20 @@ func (l *Logger) nowFileName(event string) string {
prefix = prefix + "_"
}
if l.channel != "" {
prefix = l.channel + "/" + prefix
if channel != "" {
prefix = channel + "/" + prefix
}
return l.option.dir + "/" + prefix + event + ".log"
}
// 新建文件
func (l *Logger) getFile(event string) (*logFile, error) {
key := fileKey{channel: l.channel, event: event}
return l.getFileTo(l.channel, event)
}
// getFileTo 取指定 channel 的文件句柄
func (l *Logger) getFileTo(channel, event string) (*logFile, error) {
key := fileKey{channel: channel, event: event}
if f := l.loadFile(key); f != nil {
return f, nil
@@ -150,9 +205,47 @@ func (l *Logger) getFile(event string) (*logFile, error) {
return f, nil
}
fileName := l.nowFileName(event)
// 已经关闭:不再新开句柄,否则没人负责关它
if l.closed.Load() {
return nil, errClosed
}
lf, err := l.newFile(key)
if err != nil {
return nil, err
}
l.filePath[key] = lf
return lf, nil
}
// newFile 打开新文件;如果目标文件已经超过大小上限,就先把旧文件归档再开新序号
func (l *Logger) newFile(key fileKey) (*logFile, error) {
limit := l.option.sizeSplit
lf, err := l.openNewFile(key)
if err != nil {
return nil, err
}
if limit <= 0 || lf.sizeOnDisk() <= int64(limit) {
return lf, nil
}
// 上一轮运行留下的文件已经写满(进程重启场景):归档它,再开一个新序号
_ = lf.file.Close()
path, aerr := l.archive(lf.baseName, lf.fileName)
if aerr != nil {
return nil, aerr
}
l.scheduleCompress(path)
return l.openNewFileIndexed(key)
}
// openNewFile 打开不带序号的当日文件
func (l *Logger) openNewFile(key fileKey) (*logFile, error) {
fileName := l.fileNameIn(key.channel, key.event)
// 识别目录与文件,创建多层目录(已存在不报错)
if dir := filepath.Dir(fileName); dir != "" {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
@@ -168,12 +261,62 @@ func (l *Logger) getFile(event string) (*logFile, error) {
file: file,
buf: make([]byte, fileBufSize),
fileName: fileName,
baseName: fileName,
}
l.filePath[key] = lf
lf.initSize()
return lf, nil
}
// openNewFileIndexed 开一个全新的序号文件
// 序号永远递增且跳过已存在的文件:绝不复用别人的归档
// (复用后再归档会得到 名字_1_2.log 这种链式文件名,旧内容也可能被追加写坏)
func (l *Logger) openNewFileIndexed(key fileKey) (*logFile, error) {
base := l.fileNameIn(key.channel, key.event)
for try := 0; try < 1000; try++ {
idx := l.nextIndex()
if _, err := os.Stat(numberedName(base, idx)); err == nil {
continue // 序号已被占用,换下一个
}
return l.openNumberedFile(key, idx)
}
return nil, fmt.Errorf("无法为 %s 找到可用的归档序号", key.event)
}
// openFileWithIndex 打开 「基名_序号.log」
func (l *Logger) openFileWithIndex(key fileKey, idx int) (*logFile, error) {
base := l.fileNameIn(key.channel, key.event)
fileName := numberedName(base, idx)
if dir := filepath.Dir(fileName); dir != "" {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
}
file, err := os.OpenFile(fileName, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("打开日志文件失败 %s: %w", fileName, err)
}
lf := &logFile{
file: file,
buf: make([]byte, fileBufSize),
fileName: fileName,
baseName: base,
}
lf.initSize()
return lf, nil
}
// numberedName 由基名和序号拼出归档文件名
// 例:./log/2026-09-13_info.log + 3 -> ./log/2026-09-13_info_3.log
func numberedName(base string, idx int) string {
return fmt.Sprintf("%s_%d.log", strings.TrimSuffix(base, ".log"), idx)
}
// openNumberedFile 打开指定序号文件,序号由调用方保证未被占用
func (l *Logger) openNumberedFile(key fileKey, idx int) (*logFile, error) {
return l.openFileWithIndex(key, idx)
}
// 加载文件(读锁)
func (l *Logger) loadFile(key fileKey) *logFile {
l.mu.RLock()
@@ -187,8 +330,9 @@ func (l *Logger) loadFileLocked(key fileKey) *logFile {
if !ok || f == nil {
return nil
}
// 时间切割导致文件名变化:关闭旧文件,让调用方按新名字重建
if f.fileName != l.nowFileName(key.event) {
// 时间切割导致文件名变化:关闭旧文件,让调用方按新名字重建
// 注意比的是不带序号的基名,否则按大小切割出的 _N 文件会被误判成跨天
if f.baseName != l.fileNameIn(key.channel, key.event) {
delete(l.filePath, key)
f.Close()
return nil
+14 -4
View File
@@ -12,6 +12,19 @@ import (
"time"
)
// basePath 进程工作目录(用于把绝对路径裁成相对路径)
// 只算一次并缓存:filepath.Abs 每次都要走系统调用,而它每条日志都会被用到
func (l *Logger) basePath() string {
l.basePathOnce.Do(func() {
p, err := filepath.Abs("./")
if err != nil {
p = ""
}
l.basePathVal = strings.ReplaceAll(p, "\\", "/")
})
return l.basePathVal
}
func (l *Logger) logger(ctx context.Context, event string, v ...any) {
// 调用方可能是 log 包(Logger.Write -> logger),所以这里取第 2 层
pc, file, line, ok := runtime.Caller(2)
@@ -21,10 +34,7 @@ func (l *Logger) logger(ctx context.Context, event string, v ...any) {
if fn := runtime.FuncForPC(pc); fn != nil {
funcName = strings.TrimPrefix(filepath.Ext(fn.Name()), ".")
}
if basePath, err := filepath.Abs("./"); err == nil {
basePath = strings.ReplaceAll(basePath, "\\", "/")
file = strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), basePath)
}
file = strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), l.basePath())
}
nowTime := time.Now().In(l.option.timeZone).Format("2006-01-02 15:04:05.000000")
+106 -19
View File
@@ -11,6 +11,8 @@ import (
"log"
"os"
"sync"
"sync/atomic"
"time"
// "sync_log/global"
@@ -28,6 +30,7 @@ type Logger struct {
writeType writeType // 是否异步落盘,这里作用范围是本条,优先判断这里
closeOnce *sync.Once
closed *atomic.Bool // 是否已经关闭:关闭后再写不再新开句柄
done chan struct{} // 通用关闭信号(delete / ctx 相关 goroutine 用)
workerDone chan struct{} // 异步消费 goroutine 退出信号
closing chan struct{} // 异步投递关闭信号:只关一次
@@ -36,6 +39,16 @@ type Logger struct {
// 浅拷贝,如果状态直接放在 Logger 里,拷贝出的实例就会各看各的
// (曾经因此让 Close 看不到真正的队列,白等一场、最后一条日志丢失)
async *asyncCtl
// basePath 缓存(见 format.go basePath),避免每条日志都算一次 filepath.Abs
basePathOnce *sync.Once
basePathVal string
// 归档压缩相关状态(同样挂在指针后面供拷贝共享)
idxOnce *sync.Once // 归档序号只从目录里初始化一次
idxMu *sync.Mutex // 保护 idx
idx int // 归档序号
compWg *sync.WaitGroup // 后台压缩协程计数,Close 需要等它们收尾
}
// asyncCtl 异步队列的共享控制块:所有拷贝共享同一份
@@ -43,6 +56,17 @@ type asyncCtl struct {
mu sync.Mutex
ch chan cacheData
closed bool // 只能读写于 mu 保护下
wg sync.WaitGroup
}
// pendingAsync 记录「已通过投递检查、正在往队列里放」的写入数量。
// 调用方必须持有 mu,Done 必须紧跟在 mu 解锁之后(见 toAsync)
func (c *asyncCtl) begin() {
c.wg.Add(1)
}
func (c *asyncCtl) end() {
c.wg.Done()
}
// fileKey 文件缓存的键
@@ -65,16 +89,21 @@ func NewLogger(ctx context.Context, opts ...Option) *Logger {
}
l := &Logger{
ctx: ctx,
filePath: make(map[fileKey]*logFile),
mu: &sync.RWMutex{},
writeMu: &sync.Mutex{},
option: opt,
writeType: writeTypeDefault,
closeOnce: &sync.Once{},
done: make(chan struct{}),
workerDone: make(chan struct{}),
async: &asyncCtl{},
ctx: ctx,
filePath: make(map[fileKey]*logFile),
mu: &sync.RWMutex{},
writeMu: &sync.Mutex{},
option: opt,
writeType: writeTypeDefault,
closeOnce: &sync.Once{},
closed: &atomic.Bool{},
done: make(chan struct{}),
workerDone: make(chan struct{}),
async: &asyncCtl{},
basePathOnce: &sync.Once{},
idxOnce: &sync.Once{},
idxMu: &sync.Mutex{},
compWg: &sync.WaitGroup{},
}
log.SetOutput(l)
@@ -89,6 +118,11 @@ func NewLogger(ctx context.Context, opts ...Option) *Logger {
// 日志删除
go l.delete()
// 定时刷盘
if opt.flushEvery > 0 {
go l.flushLoop()
}
// 强制刷盘
// 用 done 而不是只监听 ctxctx 为 Background 时 Close 也要能把 goroutine 收回去
go func() {
@@ -102,30 +136,78 @@ func NewLogger(ctx context.Context, opts ...Option) *Logger {
return l
}
// flushTick 定时刷盘的检查频率
// 实际刷盘间隔由 SetFlushInterval 决定;这里用固定的小 tick 做检查,
// 这样即使调用方设了很小的间隔,也不会因为「每个 tick 都抢全局写锁」而拖垮吞吐。
// 写锁是全局的,高频抢锁会直接顶住所有写入方
const flushTick = 50 * time.Millisecond
// flushLoop 定时把内存缓冲刷到磁盘
// 作用是把「进程崩溃时可能丢的数据量」从「最多一整个 32KB 缓冲」
// 缩小到「一个刷盘间隔内产生的日志量」
func (l *Logger) flushLoop() {
tick := time.NewTicker(flushTick)
defer tick.Stop()
last := time.Now()
for {
select {
case <-tick.C:
interval := l.option.flushEvery
if interval <= 0 {
continue
}
if now := time.Now(); now.Sub(last) >= interval {
last = now
// 单个文件刷盘失败不应该让这个循环退出
if err := l.MustSync(); err != nil {
log.Println("loggerx: 定时刷盘失败:", err)
}
}
case <-l.done:
return
}
}
}
// 关闭日志:关闭异步队列、落盘并关闭所有文件句柄
// 重复调用安全,调用后本实例的后台 goroutine 会全部退出
func (l *Logger) Close() error {
var err error
l.closeOnce.Do(func() {
// 1. 停投递 + 等消费协程把队列里已入队的任务全部写完
// 1. 停投递 + 等消费协程把队列里已入队的任务全部写完
// 这一步必须在「封盘」之前:队列里可能还有别的 channel / event 的任务,
// 它们需要新开自己的文件句柄。如果提前封盘,这些任务会直接写失败被丢掉
l.drainAsync()
// 2. 在写锁内完成最后一次刷盘与关闭
// 此后不会再有写入(投递已关闭,同步写入也要先抢到这把锁)
l.writeMu.Lock()
syncErr := l.MustSync()
closeErr := l.close()
l.writeMu.Unlock()
err = joinErrors([]error{syncErr, closeErr})
// 2. 封盘:此后不再新开句柄,避免 Close 扫完句柄表之后又冒出没人负责关的句柄
l.closed.Store(true)
// 3. 最后再发通用关闭信号,让 delete / ctx 相关 goroutine 退出
// 3. 在写锁内完成最后一次刷盘并关闭所有句柄。此时无锁等待,不会和谁抢锁
l.writeMu.Lock()
err = joinErrors([]error{l.mustSyncLocked(), l.close()})
l.writeMu.Unlock()
// 4. 等后台压缩协程收尾:不等的话 Close 返回后去读归档会读到半截 .gz
l.compWg.Wait()
// 5. 最后再发通用关闭信号,让 delete / ctx 相关 goroutine 退出
close(l.done)
})
return err
}
// 强制刷盘:只 flush 缓存数据,文件继续可写
// 与写入并发调用是安全的(内部会先拿到写锁)
func (l *Logger) MustSync() error {
l.writeMu.Lock()
defer l.writeMu.Unlock()
return l.mustSyncLocked()
}
// mustSyncLocked 调用方必须持有 writeMu
// 缓冲的 pending 与 buf 只有在写锁内访问才安全,否则会和 store 并发改动同一块内存
func (l *Logger) mustSyncLocked() error {
l.mu.RLock()
files := make([]*logFile, 0, len(l.filePath))
for _, f := range l.filePath {
@@ -135,6 +217,11 @@ func (l *Logger) MustSync() error {
var errs []error
for _, f := range files {
// 没有待落盘数据的文件直接跳过:省掉一次 fsync。
// Close 仍然安全:logFile.Close 自己会再 Flush 一次并关句柄
if !f.hasPending() {
continue
}
errs = append(errs, f.Sync())
}
return joinErrors(errs)
+87
View File
@@ -0,0 +1,87 @@
package loggerx_test
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/yuninks/loggerx"
)
// 归档滚动后,源文件不该留下(对应「压缩完成但无法删除原文件」这个报错)
// 覆盖单线程滚动
func TestArchiveSourceRemovable(t *testing.T) {
for round := 0; round < 3; round++ {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetSizeSplit(4*1024),
loggerx.SetCompress(true),
)
for i := 0; i < 600; i++ {
l.Infof(context.Background(), "ROLL-%d-%s", i, strings.Repeat("z", 60))
}
if err := l.Close(); err != nil {
t.Fatalf("第 %d 轮 Close: %v", round, err)
}
if leftover := findLeftoverArchives(t, dir); len(leftover) > 0 {
t.Errorf("第 %d 轮:这些归档的源文件没删掉(白占一份磁盘): %v", round, leftover)
}
}
}
// 并发 + 多 channel + 异步 + 高频刷盘同时压,归档源文件同样不能残留
func TestArchiveSourceRemovableConcurrent(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetSizeSplit(2*1024),
loggerx.SetCompress(true),
loggerx.SetWriteAsync(),
loggerx.SetFlushInterval(5*time.Millisecond),
)
var wg sync.WaitGroup
ctx := context.Background()
for w := 0; w < 8; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < 200; i++ {
l.Channel(fmt.Sprintf("c%d", w%3)).Infof(ctx, "CONC-%d-%d-%s", w, i, strings.Repeat("q", 40))
l.Infof(ctx, "ROOT-%d-%d", w, i)
_ = l.MustSync()
}
}(w)
}
wg.Wait()
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if leftover := findLeftoverArchives(t, dir); len(leftover) > 0 {
t.Errorf("并发滚动下这些归档的源文件没删掉: %v", leftover)
}
}
// findLeftoverArchives 找出「.gz 与同名 .log 成对存在」的冗余源文件
func findLeftoverArchives(t *testing.T, dir string) []string {
t.Helper()
var leftover []string
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(p, ".gz") {
return nil
}
base := strings.TrimSuffix(p, ".gz")
if _, serr := os.Stat(base); serr == nil {
leftover = append(leftover, filepath.Base(base))
}
return nil
})
return leftover
}
+78
View File
@@ -0,0 +1,78 @@
package loggerx_test
import (
"context"
"io"
"testing"
"time"
"github.com/yuninks/loggerx"
)
func benchLogger(b *testing.B, opts ...loggerx.Option) *loggerx.Logger {
b.Helper()
// 预热时区:Windows 上 time.Local 首次使用会去读注册表(一次性、很贵),
// 不预热会把这笔初始化成本摊到单次日志耗时里
_, _ = time.Now().In(time.Local).Zone()
opts = append([]loggerx.Option{loggerx.SetDir(b.TempDir())}, opts...)
l := loggerx.NewLogger(context.Background(), opts...)
b.Cleanup(func() { _ = l.Close() })
return l
}
// 同步写入:每条都进缓冲(32KB 才落盘一次)
func BenchmarkWriteSync(b *testing.B) {
l := benchLogger(b)
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Infof(ctx, "hello %d", i)
}
}
// 异步写入
func BenchmarkWriteAsync(b *testing.B) {
l := benchLogger(b, loggerx.SetWriteAsync())
al := l.WriteAsync()
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
al.Infof(ctx, "hello %d", i)
}
}
// 并行写入(16 核)
func BenchmarkWriteParallel(b *testing.B) {
l := benchLogger(b)
ctx := context.Background()
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
l.Infof(ctx, "hello parallel")
}
})
}
// 关闭 goroutine id、关掉文件输出:看纯格式化开销
func BenchmarkFormatOnly(b *testing.B) {
l := benchLogger(b, loggerx.SetPrintFile(false), loggerx.SetExtraDriver(io.Discard), loggerx.SetGID(false))
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Infof(ctx, "hello %d", i)
}
}
// 默认(含 goroutine id + 写文件)
func BenchmarkDefault(b *testing.B) {
l := benchLogger(b)
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Info(ctx, "hello")
}
}
+72
View File
@@ -0,0 +1,72 @@
package loggerx_test
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/yuninks/loggerx"
)
// 异步模式下 Channel + error 组合是否落盘
func TestAsyncChannelErrorWrites(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetWriteAsync(),
loggerx.SetSizeSplit(6*1024),
)
for i := 0; i < 10; i++ {
l.Info(context.Background(), "main")
l.Channel("sub").Errorf(context.Background(), "ERR-%d", i)
l.Channel("sub").Infof(context.Background(), "SUBINFO-%d", i)
l.Errorf(context.Background(), "MAINERR-%d", i)
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(dir, p)
if info.IsDir() {
t.Logf(" [dir] %s", rel)
} else {
t.Logf(" [file] %s %d", rel, info.Size())
}
return nil
})
// sub channel 的日志必须落在 sub 子目录,不能混进根目录
all := readAllLogs(t, dir)
if n := countOf(all, "SUBINFO"); n != 0 {
t.Errorf("根目录日志里混入了 %d 条 sub channel 的 info 日志", n)
}
if _, err := os.Stat(filepath.Join(dir, "sub")); err != nil {
t.Fatalf("channel 目录不存在: %v", err)
}
sub := readAllIn(t, dir, "sub")
for i := 0; i < 10; i++ {
if !strings.Contains(sub, fmt.Sprintf("ERR-%d", i)) {
t.Errorf("sub channel 丢失 error 日志 ERR-%d", i)
}
if !strings.Contains(sub, fmt.Sprintf("SUBINFO-%d", i)) {
t.Errorf("sub channel 丢失 info 日志 SUBINFO-%d", i)
}
}
}
func countOf(s, sub string) int {
n := 0
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
n++
}
}
return n
}
+130
View File
@@ -0,0 +1,130 @@
package loggerx_test
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yuninks/loggerx"
)
// 定时刷盘 + 异步写入 + 按大小切割 + 压缩 全部开启时的综合正确性
func TestAllFeaturesCombined(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetWriteAsync(),
loggerx.SetFlushInterval(15*time.Millisecond),
loggerx.SetSizeSplit(6*1024),
loggerx.SetCompress(true),
)
al := l.WriteAsync()
const n = 1500
for i := 0; i < n; i++ {
// 注意:内容里带了固定后缀,条目文本形如 ALL-0-yyy...
// 断言时用 "ALL-<i>-" 前缀而不是 "ALL-<i>\""
al.Infof(context.Background(), "ALL-%d-%s", i, strings.Repeat("y", 40))
// 顺带混入 channel 与 error 事件,确认多句柄并发滚动也正常
if i%50 == 0 {
l.Channel("sub").Errorf(context.Background(), "ERR-%d", i)
}
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// 主日志(含压缩归档)一条不能少
gzContent := readAllGz(t, dir)
logContent := readAllLogs(t, dir)
main := logContent + gzContent
t.Logf("未压缩 .log 共 %d 字节;.gz 解压后共 %d 字节", len(logContent), len(gzContent))
if len(gzContent) > 0 {
n := 200
if len(gzContent) < n {
n = len(gzContent)
}
t.Logf("gz 内容片段: %.200q", gzContent[:n])
}
missing := 0
for i := 0; i < n; i++ {
if !strings.Contains(main, fmt.Sprintf("ALL-%d-", i)) {
missing++
}
}
if missing > 0 {
t.Errorf("综合场景丢失 %d / %d 条主日志", missing, n)
}
// sub channel 的 error 日志也要完整
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(dir, p)
if info.IsDir() {
t.Logf(" [dir] %s", rel)
} else {
t.Logf(" [file] %s %d 字节", rel, info.Size())
}
return nil
})
sub := readAllIn(t, dir, "sub")
for i := 0; i < n; i += 50 {
if !strings.Contains(sub, fmt.Sprintf("ERR-%d", i)) {
t.Errorf("sub channel 丢失 ERR-%d", i)
}
}
}
// readAllIn 读取某个子目录(channel 目录)下的日志与压缩归档
func readAllIn(t *testing.T, dir, sub string) string {
t.Helper()
target := filepath.Join(dir, sub)
if _, err := os.Stat(target); err != nil {
t.Fatalf("channel 目录不存在: %v", err)
}
var sb strings.Builder
files, _ := filepath.Glob(filepath.Join(target, "*"))
for _, f := range files {
st, err := os.Stat(f)
if err != nil || st.IsDir() {
continue
}
b, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读取 %s: %v", f, err)
}
sb.Write(b)
}
return sb.String()
}
// 综合场景下定时刷盘不能把 Close 卡死(刷盘协程与关闭的互锁)
func TestAllFeaturesCloseNotBlocked(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetFlushInterval(1*time.Millisecond), // 刷盘非常频繁,放大竞争
loggerx.SetSizeSplit(2*1024),
)
for i := 0; i < 800; i++ {
l.Infof(context.Background(), "BLK-%d", i)
}
done := make(chan error, 1)
go func() { done <- l.Close() }()
select {
case err := <-done:
if err != nil {
t.Errorf("Close 返回错误: %v", err)
}
case <-time.After(20 * time.Second):
t.Fatal("高频定时刷盘时 Close 卡死")
}
}
+53
View File
@@ -0,0 +1,53 @@
package loggerx_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/yuninks/loggerx"
)
func statFile(p string) (os.FileInfo, error) { return os.Stat(p) }
// Close() 返回后压缩必须已经收尾:不应该残留「既是 .log 又有同名 .gz」的中间态
func TestCompressionFinishedAfterClose(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetSizeSplit(4*1024),
)
for i := 0; i < 500; i++ {
l.Infof(context.Background(), "FIN-%d", i)
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
logs, _ := filepath.Glob(filepath.Join(dir, "*.log"))
gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz"))
t.Logf("Close 后: %d 个 .log, %d 个 .gz", len(logs), len(gzs))
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
info, _ := e.Info()
t.Logf(" %s %d 字节", e.Name(), info.Size())
}
// 对每个 .gz,不应还存在同名的 .log(说明压缩成功后原文件已清理)
for _, g := range gzs {
base := strings.TrimSuffix(g, ".gz")
if _, err := statFile(base); err == nil {
t.Errorf("压缩完成后原文件仍存在: %s", filepath.Base(base))
}
}
// gz 加 log 的总数应该等于归档总数(每个归档恰好以一种形态存在)
if len(gzs) == 0 {
t.Fatal("没有产生 .gz 归档")
}
}
+53
View File
@@ -0,0 +1,53 @@
package loggerx_test
import (
"context"
"testing"
"time"
"github.com/yuninks/loggerx"
)
// 按大小切割开启后,每条日志会多一次 Stat(判断是否该滚动)的开销
func BenchmarkWriteWithSizeSplit(b *testing.B) {
l := benchLogger(b, loggerx.SetSizeSplit(8<<20))
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Infof(ctx, "hello %d", i)
}
}
// 不开大小切割的对照
func BenchmarkWriteNoSizeSplit(b *testing.B) {
l := benchLogger(b)
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Infof(ctx, "hello %d", i)
}
}
// 定时刷盘开启后(间隔取大值,避免刷盘本身干扰测量)
func BenchmarkWriteWithFlushInterval(b *testing.B) {
l := benchLogger(b, loggerx.SetFlushInterval(time.Hour))
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Infof(ctx, "hello %d", i)
}
}
// 定时刷盘:短间隔(1ms)下的稳态吞吐,含刷盘协程竞争
func BenchmarkWriteShortFlushInterval(b *testing.B) {
l := benchLogger(b, loggerx.SetFlushInterval(time.Millisecond))
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Infof(ctx, "hello %d", i)
}
}
+301
View File
@@ -0,0 +1,301 @@
package loggerx_test
import (
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yuninks/loggerx"
)
// ---------- 功能 1:定时刷盘 ----------
// 开启定时刷盘后,未 Close 也能在磁盘上看到日志
func TestFlushIntervalPersistsWithoutClose(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetFlushInterval(30*time.Millisecond),
)
defer l.Close()
l.Info(context.Background(), "FLUSHED-BY-TIMER")
// 等若干次刷盘周期;不调用 MustSync,也不 Close
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if strings.Contains(readAllLogs(t, dir), "FLUSHED-BY-TIMER") {
return // 成功:定时器把它刷下去了
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("定时刷盘没生效:未 Close 时磁盘上看不到日志")
}
// 不开定时刷盘时,日志应该仍留在内存缓冲里(证明上面的测试不是假通过)
func TestNoFlushIntervalKeepsInBuffer(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir))
defer l.Close()
l.Info(context.Background(), "STILL-BUFFERED")
time.Sleep(200 * time.Millisecond)
if strings.Contains(readAllLogs(t, dir), "STILL-BUFFERED") {
t.Skip("本次写入正好触发了缓冲落盘,跳过该对照")
}
}
// 定时刷盘不能丢日志、也不能让 Close 之后 goroutine 残留
func TestFlushIntervalNoLossAndStops(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetFlushInterval(10*time.Millisecond),
)
const n = 3000
for i := 0; i < n; i++ {
l.Infof(context.Background(), "FL-%d", i)
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
content := readAllLogs(t, dir)
missing := 0
for i := 0; i < n; i++ {
if !strings.Contains(content, fmt.Sprintf(`FL-%d"`, i)) {
missing++
}
}
if missing > 0 {
t.Errorf("定时刷盘场景丢了 %d 条日志", missing)
}
}
// ---------- 功能 2:按大小切割 + 压缩 ----------
// 超过大小上限后应该滚动出带序号的归档文件,并压缩成 .gz
func TestSizeSplitRollsAndCompresses(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetSizeSplit(8*1024), // 8KB 一个文件
)
const n = 800 // 每条约 130 字节 => 约 104KB,应滚出十来个文件
for i := 0; i < n; i++ {
l.Infof(context.Background(), "SPLIT-%d-%s", i, strings.Repeat("x", 60))
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz"))
t.Logf("滚动结果: %d 个 .log, %d 个 .gz", len(files), len(gzs))
if len(gzs) == 0 {
t.Fatal("没有产生任何压缩归档 .gz")
}
// 每个未压缩文件都不应明显超过上限(留一点余量给缓冲/单条超长)
for _, f := range files {
st, err := os.Stat(f)
if err != nil {
t.Fatalf("stat %s: %v", f, err)
}
if st.Size() > 8*1024+2048 {
t.Errorf("%s 大小 %d 超过上限过多", filepath.Base(f), st.Size())
}
}
// 归档文件名应带序号
for _, g := range gzs {
if !strings.Contains(filepath.Base(g), "_") {
t.Errorf("归档文件名缺少序号: %s", filepath.Base(g))
}
}
}
// 压缩归档要能被解开,且内容是完整合法的日志(不能丢条)
func TestCompressedArchiveIsReadable(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetSizeSplit(4*1024),
)
const n = 400
for i := 0; i < n; i++ {
l.Infof(context.Background(), "GZ-%d", i)
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz"))
if len(gzs) == 0 {
t.Fatal("没有产生 .gz 归档")
}
// 压缩归档 + 当前未压缩文件一起统计,必须一条不漏
content := readAllGz(t, dir) + readAllLogs(t, dir)
if !strings.Contains(content, "[info]{") {
t.Errorf("内容不像日志: %.120q", content)
}
missing := 0
for i := 0; i < n; i++ {
if !strings.Contains(content, fmt.Sprintf(`GZ-%d"`, i)) {
missing++
}
}
if missing > 0 {
t.Errorf("压缩归档丢失 %d / %d 条日志", missing, n)
}
t.Logf("共 %d 个 .gz 归档,解压后日志完整(%d 条)", len(gzs), n)
}
// 关闭压缩时,归档应保持未压缩的 .log 形态
func TestSizeSplitWithoutCompress(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetSizeSplit(4*1024),
loggerx.SetCompress(false),
)
for i := 0; i < 300; i++ {
l.Infof(context.Background(), "RAW-%d", i)
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz"))
if len(gzs) != 0 {
t.Errorf("SetCompress(false) 仍然产生了 %d 个 .gz", len(gzs))
}
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
if len(files) < 2 {
t.Errorf("期望滚出多个归档文件,实际 %d 个", len(files))
}
}
// 滚动过程中不能丢日志:所有序号都应在 .log 或 .gz 里找得到
func TestSizeSplitNoLoss(t *testing.T) {
for round := 0; round < 3; round++ {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetSizeSplit(8*1024),
)
const n = 600
for i := 0; i < n; i++ {
l.Infof(context.Background(), "NOLOSS-%d", i)
}
if err := l.Close(); err != nil {
t.Fatalf("第 %d 轮 Close: %v", round, err)
}
content := readAllLogs(t, dir) + readAllGz(t, dir)
missing := 0
for i := 0; i < n; i++ {
if !strings.Contains(content, fmt.Sprintf(`NOLOSS-%d"`, i)) {
missing++
}
}
if missing > 0 {
t.Errorf("第 %d 轮:按大小切割丢了 %d 条日志", round, missing)
}
}
}
// 单条日志就超过上限时不能死循环
func TestSizeSplitHugeSingleEntry(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(),
loggerx.SetDir(dir),
loggerx.SetSizeSplit(1024), // 上限比单条还小
)
done := make(chan struct{})
go func() {
defer close(done)
l.Info(context.Background(), strings.Repeat("H", 8192))
l.Info(context.Background(), "after-huge")
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("单条超长日志导致卡死(可能是滚动死循环)")
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if !strings.Contains(readAllLogs(t, dir)+readAllGz(t, dir), "after-huge") {
t.Error("超长日志之后的那条日志丢失了")
}
}
// 重启场景:文件已经满了,新进程开起来应该归档旧文件而不是永远写同一个
func TestSizeSplitOnRestart(t *testing.T) {
dir := t.TempDir()
opts := []loggerx.Option{loggerx.SetDir(dir), loggerx.SetSizeSplit(4 * 1024)}
l1 := loggerx.NewLogger(context.Background(), opts...)
for i := 0; i < 200; i++ {
l1.Infof(context.Background(), "RUN1-%d", i)
}
if err := l1.Close(); err != nil {
t.Fatalf("l1.Close: %v", err)
}
l2 := loggerx.NewLogger(context.Background(), opts...)
for i := 0; i < 200; i++ {
l2.Infof(context.Background(), "RUN2-%d", i)
}
if err := l2.Close(); err != nil {
t.Fatalf("l2.Close: %v", err)
}
content := readAllLogs(t, dir) + readAllGz(t, dir)
for i := 0; i < 200; i++ {
if !strings.Contains(content, fmt.Sprintf(`RUN2-%d"`, i)) {
t.Fatalf("重启后 RUN2-%d 丢失", i)
}
}
// 归档序号不应互相覆盖:RUN1 与 RUN2 都应该在
if !strings.Contains(content, `RUN1-0"`) {
t.Error("第一轮运行的日志被覆盖了")
}
}
// readAllGz 解开目录下所有 .gz 归档
func readAllGz(t *testing.T, dir string) string {
t.Helper()
var sb strings.Builder
gzs, _ := filepath.Glob(filepath.Join(dir, "*.gz"))
for _, g := range gzs {
f, err := os.Open(g)
if err != nil {
t.Fatalf("打开 %s: %v", g, err)
}
zr, err := gzip.NewReader(f)
if err != nil {
_ = f.Close()
t.Fatalf("%s 不是合法 gzip: %v", g, err)
}
b, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("解压 %s: %v", g, err)
}
_ = zr.Close()
_ = f.Close()
sb.Write(b)
}
return sb.String()
}
+84
View File
@@ -0,0 +1,84 @@
package loggerx
import (
"os"
"path/filepath"
"testing"
)
// 自愈:能删掉「.gz + .log 成对」里冗余的 .log
func TestHealRemovesRedundantLog(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "2026-09-13_info_1.log")
if err := os.WriteFile(src, []byte("hello log\n"), 0644); err != nil {
t.Fatal(err)
}
if err := gzipFile(src, 1); err != nil {
t.Fatal(err)
}
// 人为造出「压缩成功但源文件还在」的现场
if err := os.WriteFile(src, []byte("hello log\n"), 0644); err != nil {
t.Fatal(err)
}
l := &Logger{option: defaultOptions()}
l.option.dir = dir
l.option.compress = true
if err := l.healCompressedLeftovers(); err != nil {
t.Fatalf("自愈出错: %v", err)
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Errorf("冗余的 .log 没被清掉: %v", err)
}
if _, err := os.Stat(src + ".gz"); err != nil {
t.Errorf(".gz 被误删了: %v", err)
}
}
// 安全性:.gz 损坏时必须保留 .log(那是唯一可读副本)
func TestHealKeepsLogWhenGzCorrupt(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "2026-09-13_info_2.log")
if err := os.WriteFile(src, []byte("important data\n"), 0644); err != nil {
t.Fatal(err)
}
// 造一个损坏的 .gz(截断的非 gzip 内容)
if err := os.WriteFile(src+".gz", []byte("not a gzip stream"), 0644); err != nil {
t.Fatal(err)
}
l := &Logger{option: defaultOptions()}
l.option.dir = dir
l.option.compress = true
if err := l.healCompressedLeftovers(); err != nil {
t.Fatalf("自愈出错: %v", err)
}
if _, err := os.Stat(src); err != nil {
t.Errorf("损坏压缩包时不该删掉 .log: %v", err)
}
}
// 关闭压缩时不做自愈(用户有意保留未压缩归档)
func TestHealSkippedWhenCompressOff(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "2026-09-13_info_3.log")
if err := os.WriteFile(src, []byte("data\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(src+".gz", []byte("x"), 0644); err != nil {
t.Fatal(err)
}
l := &Logger{option: defaultOptions()}
l.option.dir = dir
l.option.compress = false
if err := l.healCompressedLeftovers(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(src); err != nil {
t.Errorf("关闭压缩时不该清理 .log: %v", err)
}
}
+65
View File
@@ -0,0 +1,65 @@
package loggerx_test
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/yuninks/loggerx"
)
// MustSync 与写入并发时是否安全(配合 -race 运行)
func TestMustSyncConcurrentWithWrite(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir))
defer l.Close()
var wg sync.WaitGroup
ctx := context.Background()
// 一边写,一边调 MustSync
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 500; i++ {
l.Infof(ctx, "concurrent-%d", i)
}
}()
go func() {
defer wg.Done()
for i := 0; i < 200; i++ {
_ = l.MustSync()
}
}()
wg.Wait()
}
// goroutine id 开关是否真的生效(SetGID(false) 后不应再出现 gid 字段)
func TestSetGIDActuallyDisablesGID(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetGID(false))
l.Info(context.Background(), "no-gid")
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
var sb strings.Builder
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读取 %s: %v", f, err)
}
sb.Write(b)
}
content := sb.String()
if content == "" {
t.Fatal("没有写入任何内容")
}
if strings.Contains(content, `"gid":`) {
t.Errorf("SetGID(false) 未生效,日志里仍有 gid: %s", content)
}
}
+55
View File
@@ -0,0 +1,55 @@
package loggerx_test
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"github.com/yuninks/loggerx"
)
// MustSync 与写入并发时不能丢日志(模拟外部定时刷盘的用法)
func TestMustSyncConcurrentNoLoss(t *testing.T) {
for round := 0; round < 5; round++ {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir))
ctx := context.Background()
const n = 2000
var wg sync.WaitGroup
wg.Add(2)
// 持续写入
go func() {
defer wg.Done()
for i := 0; i < n; i++ {
l.Infof(ctx, "MS-%d", i)
}
}()
// 持续外部刷盘(这正是 MustSync 的公开用途)
go func() {
defer wg.Done()
for i := 0; i < 300; i++ {
_ = l.MustSync()
}
}()
wg.Wait()
if err := l.Close(); err != nil {
t.Fatalf("第 %d 轮 Close: %v", round, err)
}
content := readAllLogs(t, dir)
missing := 0
for i := 0; i < n; i++ {
if !strings.Contains(content, fmt.Sprintf(`MS-%d"`, i)) {
missing++
}
}
if missing > 0 {
t.Errorf("第 %d 轮:写入 %d 条,丢失 %d 条", round, n, missing)
}
}
}
+102
View File
@@ -0,0 +1,102 @@
package loggerx_test
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/yuninks/loggerx"
)
func readAllLogs(t *testing.T, dir string) string {
t.Helper()
var sb strings.Builder
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读取 %s: %v", f, err)
}
sb.Write(b)
}
return sb.String()
}
// 写入量远超异步队列容量(1000)时的行为
func TestAsyncQueueOverflow(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetWriteAsync())
const n = 5000
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < n; i++ {
l.Infof(context.Background(), "OVF-%d", i)
}
}()
// 等写完,再关闭
<-done
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
content := readAllLogs(t, dir)
missing := 0
for i := 0; i < n; i++ {
if !strings.Contains(content, fmt.Sprintf(`OVF-%d"`, i)) {
missing++
}
}
t.Logf("写入 %d 条,落盘缺失 %d 条,文件字节=%d", n, missing, len(content))
if missing > 0 {
t.Errorf("队列溢出场景丢日志 %d 条", missing)
}
}
// 超过队列容量且「边写边关闭」:不能永久阻塞
func TestAsyncOverflowConcurrentClose(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetWriteAsync())
const n = 5000
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < n; i++ {
l.Infof(context.Background(), "CLOSE-%d", i)
}
}()
// 写一半就关
closeDone := make(chan error, 1)
go func() { closeDone <- l.Close() }()
waitWrite := make(chan struct{})
go func() { wg.Wait(); close(waitWrite) }()
// 两者都必须在合理时间内结束,否则就是死锁
for i := 0; i < 2; i++ {
select {
case err := <-closeDone:
if err != nil {
t.Errorf("Close 返回错误: %v", err)
}
closeDone = nil
case <-waitWrite:
waitWrite = nil
}
if closeDone == nil && waitWrite == nil {
break
}
}
if closeDone != nil || waitWrite != nil {
t.Fatal("写入或 Close 卡死(死锁)")
}
}
+121
View File
@@ -0,0 +1,121 @@
package loggerx
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// removeWithRetry 遇到「文件被占用」应重试;占用解除后必须删掉
func TestRemoveWithRetryHandlesTransientLock(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "locked.log")
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
// 先独占打开,制造占用
holder, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
locked := make(chan struct{})
release := make(chan struct{})
closed := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
close(locked)
<-release
_ = holder.Close()
close(closed)
}()
<-locked
// 120ms 后释放占用;退避总时长(20+40+80+160+320+640 ≈ 1.2s)要能覆盖
go func() {
time.Sleep(120 * time.Millisecond)
close(release)
}()
start := time.Now()
err = removeWithRetry(path, 7)
elapsed := time.Since(start)
<-closed
wg.Wait()
if err != nil {
t.Fatalf("占用解除后仍未删除成功: %v", err)
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Errorf("文件仍然存在")
}
t.Logf("重试 %v 后删除成功", elapsed.Round(time.Millisecond))
}
// 不可重试的错误(文件不存在)应立即返回,不做无谓等待
func TestRemoveWithRetryFastFail(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "nope.log")
start := time.Now()
err := removeWithRetry(missing, 5)
elapsed := time.Since(start)
if err == nil {
t.Fatal("删除不存在的文件应该返回错误")
}
if elapsed > 100*time.Millisecond {
t.Errorf("不该对不可重试的错误做退避等待,耗时 %v", elapsed)
}
}
// isTransientRemoveErr 判定
func TestIsTransientRemoveErr(t *testing.T) {
cases := []struct {
msg string
want bool
}{
{"remove x.log: The process cannot access the file because it is being used by another process.", true},
{"sharing violation", true},
{"resource busy", true},
{"permission denied", false},
{"no such file or directory", false},
}
for _, c := range cases {
got := isTransientRemoveErr(fmt.Errorf("%s", c.msg))
if got != c.want {
t.Errorf("isTransientRemoveErr(%q) = %v, 期望 %v", c.msg, got, c.want)
}
}
if isTransientRemoveErr(nil) {
t.Error("nil 不应判为可重试")
}
}
// 压缩完成后,源文件与 .gz 不应长期并存
func TestCompressCleansSourceFile(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "a.log")
if err := os.WriteFile(src, []byte(strings.Repeat("log line\n", 500)), 0644); err != nil {
t.Fatal(err)
}
if err := gzipFile(src, 1); err != nil {
t.Fatalf("gzipFile: %v", err)
}
if _, err := os.Stat(src + ".gz"); err != nil {
t.Fatalf("没有生成 .gz: %v", err)
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Errorf("源文件没有被删除: %v", err)
}
if _, err := os.Stat(src + ".gz.tmp"); !os.IsNotExist(err) {
t.Errorf("残留了 .gz.tmp")
}
}
+38 -2
View File
@@ -1,6 +1,7 @@
package loggerx
import (
"compress/gzip"
"io"
"os"
"time"
@@ -19,7 +20,10 @@ type loggerOption struct {
days int // 日志保存天数
drivers []io.Writer // 文件落盘驱动器
fileSplit FileSplit // 文件切割规则
sizeSplit int // 根据文件大小切割
sizeSplit int // 根据文件大小切割(字节,<=0 不切割)
compress bool // 归档文件是否压缩为 .gz
compressLvl int // gzip 压缩级别
flushEvery time.Duration // 定时刷盘间隔,0 表示不开启
timeZone *time.Location // 时区
escapeHTML bool
expandData map[string]string // 扩展字段
@@ -45,6 +49,8 @@ func defaultOptions() loggerOption {
traceField: "trace_id",
days: 7,
fileSplit: FileSplitTimeE,
compress: true,
compressLvl: gzip.BestSpeed,
timeZone: time.Local,
escapeHTML: true,
expandData: make(map[string]string),
@@ -186,13 +192,43 @@ const (
)
// 根据文件大小切割(暂时未生效)
// 根据文件大小切割
// m 为单个文件的大小上限(字节);<=0 表示不按大小切割
// 超过上限时当前文件会被改名归档(并可选压缩),随后写入新文件
func SetSizeSplit(m int) Option {
return func(o *loggerOption) {
o.sizeSplit = m
}
}
// 归档文件是否压缩成 .gz(默认开启)
// 压缩在后台协程完成,不阻塞写入
func SetCompress(open bool) Option {
return func(o *loggerOption) {
o.compress = open
}
}
// 压缩级别 1~9,数字越大压缩率越高、CPU 开销越大,默认 gzip.BestSpeed
func SetCompressLevel(level int) Option {
return func(o *loggerOption) {
if level >= gzip.HuffmanOnly && level <= gzip.BestCompression {
o.compressLvl = level
}
}
}
// 定时刷盘间隔(默认 0 = 不开启)
// 开启后会按间隔把内存缓冲刷到磁盘,把「进程崩溃时可能丢失的数据量」
// 从「最多 32KB 缓冲」压缩到「一个间隔内产生的日志量」
func SetFlushInterval(d time.Duration) Option {
return func(o *loggerOption) {
if d > 0 {
o.flushEvery = d
}
}
}
func SetEscapeHTML(b bool) Option {
return func(o *loggerOption) {
o.escapeHTML = b
+153 -16
View File
@@ -1,26 +1,163 @@
# 简介
# loggerx
这个是基于原生log实现的日志存储
基于 Go 原生 `log` 封装的日志库:支持按时间切分文件、同步/异步落盘、channel 分目录、Gin 中间件
```go
log := loggerx.NewLogger(ctx, loggerx.SetDir("./log"), loggerx.SetToConsole())
defer log.Close() // 退出前务必调用,否则最后一批日志(最多 32KB)不会落盘
log.Println("ddddd")
log.Info(ctx, "hello")
log.Infof(ctx, "hello %s", "world")
```
# 用法
## 用法
# 开发计划
### 创建与选项
1. [ ] 自动清除过期的日志文件
2. [ ] 支持日志文件压缩
```go
log := loggerx.NewLogger(ctx,
loggerx.SetDir("./log"), // 日志目录,默认 ./log
loggerx.SetToConsole(), // 同时输出到控制台
loggerx.SetDays(7), // 保留天数,默认 7;<=0 表示不删除
loggerx.SetTimeZone(time.FixedZone("CST", 8*3600)), // 时区,默认 time.Local
loggerx.SetFileSplit(loggerx.FileSplitTimeE), // 时间切割方式,默认按天
loggerx.SetSizeSplit(64<<20), // 单文件上限 64MB,超过则滚动归档
loggerx.SetCompress(true), // 归档是否压成 .gz,默认 true
loggerx.SetCompressLevel(gzip.BestCompression), // 压缩级别 1~9,默认 BestSpeed
loggerx.SetFlushInterval(200*time.Millisecond), // 定时刷盘,默认关闭
loggerx.SetEscapeHTML(false), // 是否转义 HTML,默认 true
loggerx.SetGID(false), // 是否记录 goroutine id,默认 true
loggerx.SetTraceField("trace_id"), // trace 字段名,默认 trace_id
loggerx.SetErrorToInfo(), // error 是否同时写入 info 日志
loggerx.SetExpandData("app", "order"), // 每条日志追加固定字段
loggerx.SetExtraDriver(f, hooks), // 额外落盘驱动(实现 io.Writer 即可)
loggerx.SetPrintFile(false), // 不写文件,只走驱动
)
```
### 定时刷盘:把崩溃丢失窗口压到最小
默认不刷盘时,日志攒在 32KB 内存缓冲里,进程被 `kill -9` 最多丢 32KB。
开启定时刷盘后,丢失量收敛为「一个间隔内产生的日志量」:
```go
log := loggerx.NewLogger(ctx,
loggerx.SetDir("./log"),
loggerx.SetFlushInterval(200*time.Millisecond), // 每 200ms 刷一次
)
```
刷盘由后台协程完成,失败只记日志、不会中断写入;`Close()` 会等它退出。
### 按大小切割 + 自动压缩归档
```go
log := loggerx.NewLogger(ctx,
loggerx.SetDir("./log"),
loggerx.SetSizeSplit(64<<20), // 每个文件最多 64MB
loggerx.SetCompress(true), // 滚动后压成 .gz(默认开)
)
```
滚动过程(不阻塞写入):
1. 关掉当前句柄(关句柄会先清空缓冲,归档内容因此完整)
2. 改名成 `2026-09-13_info_3.log`(先改名,任何时刻文件都存在,崩溃也不丢)
3. 后台协程压缩成 `2026-09-13_info_3.log.gz`,成功后删掉未压缩文件
细节保证:
- **序号全局递增**,且跳过已存在的文件,不覆盖历史归档;跨进程重启也从目录里续号
- **单条日志超长**(比上限还大)不会死循环,最坏就是该文件超限
- **归档失败**(如权限问题)时继续写原文件,宁可文件大一点也不丢日志
- `Close()` 会等压缩协程收尾,返回后读归档不会读到半截 `.gz`
- 过期清理(`SetDays`)同时作用于 `.log``.gz`
### 文件切割
| 取值 | 目录/文件名形态 |
| --- | --- |
| `FileSplitNone` | `info.log` |
| `FileSplitTimeA` | `2026/09/13/15_info.log` |
| `FileSplitTimeB` | `2026/09/13_info.log` |
| `FileSplitTimeC` | `2026/09-13_info.log` |
| `FileSplitTimeD` | `2026-09-13-15_info.log` |
| `FileSplitTimeE` | `2026-09-13_info.log`(默认) |
### channel 分目录
```go
log.Channel("order").Info(ctx, "下单") // 落到 ./log/order/2026-09-13_info.log
log.Channel("pay").Error(ctx, "支付失败")
```
### 同步 / 异步
```go
log.Info(ctx, "默认同步") // 写入内存缓冲(32KB 满才落盘)
log.WriteAsync().Info(ctx, "这条异步") // 交给后台协程消费
loggerx.SetWriteAsync() // 全局异步
```
异步是**有界阻塞队列**(容量 1000):队列满时写入方会等待,不会丢日志。
`Close()` 会等队列排空并把缓冲刷盘。
### Gin 中间件
```go
g := gin.Default()
log := loggerx.NewLogger(context.Background(), loggerx.SetToConsole())
defer log.Close()
g.Use(middleware.SetGinTraceIdByLogger(log)) // 读取/生成 trace_id
g.Use(middleware.SetGinParams(log)) // 记录请求与响应
```
### 与标准库 log 互通
`NewLogger` 会把全局 `log` 的输出接管到该实例,同时继承 `io.Writer`,可以直接传给任何需要 `io.Writer` 的地方:
```go
log.SetOutput(loggerx.NewLogger(ctx, loggerx.SetDir("./log")))
```
## 落盘行为与保证
- **缓冲**:每条日志先写进 32KB 内存缓冲,写满才 `write` 一次系统调用。
- **完整性**:以下四种方式都会把缓冲落盘 —— 缓冲写满、`Close()``MustSync()``SetFlushInterval` 定时器。
- **崩溃语义**:进程被 `kill -9`、panic 未恢复、断电时,**最多丢失最后 32KB** 未落盘的日志;
开启 `SetFlushInterval` 后,丢失量收敛到「一个间隔内产生的日志量」。
- **关闭语义**`Close()` 之后该实例不再接受写入(返回 `loggerx: 日志已关闭`);
会等异步队列排空、等压缩归档收尾;重复调用安全;不泄漏文件句柄。
- **写失败**:写文件失败会重试一次(重开句柄);磁盘满等持续失败时该条日志会丢,
错误通过 `io.Writer` 语义返回给调用方,建议对 `Write`/`MustSync`/`Close` 的返回值做检查。
- **channel 隔离**`Channel("x")` 的日志落在 `<dir>/x/` 子目录,同步与异步模式都成立。
## 性能
本机(Windows / 16 核)实测,Go 1.26
| 场景 | 单条耗时 | 分配 |
| --- | --- | --- |
| `SetGID(false)` + 只走驱动 | 3.4 µs | 14 |
| 默认(含 gid,写文件) | 10.8 µs | 15 |
| 并行写入(16 goroutine | 19.6 µs | 17 |
单条 10.8 µs 中约 4.3 µs 花在采集 goroutine id 上。对延迟敏感、日志量大的场景建议
`SetGID(false)`,可省掉约 40% 开销。开启 `SetSizeSplit` 后每条日志会多一次
`Stat`(约 0.6 µs)用于判断是否该滚动;不配置大小切割则没有这笔开销。
## 开发计划
1. [X] 自动清除过期的日志文件(`.log``.gz` 都清)
2. [X] 支持日志文件压缩(滚动归档自动 gzip)
3. [X] 支持日志文件切割
4. [ ] 支持日志文件归档
5. [ ] 支持多种文件分割类型
1. [ ] 按照时间分割
2. [ ] 按照文件大小分割
4. [ ] 支持日志文件归档到对象存储
5. 支持多种文件分割类型
1. [X] 按照时间分割
2. [X] 按照文件大小分割`SetSizeSplit`
3. [ ] 按照日志行数分割
6. [ ] 支持debug 模式
7. [X] 添加异步落库,支持全局和单次
1. [ ] 优化:异步应该跟实例不应该全局,多实例异步落库将会有BUG
8. [X] 添加支持是否转义html
6. [ ] 支持日志级别过滤(`SetFormat("text")` 尚未生效)
7. [X] 异步落盘(按实例隔离,不再是全局队列)
8. [X] 支持是否转义 HTML
9. [X] 支持定时刷盘(`SetFlushInterval`
+6 -1
View File
@@ -23,6 +23,10 @@ func (l *Logger) delete() {
fmt.Println("清理日志文件异常:", r)
}
}()
// 先清掉「压缩完成但源文件没删掉」留下的冗余文件
if err := l.healCompressedLeftovers(); err != nil {
fmt.Println(err)
}
if err := l.walkAndDel(); err != nil {
fmt.Println(err)
}
@@ -59,7 +63,8 @@ func (l *Logger) walkAndDel() error {
if !isEmptyDir(path) {
return nil
}
} else if filepath.Ext(path) != ".log" {
} else if ext := filepath.Ext(path); ext != ".log" && ext != ".gz" {
// 只清理日志与压缩归档
return nil
}
+90 -11
View File
@@ -24,11 +24,16 @@ func (l *Logger) write(event string, b []byte) (n int, err error) {
// 必须满足 io.Writer 契约:成功时 n == len(b),否则调用方(log / io.MultiWriter
// 会认为发生了短写并把日志吞掉
func (l *Logger) store(event string, b []byte) (n int, err error) {
return l.storeTo(l.channel, event, b)
}
// storeTo 按指定 channel 落盘(异步消费协程用,它的 l.channel 是空的)
func (l *Logger) storeTo(channel, event string, b []byte) (n int, err error) {
if l.option.isPrintFile {
// 串行化写入:句柄缓冲区不是并发安全的,
// 异步消费协程与同步调用可能同时写同一个句柄
l.writeMu.Lock()
n, err = l.storeFile(event, b)
n, err = l.storeFileTo(channel, event, b)
l.writeMu.Unlock()
if err != nil {
return 0, err
@@ -43,11 +48,25 @@ func (l *Logger) store(event string, b []byte) (n int, err error) {
// storeFile 写入日志文件
func (l *Logger) storeFile(event string, b []byte) (int, error) {
f, err := l.getFile(event)
return l.storeFileTo(l.channel, event, b)
}
// storeFileTo 把日志写进指定 channel 对应的文件
func (l *Logger) storeFileTo(channel, event string, b []byte) (int, error) {
f, err := l.getFileTo(channel, event)
if err != nil {
return 0, err
}
// 按大小切割:这条日志会把当前文件写超上限时,先归档当前文件再换新文件
if limit := l.option.sizeSplit; limit > 0 && f.full(limit, len(b)) {
nf, rerr := l.rollFileTo(channel, event, f)
if rerr == nil {
f = nf
}
// 归档失败就继续往原文件写:宁可文件超一点,也不能把日志丢了
}
n, err := f.Write(b)
if err == nil && n < len(b) {
err = io.ErrShortWrite
@@ -58,8 +77,8 @@ func (l *Logger) storeFile(event string, b []byte) (int, error) {
// 写入失败:丢弃这个句柄,落到磁盘后按最新文件名重开一次再写
// 只重试一次,避免原实现在短写时反复重开文件
l.discardFile(event, f)
if nf, nerr := l.getFile(event); nerr == nil {
l.discardFileTo(channel, event, f)
if nf, nerr := l.getFileTo(channel, event); nerr == nil {
if n2, err2 := nf.Write(b); err2 == nil && n2 == len(b) {
return n2, nil
}
@@ -68,6 +87,43 @@ func (l *Logger) storeFile(event string, b []byte) (int, error) {
return 0, err
}
// rollFileTo 归档当前文件并返回新文件句柄
func (l *Logger) rollFileTo(channel, event string, f *logFile) (*logFile, error) {
key := fileKey{channel: channel, event: event}
// 先把缓冲清空再关句柄,保证归档内容完整
if err := f.Close(); err != nil {
return nil, err
}
// 归档基名直接用句柄记下的 baseName:绝不去猜文件名尾部的 _N 是不是归档序号
// (按小时切割出来的名字本身就长这样:2026/09/13/06_info.log
path, err := l.archive(f.baseName, f.fileName)
if err != nil {
return nil, err
}
l.mu.Lock()
if cur, ok := l.filePath[key]; ok && cur == f {
delete(l.filePath, key)
}
l.mu.Unlock()
// 新文件带走递增序号,避免覆盖刚归档出去的同名文件
nf, err := l.openNumberedFile(key, l.nextIndex())
if err != nil {
return nil, err
}
l.mu.Lock()
l.filePath[key] = nf
l.mu.Unlock()
// 后台压缩,不阻塞写入
l.scheduleCompress(path)
return nf, nil
}
// 写入额外的驱动(控制台 / 自定义 writer)
func (l *Logger) writeDrivers(b []byte) (int, error) {
if len(l.option.drivers) == 0 {
@@ -78,20 +134,29 @@ func (l *Logger) writeDrivers(b []byte) (int, error) {
// discardFile 关闭文件并从缓存中移除,使下次写入重新打开
func (l *Logger) discardFile(event string, f *logFile) {
l.discardFileTo(l.channel, event, f)
}
// discardFileTo 关闭并移除指定 channel 的文件句柄
func (l *Logger) discardFileTo(channel, event string, f *logFile) {
_ = f.Close()
l.mu.Lock()
defer l.mu.Unlock()
key := fileKey{channel: l.channel, event: event}
key := fileKey{channel: channel, event: event}
if cur, ok := l.filePath[key]; ok && cur == f {
delete(l.filePath, key)
}
}
// 异步队列的任务
// Channel 必须跟着任务走:消费协程持有的是「创建队列时的那个 logger」,
// 它的 channel 是空的。如果消费时用 logger 自己的 channel
// 所有 channel 的日志都会落进根目录文件(曾经就是这么错的)
type cacheData struct {
Event string
Data []byte
Channel string
Event string
Data []byte
}
// toAsync 尝试异步写入
@@ -105,11 +170,13 @@ func (l *Logger) toAsync(event string, b []byte) bool {
// 整段「检查开关 + 入队」都在同一把锁内:Close 也拿这把锁来停止投递,
// 这样 Close 拿到锁时就能确定「要么这条还没入队、要么已经完整入队」。
// 否则消费协程可能先看到空队列就退出,把还在路上的这条日志整条丢掉。
//
// wg 用来告诉 drainAsync「投递临界区里还有人」:Close 必须等这些写入
// 真正入队之后才能关闭队列,否则向已关闭的 channel 发送会 panic。
// 队列满时这里会阻塞,但消费者是独立 goroutine 且不需要这把锁,不会死锁
l.async.mu.Lock()
defer l.async.mu.Unlock()
if l.async.closed {
l.async.mu.Unlock()
// 已开始关闭:退化为同步写入
return false
}
@@ -117,7 +184,13 @@ func (l *Logger) toAsync(event string, b []byte) bool {
l.async.ch = make(chan cacheData, asyncQueueSize)
go l.asyncWorker(l.async.ch)
}
l.async.ch <- cacheData{Event: event, Data: b}
l.async.begin()
ch := l.async.ch
l.async.mu.Unlock()
defer l.async.end() // 必须在解锁之后 Done,保证 begin/end 覆盖整段投递
ch <- cacheData{Channel: l.channel, Event: event, Data: b}
return true
}
@@ -130,7 +203,8 @@ func (l *Logger) asyncWorker(q chan cacheData) {
}
}()
for val := range q {
_, _ = l.store(val.Event, val.Data)
// 按任务里记的 channel 落盘,避免多个 channel 的日志混到根目录
_, _ = l.storeTo(val.Channel, val.Event, val.Data)
}
}
@@ -147,6 +221,11 @@ func (l *Logger) drainAsync() {
return
}
// 关键:必须等「已经进入投递临界区」的写入全部入队之后才能关队列。
// 这里能看到 wg 已经是 0,就说明没有写入还卡在投递路径上,
// 否则 close(q) 之后它们再发就会 panic: send on closed channel
l.async.wg.Wait()
// 关闭队列并等消费者把缓冲里的任务全部处理完。
// 这一步必须真的等到 workerDone:否则 Close 会在消费协程还在写缓冲时
// 就刷盘并关闭文件,最后几条日志会连着句柄一起丢掉