- 逐条网络日志(请求完成 / 接口返回异常 / 响应刷新CSRF已同步)从 info/warn 降为 debug: 实测一次 6 账号的模拟抢购里 233 条日志有 174 条(74%)属于这类;真实抢购会产生几千条, 把界面仅 800 条的内存日志冲掉,反而看不到关键信息。HTTP/解析/传输层错误仍为 error 级 - 新增按接口聚合的网络统计,抢购任务结束时输出一行,替代逐条刷屏: "接口统计: 锁定兑换商品 ×341 失败341 平均=95ms 最慢=312ms 最后错误=活动火爆,请稍后再试" - 日志文件开头新增表头:实例名、数据目录、双时间格式说明(本地时间 + D 斗鱼估算时间)、 并说明"校准完成前偏移为 0,只显示本地时间"。双时间一直存在却没有任何地方解释过, 多实例日志复制或汇总后也分不清来源 - 界面日志面板:显示日志级别并按级别着色(此前级别只存在于 CSS class,复制出去就丢); 新增"复制"按钮,按与日志文件一致的单行格式导出(此前直接复制会变成 5 行一条、 1204 行里其实只有 233 条日志) - 设置页"日志显示等级"补充说明:Info 起只显示关键日志,需要逐请求细节时切到 Debug - 新增测试:TestRequestStatsAggregatesByLabel、TestLogFileHeaderDocumentsFormat、 TestRunNowLogsAggregateInsteadOfPerRequest(断言必须有聚合行、且不得出现逐条请求日志)
302 lines
8.7 KiB
Go
302 lines
8.7 KiB
Go
package logger
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"douyu_go/internal/constants"
|
|
"douyu_go/internal/types"
|
|
)
|
|
|
|
const (
|
|
asyncLogBufferSize = 8192
|
|
logTimeLayout = "2006/01/02 15:04:05.000"
|
|
logEntryTimeLayout = "2006-01-02 15:04:05.000"
|
|
)
|
|
|
|
// minLevel 日志最低等级,低于此等级的日志不输出。0=info, 1=warn, 2=error。
|
|
var minLevel atomic.Int32
|
|
|
|
var douyuOffset atomic.Int64
|
|
|
|
// SetMinLevel 设置日志最低显示等级。
|
|
func SetMinLevel(level string) {
|
|
minLevel.Store(int32(levelPriority(level)))
|
|
}
|
|
|
|
// SetDouyuOffset 设置斗鱼服务端时间与本地时间的偏移量,用于日志双时间显示。
|
|
func SetDouyuOffset(offset time.Duration) {
|
|
douyuOffset.Store(int64(offset))
|
|
}
|
|
|
|
func levelPriority(level string) int {
|
|
switch strings.ToLower(level) {
|
|
case "debug":
|
|
return -1
|
|
case "info":
|
|
return 0
|
|
case "warn":
|
|
return 1
|
|
case "error":
|
|
return 2
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// AsyncLogWriter 异步日志写入器,队列满时丢弃日志以保护主链路。
|
|
type AsyncLogWriter struct {
|
|
mu sync.RWMutex
|
|
dst io.Writer
|
|
ch chan []byte
|
|
wg sync.WaitGroup
|
|
closed uint32
|
|
dropped uint64
|
|
}
|
|
|
|
// NewAsyncLogWriter 创建异步日志写入器。
|
|
func NewAsyncLogWriter(dst io.Writer, buffer int) *AsyncLogWriter {
|
|
if buffer <= 0 {
|
|
buffer = 1024
|
|
}
|
|
w := &AsyncLogWriter{
|
|
dst: dst,
|
|
ch: make(chan []byte, buffer),
|
|
}
|
|
w.wg.Add(1)
|
|
go w.run()
|
|
return w
|
|
}
|
|
|
|
// Write 异步写日志;队列满时丢弃,避免抢购请求被磁盘 I/O 阻塞。
|
|
func (w *AsyncLogWriter) Write(p []byte) (int, error) {
|
|
if w == nil {
|
|
return len(p), nil
|
|
}
|
|
w.mu.RLock()
|
|
defer w.mu.RUnlock()
|
|
if atomic.LoadUint32(&w.closed) == 1 {
|
|
return len(p), nil
|
|
}
|
|
buf := append([]byte(nil), p...)
|
|
select {
|
|
case w.ch <- buf:
|
|
default:
|
|
atomic.AddUint64(&w.dropped, 1)
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (w *AsyncLogWriter) run() {
|
|
defer w.wg.Done()
|
|
for p := range w.ch {
|
|
_, _ = w.dst.Write(p)
|
|
}
|
|
if dropped := atomic.LoadUint64(&w.dropped); dropped > 0 {
|
|
_, _ = fmt.Fprintln(w.dst, formatLogLine(time.Now(), "日志", "warn", "", fmt.Sprintf("日志队列已丢弃 %d 条,已优先保护抢购链路", dropped)))
|
|
}
|
|
}
|
|
|
|
// Close 关闭写入器,等待队列清空。
|
|
func (w *AsyncLogWriter) Close() error {
|
|
if w == nil {
|
|
return nil
|
|
}
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if atomic.CompareAndSwapUint32(&w.closed, 0, 1) {
|
|
close(w.ch)
|
|
w.wg.Wait()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sink 日志输出汇集点,把结构化日志条目分发到内存、文件和终端。
|
|
type sink struct {
|
|
async *AsyncLogWriter // 文件异步写入(无文件时为 nil,仅写内存+终端)
|
|
store *MemoryLogStore
|
|
stdout bool
|
|
}
|
|
|
|
// write 分发一条日志:内存同步写入,文件异步写入,终端尽力写入。
|
|
func (s *sink) write(entry types.LogEntry) {
|
|
s.store.Append(entry)
|
|
if s.async != nil {
|
|
_, _ = s.async.Write([]byte(entry.Message + "\n"))
|
|
}
|
|
if s.stdout {
|
|
_, _ = os.Stdout.Write([]byte(entry.Message + "\n"))
|
|
}
|
|
}
|
|
|
|
var globalSink *sink
|
|
|
|
// InitLogging 初始化日志输出:日志文件和内存缓冲同时写入。
|
|
// 终端输出通过 sink 同步写入(Windows GUI 应用无控制台时自动跳过)。
|
|
//
|
|
// instanceName / dataDir 只用于写文件表头,便于把日志复制/汇总后仍能看出
|
|
// 是哪台机器的哪个实例。
|
|
func InitLogging(store *MemoryLogStore, logDir, instanceName, dataDir string) func() {
|
|
log.SetFlags(0)
|
|
log.SetPrefix("")
|
|
|
|
if logDir == "" {
|
|
logDir = constants.DefaultLogDir
|
|
}
|
|
|
|
// 解析为绝对路径,避免打包后工作目录不同导致日志写到意外位置
|
|
logDir = resolveAbsolutePath(logDir)
|
|
|
|
if err := os.MkdirAll(logDir, 0755); err != nil {
|
|
globalSink = &sink{store: store, stdout: true}
|
|
LogEvent("日志", "warn", "日志目录创建失败,仅写入内存: %v", err)
|
|
return func() {}
|
|
}
|
|
|
|
base := filepath.Join(logDir, "douyu_"+time.Now().In(constants.CST).Format("20060102_150405"))
|
|
file, filePath, err := createUniqueLogFile(base)
|
|
if err != nil {
|
|
globalSink = &sink{store: store, stdout: true}
|
|
LogEvent("日志", "warn", "日志文件创建失败,仅写入内存: %v", err)
|
|
return func() {}
|
|
}
|
|
|
|
// 表头同步写入,确保排在任何日志之前(异步写入器还没启动)。
|
|
// 写失败不影响日志本身。
|
|
_, _ = file.WriteString(logFileHeader(instanceName, dataDir))
|
|
|
|
store.SetFilePath(filePath)
|
|
globalSink = &sink{
|
|
async: NewAsyncLogWriter(file, asyncLogBufferSize),
|
|
store: store,
|
|
stdout: true,
|
|
}
|
|
LogEvent("日志", "success", "日志文件已启用: %s", filePath)
|
|
return func() {
|
|
if globalSink != nil && globalSink.async != nil {
|
|
_ = globalSink.async.Close()
|
|
}
|
|
_ = file.Close()
|
|
}
|
|
}
|
|
|
|
// logFileHeader 说明日志格式与运行上下文。
|
|
//
|
|
// 双时间(本地时间 + 斗鱼估算时间)一直存在,但日志里没有任何地方解释过;
|
|
// 而且时间校准完成前偏移为 0、只显示本地时间,看起来像格式不一致。
|
|
// 把格式、实例名、数据目录写在文件开头,复制或汇总多实例日志时就分得清。
|
|
func logFileHeader(instanceName, dataDir string) string {
|
|
if instanceName == "" {
|
|
instanceName = "未知实例"
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("==================================================================\n")
|
|
fmt.Fprintf(&b, "实例: %s\n", instanceName)
|
|
if dataDir != "" {
|
|
fmt.Fprintf(&b, "数据目录: %s\n", dataDir)
|
|
}
|
|
b.WriteString("时间格式: 本地时间(D斗鱼估算时间) [类别][级别][账号] 正文\n")
|
|
b.WriteString(" D 为经服务端校准的斗鱼时间估算;校准完成前偏移为 0,只显示本地时间\n")
|
|
b.WriteString("网络逐条请求为 debug 级,默认不显示;排查时把配置 logLevel 改成 debug\n")
|
|
b.WriteString("==================================================================\n")
|
|
return b.String()
|
|
}
|
|
|
|
// createUniqueLogFile 以 O_EXCL 递增后缀创建日志文件。
|
|
//
|
|
// 早期实现用 O_CREATE|O_APPEND + 秒级时间戳:同一目录下同秒启动的两个进程会
|
|
// 打开同一个文件并交错写入,日志既分不清实例,也会互相污染。现在改用独占创建,
|
|
// 冲突时自动追加 -2 / -3 后缀。
|
|
func createUniqueLogFile(base string) (*os.File, string, error) {
|
|
for i := 1; i <= 50; i++ {
|
|
path := base + ".log"
|
|
if i > 1 {
|
|
path = fmt.Sprintf("%s-%d.log", base, i)
|
|
}
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
|
if err == nil {
|
|
return file, path, nil
|
|
}
|
|
if !errors.Is(err, os.ErrExist) {
|
|
return nil, "", err
|
|
}
|
|
}
|
|
return nil, "", fmt.Errorf("日志文件名连续冲突: %s", base)
|
|
}
|
|
|
|
// LogEvent 输出带分类和级别的结构化文本日志。低于当前最低等级的日志会被丢弃。
|
|
func LogEvent(category, level, format string, args ...any) {
|
|
logStructuredEvent(category, level, "", format, args...)
|
|
}
|
|
|
|
// LogAccountEvent 输出带账号标签的结构化日志,便于前端按账号观察抢购链路。
|
|
func LogAccountEvent(category, level, account, format string, args ...any) {
|
|
logStructuredEvent(category, level, account, format, args...)
|
|
}
|
|
|
|
func logStructuredEvent(category, level, account, format string, args ...any) {
|
|
if category == "" {
|
|
category = "系统"
|
|
}
|
|
if level == "" {
|
|
level = "info"
|
|
}
|
|
if levelPriority(level) < int(minLevel.Load()) {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
message := fmt.Sprintf(format, args...)
|
|
entry := types.LogEntry{
|
|
Time: now.In(constants.CST).Format(logEntryTimeLayout),
|
|
Level: level,
|
|
Category: category,
|
|
Account: account,
|
|
Message: formatLogLine(now, category, level, account, message),
|
|
}
|
|
if globalSink != nil {
|
|
globalSink.write(entry)
|
|
}
|
|
}
|
|
|
|
func formatLogLine(now time.Time, category, level, account, message string) string {
|
|
ts := now.In(constants.CST).Format(logTimeLayout)
|
|
offset := time.Duration(douyuOffset.Load())
|
|
var prefix string
|
|
if offset != 0 {
|
|
dyTime := now.Add(offset)
|
|
prefix = fmt.Sprintf("%s(D%02d:%02d:%02d.%03d) ", ts,
|
|
dyTime.Hour(), dyTime.Minute(), dyTime.Second(), dyTime.Nanosecond()/1e6)
|
|
} else {
|
|
prefix = ts + " "
|
|
}
|
|
if account != "" {
|
|
return fmt.Sprintf("%s[%s][%s][账号:%s] %s", prefix, category, level, account, message)
|
|
}
|
|
return fmt.Sprintf("%s[%s][%s] %s", prefix, category, level, message)
|
|
}
|
|
|
|
// resolveAbsolutePath 将相对路径解析为绝对路径。
|
|
// 如果路径已经是绝对路径则直接返回;否则基于可执行文件所在目录解析。
|
|
func resolveAbsolutePath(path string) string {
|
|
if filepath.IsAbs(path) {
|
|
return path
|
|
}
|
|
// 先尝试基于当前工作目录解析
|
|
if abs, err := filepath.Abs(path); err == nil {
|
|
return abs
|
|
}
|
|
// 兜底:基于可执行文件所在目录
|
|
if exe, err := os.Executable(); err == nil {
|
|
return filepath.Join(filepath.Dir(exe), path)
|
|
}
|
|
return path
|
|
}
|