package loggerx import ( "errors" "fmt" "io" "os" "path/filepath" "regexp" "strings" "sync/atomic" "time" ) // 文件操作 // ErrClosed 日志实例已关闭,不再接受新的文件写入 // 导出它以便调用方用 errors.Is(err, loggerx.ErrClosed) 判断, // 而不是去匹配错误字符串 var ErrClosed = errors.New("loggerx: 日志已关闭") // errClosed 内部别名,保持既有引用不变 var errClosed = ErrClosed // 每个文件的写缓冲大小:越大越省系统调用,代价是崩溃时可能丢最后一批日志 const fileBufSize = 32 * 1024 // logFile 一个已打开的日志文件句柄 // 自己管理写缓冲(不用 bufio):bufio.Writer 在底层短写时会永久置位内部错误状态, // 之后所有写入和 Flush 都会失败,已攒下的一批日志会整批丢掉 type logFile struct { file *os.File buf []byte pending int // written 已写进文件的字节数(内存计数) // 按大小切割要判断是否该滚动:每条日志都 Stat 一次要花约 8µs, // 而写了多少字节自己最清楚,只在打开文件时 Stat 一次做基准即可 written int64 // lastUsed 最后一次写入时间(UnixNano),用于回收长期不活跃的句柄 lastUsed atomic.Int64 // closed 句柄是否已关闭 // 一旦关闭,Write 必须直接报错:否则数据只会进内存缓冲, // 而这个句柄已经不在 filePath 里,刷新和关闭都找不到它 —— 日志静默丢失 closed atomic.Bool fileName string // baseName 是 fileNameIn 的结果(不带大小切割的 _N 序号), // 用于判断是否跨了时间切割边界,以及归档时取规范基名 baseName string } // 句柄数量上界 // // 每个句柄 = 1 个文件描述符 + 32KB 缓冲,而 channel 名来自调用方(可能是 // 请求 ID、租户名等)。不设上界时 1000 个不同 channel 名就是 1000 个 fd // 加约 32MB 缓冲,Linux 默认 ulimit -n 1024 会直接 EMFILE,之后写入全失败 const maxOpenHandles = 512 // idleHandleAge 多久没写入的句柄可以被回收 const idleHandleAge = 30 * time.Minute // touch 记录一次写入,用于空闲回收 func (f *logFile) touch() { f.lastUsed.Store(time.Now().UnixNano()) } // idle 句柄是否已经空闲超时 func (f *logFile) idle() bool { last := f.lastUsed.Load() if last == 0 { return false // 刚建好还没写过,先不回收 } return time.Since(time.Unix(0, last)) > idleHandleAge } // Write 写入缓冲,满了就落盘一次 func (f *logFile) Write(b []byte) (int, error) { if f.closed.Load() { // 已关闭的句柄绝不能再收数据:它可能已不在句柄表里, // 进去的字节没有任何人会刷盘,而调用方看到的是成功 return 0, ErrClosed } f.touch() written := 0 for len(b) > 0 { if f.pending == len(f.buf) { if err := f.Flush(); err != nil { return written, err } } n := copy(f.buf[f.pending:], b) f.pending += n written += n b = b[n:] } 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 { n, err := f.file.Write(p) p = p[n:] if err != nil { return err } if n == 0 { return io.ErrShortWrite } } return nil } // Flush 把缓冲写入文件(不做 fsync) func (f *logFile) Flush() error { return f.flushInternal(false) } // flushInternal 落盘缓冲 // keepOnError=true 时,写到一半失败也保留「没写成功的那段」并返回错误; // 默认(false)则丢弃未成功的部分,避免错误恢复路径里反复重试同一段数据 func (f *logFile) flushInternal(keepOnError bool) error { if f.pending == 0 { return nil } n, err := f.file.Write(f.buf[:f.pending]) // 关键:先把「已被内核确认写入」的字节从缓冲里去掉。 // 否则下一次 Flush 会重发这段前缀,日志里出现重复/交错的半条记录, // 同时 written 计数偏低会让 sizeSplit 误判,文件悄悄超限 if n > 0 { f.written += int64(n) copy(f.buf, f.buf[n:f.pending]) f.pending -= n } if err != nil { if !keepOnError { f.pending = 0 // 这段数据已经写坏了,不再重试,避免无限循环 } return err } if f.pending > 0 { // 底层写了(0, nil):不是合法行为,按短写处理 if !keepOnError { f.pending = 0 } return io.ErrShortWrite } 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 if err := f.Flush(); err != nil { errs = append(errs, err) } if f.file != nil { if err := f.file.Sync(); err != nil { errs = append(errs, err) } } return joinErrors(errs) } // Close 落盘并关闭文件 // 先置 closed 标记再关:避免关掉之后还有写入把数据塞进没人刷的缓冲 func (f *logFile) Close() error { if f.closed.Swap(true) { return nil // 重复关闭直接返回,避免二次 Close 污染错误信息 } if f.file == nil { return nil } return joinErrors([]error{f.Sync(), f.file.Close()}) } // joinErrors 汇总错误,Go 1.20+ 用 errors.Join func joinErrors(errs []error) error { var real []error for _, err := range errs { if err != nil { real = append(real, err) } } if len(real) == 0 { return nil } return errors.Join(real...) } // 获取最新的文件名 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 { case FileSplitTimeA: // (年/月/日/时) prefix = time.Now().In(l.option.timeZone).Format("2006/01/02/15") case FileSplitTimeB: // (年/月/日) prefix = time.Now().In(l.option.timeZone).Format("2006/01/02") case FileSplitTimeC: // (年/月-日) prefix = time.Now().In(l.option.timeZone).Format("2006/01-02") case FileSplitTimeD: // (年-月-日-时) prefix = time.Now().In(l.option.timeZone).Format("2006-01-02-15") case FileSplitTimeE: // (年-月-日) prefix = time.Now().In(l.option.timeZone).Format("2006-01-02") } if prefix != "" { prefix = prefix + "_" } if channel != "" { prefix = sanitizeChannel(channel) + "/" + prefix } return l.option.dir + "/" + prefix + event + ".log" } // channelNameRe channel 名允许的字符:字母数字与 . _ - var channelNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`) // sanitizeChannel 清洗 channel 名,防止它被当成路径片段逃出日志目录 // // channel 名经常来自请求参数(tenant、requestID 等),直接拼进路径会出问题: // // Channel("../../etc") 会写到日志目录外面,而且逃出保留期清理的扫描范围 // // 非法字符统一替换成 '_':既不丢这条日志,也不会写到别处去 func sanitizeChannel(channel string) string { if channelNameRe.MatchString(channel) && channel != "." && channel != ".." { return channel } out := make([]rune, 0, len(channel)) for _, r := range channel { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': out = append(out, r) default: out = append(out, '_') } } s := strings.Trim(string(out), ".") if s == "" { s = "channel" } if len(s) > 64 { s = s[:64] } return s } // 新建文件 func (l *Logger) getFile(event string) (*logFile, error) { 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 } l.mu.Lock() defer l.mu.Unlock() // 双检:可能在等锁期间已经被别的 goroutine 建好了 if f := l.loadFileLocked(key); f != nil { return f, nil } // 已经关闭:不再新开句柄,否则没人负责关它 if l.closed.Load() { return nil, errClosed } // 上界保护:句柄数已达上限时先回收空闲句柄,仍不够就明确报错, // 而不是无声地把内存和 fd 吃光(channel 名来自调用方,可能是无限的) if len(l.filePath) >= maxOpenHandles { l.evictIdleLocked(true) if len(l.filePath) >= maxOpenHandles { return nil, fmt.Errorf("%w: 已打开 %d 个日志文件句柄,请检查是否在滥用 Channel(如把请求 ID 当 channel 名)", errTooManyHandles, len(l.filePath)) } } lf, err := l.newFile(key) if err != nil { return nil, err } l.filePath[key] = lf return lf, nil } // errTooManyHandles 打开的句柄数超限 var errTooManyHandles = errors.New("loggerx: 日志文件句柄数超过上限") // evictIdleLocked 回收空闲句柄(调用方必须持有 mu 写锁) // force=false 时只回收「确实空闲」的;force=true 时在空闲句柄用尽后 // 按最早使用顺序继续淘汰(此时仍会先落盘 pending 数据,不丢日志) func (l *Logger) evictIdleLocked(force bool) int { type cand struct { key fileKey f *logFile used int64 } cands := make([]cand, 0, len(l.filePath)) for k, f := range l.filePath { if f == nil { delete(l.filePath, k) continue } used := f.lastUsed.Load() if !force && !f.idle() { continue } cands = append(cands, cand{key: k, f: f, used: used}) } // 先淘汰最久没用的 for i := 1; i < len(cands); i++ { for j := i; j > 0 && cands[j].used < cands[j-1].used; j-- { cands[j], cands[j-1] = cands[j-1], cands[j] } } n := 0 for _, c := range cands { // 关闭会先把缓冲刷盘,因此不会丢日志 if err := c.f.Close(); err != nil { l.reportError(fmt.Errorf("loggerx: 回收日志句柄失败 %s: %w", c.f.fileName, err)) continue } delete(l.filePath, c.key) n++ } return n } // sweepIdleHandles 定期回收空闲句柄(由清理协程调用) func (l *Logger) sweepIdleHandles() error { // 必须先拿 writeMu:回收会先落盘再关句柄,而「所有文件 I/O 都在 writeMu 内」 // 是本包的核心不变式,破坏它会和正在进行的写入撞到一起 l.writeMu.Lock() defer l.writeMu.Unlock() l.mu.Lock() defer l.mu.Unlock() l.evictIdleLocked(false) return 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 } } 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: fileName, } 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() // 同时看 .log 与 .log.gz:压缩成功后只剩 .gz, // 漏看会导致新文件撞上已有归档名 if indexInUse(base, idx) { 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 } } // O_EXCL:序号是否可用由调用方(indexInUse)保证,这里再兜一层。 // 绝不能用 O_APPEND 打开别人的归档:那会和正在读取该文件的压缩任务互相踩, // 而且会产生「半条记录 + 整条记录」的坏数据 file, err := os.OpenFile(fileName, os.O_CREATE|os.O_EXCL|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() defer l.mu.RUnlock() return l.loadFileLocked(key) } // loadFileLocked 调用方必须已持有锁 func (l *Logger) loadFileLocked(key fileKey) *logFile { f, ok := l.filePath[key] if !ok || f == nil { return nil } // 时间切割导致文件名变化:关闭旧文件,让调用方按新名字重建。 // 注意比的是不带序号的基名,否则按大小切割出的 _N 文件会被误判成跨天 if f.baseName != l.fileNameIn(key.channel, key.event) { delete(l.filePath, key) f.Close() return nil } return f } // close 关闭所有文件句柄(Close 专用) func (l *Logger) close() error { l.mu.Lock() defer l.mu.Unlock() var errs []error for key, f := range l.filePath { errs = append(errs, f.Close()) delete(l.filePath, key) } return joinErrors(errs) }