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

538 lines
15 KiB
Go
Raw 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 (
"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)
}