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