Files
yml2213 87f9c7994d 按实测修正锁单后支付间隔并在日志表头补充版本号,发布0.3.3
- 锁单后首次支付的等待从 0.3-0.7 秒改回 2.3-3.0 秒。斗鱼对"锁单→支付"存在最短间隔:
  2026-10-03 19:38 的模拟里,+0.50s/+0.60s/+1.37s/+1.68s 发起的支付一律被
  "您点击太快,请稍等再试"拒绝,而 +2.56s/+3.30s 发起的都成功;19:20 那次在
  +2.1s~+3.9s 发起的 6 笔全部一次通过,阈值落在 (1.68s, 2.1s] 之间。
  0.3.1 把等待压到 0.3-0.7 秒踩进该区间,6 个号各白挨两次拒绝,反而多花 2.7 秒、
  多打 12 个请求——上一版"2-4 秒纯属浪费"的判断是错的,那段等待本就是躲这条服务端规则
- 日志文件表头补充版本号与构建时间:此前无法从日志判断用户跑的是哪个包
- 新增测试断言表头包含版本号
2026-10-03 19:42:00 +08:00

307 lines
8.9 KiB
Go
Raw Permalink 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 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 constants.BuildTime != "unknown" {
fmt.Fprintf(&b, "版本: %s(构建 %s)\n", constants.Version, constants.BuildTime)
} else {
fmt.Fprintf(&b, "版本: %s\n", constants.Version)
}
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
}