Go 客服系统项目守护进程与日志体系重构实战

背景

最近对一个 Go 客服系统做了几项工程化修复,涉及守护进程库自研替代日志按日滚动与双写问题根治、以及业务逻辑精确化。这篇文章把思路和代码完整梳理一遍。


一、问题:守护模式下的双日志文件

项目之前用第三方库 github.com/zh-five/xdaemon 做进程守护,同时自研了 DailyLogWriter 实现按日滚动日志。但守护模式下 logs/ 目录总出现两个文件:

kefu.log          ← xdaemon 的 fd 重定向产生的
kefu-2026-06-02.log  ← DailyLogWriter 按日生成的

根因在于两条写入路径

flowchart TD
    A[守护进程 fork] --> B[xdaemon 打开 kefu.log]
    B --> C["子进程 fd1/fd2 → kefu.log<br/>(os 级别重定向)"]
    D[SetupDailyLog] --> E[os.Pipe 接管 os.Stdout/Stderr]
    E --> F["pipe → DailyLogWriter → kefu-2026-06-02.log"]
    C -.-> G[❌ kefu.log]
    F -.-> H[✅ kefu-2026-06-02.log]
    style G fill:#ffcccc
    style H fill:#ccffcc

xdaemon 在 OS 层面把子进程的 fd 1/2 重定向到了 kefu.log,而我们的 SetupDailyLog 通过 os.Pipe 在 Go 层面接管了 os.Stdout/os.Stderr,两个写入源指向不同文件,必然产生两个文件。


二、方案:自研守护进程 + 统一日志管道

设计思路:守护进程不碰日志文件,日志怎么落盘完全由 DailyLogWriter 控制。子进程通过继承父进程的 pipe fd 自然接入同一套日志管道。

flowchart LR
    subgraph 父进程
        A[原始进程] -->|fork| B[守护进程]
        B -->|pipe 继承| C["DailyLogWriter<br/>→ kefu-2026-06-02.log"]
    end
    subgraph 子进程
        D[业务子进程] -->|fd 继承| C
    end
    B -->|崩了? 重启| D
    style C fill:#ccffcc

关键点:不显式设置 cmd.Stdout/cmd.Stderr,让子进程继承父进程已被 pipe 接管的 fd,这样守护进程的 log.Printf 和子进程的 fmt.Println 全部进入同一个 DailyLogWriter


三、代码详解

3.1 按日滚动日志——tools/logrotate.go

// SetupDailyLog 将终端全部输出(stdout + stderr + log 包)接入按日滚动文件,只生成一个日志文件
// 返回 DailyLogWriter 供调用方设置 gin.DefaultWriter / gin.DefaultErrorWriter
func SetupDailyLog(logDir, prefix string, keepDays int) *DailyLogWriter {
    w := NewDailyLogWriter(logDir, prefix, keepDays)
    w.CleanOldLogs()

    // 标准库 log 输出 → 按日文件
    log.SetOutput(w)

    // 接管 stdout / stderr,确保 fmt.Println、panic 堆栈、GORM SQL 等全部进入按日文件
    r, pipeW, err := os.Pipe()
    if err == nil {
        os.Stdout = pipeW
        os.Stderr = pipeW
        go func() {
            io.Copy(w, r)
        }()
    }
    return w
}

两步接管:

  1. log.SetOutput(w) → 标准库 log 包的输出走 DailyLogWriter
  2. os.Pipe + goroutine → os.Stdout/os.Stderr 的写入也通过 pipe 转发到同一个 writer

跨天自动切文件逻辑:

func (w *DailyLogWriter) Write(p []byte) (int, error) {
    w.mu.Lock()
    defer w.mu.Unlock()

    today := time.Now().Format("2006-01-02")
    if today != w.curDay {
        if w.file != nil {
            w.file.Close()
            w.file = nil
        }
        w.curDay = today
        filename := filepath.Join(w.dir, w.prefix+"-"+today+".log")
        f, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
        if err != nil {
            return 0, err
        }
        w.file = f
        w.CleanOldLogs()
    }
    if w.file != nil {
        return w.file.Write(p)
    }
    return len(p), nil
}

每次 Write 时检查日期,变了就关闭旧文件、打开新文件,同时清理过期日志。

3.2 守护进程核心——tools/daemon.go

func RunDaemon(maxError int, minExitTime int64) {
    // 第一步: fork 到后台, 原始进程退出
    background(true)

    // ---- 以下只有守护进程执行 ----
    var t int64
    count := 1
    errNum := 0
    for {
        if maxError > 0 && errNum > maxError {
            log.Printf("守护进程(pid:%d): 连续异常%d次, 退出守护", os.Getpid(), errNum)
            os.Exit(1)
        }
        count++

        t = time.Now().Unix()
        cmd, isChild := background(false)

        if isChild {
            log.Printf("子进程 pid=%d: 开始运行...", os.Getpid())
            return
        }

        if cmd == nil {
            log.Println("守护进程: 子进程启动失败, 稍后重试")
            errNum++
            time.Sleep(2 * time.Second)
            continue
        }

        // 守护进程: 等待子进程退出
        err := cmd.Wait()
        elapsed := time.Now().Unix() - t

        if elapsed < minExitTime {
            errNum++
        } else {
            errNum = 0
        }

        log.Printf("守护进程(pid:%d count:%d errNum:%d/%d): 子进程(%d)退出, 运行%d秒: %v",
            os.Getpid(), count-1, errNum, maxError, cmd.ProcessState.Pid(), elapsed, err)
    }
}

进程树识别机制——用环境变量 KEFU_DAEMON_IDX 区分父子进程:

func background(isExit bool) (*exec.Cmd, bool) {
    daemonRunIdx++
    envIdx, _ := strconv.Atoi(os.Getenv(daemonEnvName))

    // 环境变量标记 > 当前计数 → 已是子进程
    if daemonRunIdx <= envIdx {
        return nil, true
    }

    // 父进程: 构造子进程启动命令
    env := os.Environ()
    env = append(env, fmt.Sprintf("%s=%d", daemonEnvName, daemonRunIdx))

    cmd := &exec.Cmd{
        Path:        os.Args[0],
        Args:        os.Args,
        Env:         env,
        SysProcAttr: newSysProcAttr(),
    }
    // 不显式设置 Stdout/Stderr, 子进程继承父进程的管道(由 SetupDailyLog 建立)
    if err := cmd.Start(); err != nil {
        log.Printf("守护进程: 启动子进程失败: %v", err)
        if isExit {
            os.Exit(1)
        }
        return nil, false
    }

    log.Printf("守护进程: 启动子进程成功 -> pid=%d", cmd.Process.Pid)

    if isExit {
        os.Exit(0)
    }
    return cmd, false
}

每次 background() 调用将 daemonRunIdx +1 并写入子进程环境变量。子进程启动后读到 envIdx >= daemonRunIdx,就知道自己是子进程。

3.3 平台差异——属性文件

WindowsHideWindow: true 避免弹黑窗

func newSysProcAttr() *syscall.SysProcAttr {
    return &syscall.SysProcAttr{
        HideWindow: true,
    }
}

Unix/LinuxSetsid: true 脱离终端会话

func newSysProcAttr() *syscall.SysProcAttr {
    return &syscall.SysProcAttr{
        Setsid: true,
    }
}

3.4 调用入口——command/server.go

// 初始化守护进程
func initDaemon() {
    if daemon == true {
        // 先设置按天滚动日志,确保守护进程和子进程都走同一套日志
        dailyWriter := tools.SetupDailyLog(common.LogDirPath, "kefu", 7)
        gin.DefaultWriter = dailyWriter
        gin.DefaultErrorWriter = dailyWriter
        // 以后台守护模式运行,崩溃自动重启
        tools.RunDaemon(5, 10) // 连续5次异常退出, 运行不足10秒算异常
    }
}

注意执行顺序:SetupDailyLog 建立 pipe,再 RunDaemon fork。这样子进程继承 pipe fd,零额外配置就接入同一套日志。


四、抖音事件过滤——im_enter_direct_msg 不触发 AI 回复

    //判断是否自动回复
    if userDouyin.MessageStatus != "1" {
        return
    }

    dingConfig := models.GetEntConfigsMap(userDouyin.EntId, "QdrantAIStatus", "DouyinSixinReplyDelay", "KeywordFinalReply")

    //调用自动回复
    isKeywords := false
    if result == "" {
        result = GetLearnReplyContent(userDouyin.EntId, content, false)
        if result != "" {
            isKeywords = true
        }
    }
    if result == "" && dingConfig["QdrantAIStatus"] == "true" && models.CheckVisitorRobotReply(vistorInfo.State) && eventType != "im_enter_direct_msg" {
        //调用GPT3.5
        result = ws.Gpt3Knowledge(userDouyin.EntId, visitorId, kefuInfo, content)
    }

im_enter_direct_msg 是用户进入私信会话页面时抖音推送的事件,不代表用户发了新消息。如果不加过滤,每次访客点进聊天窗口就会触发 AI 自动回复,体验很差。修复后该事件在 Qdrant AI 判断处被拦截,关键词匹配等更前面的逻辑也一并被跳过(因为前面 MessageStatus 或其他条件也会自然过滤,但为了明确语义,此处显式加了判断)。


五、Go Module 清理

go.mod 中移除 github.com/zh-five/xdaemon v0.1.1 依赖,执行 go mod tidy 清理。项目不再依赖任何第三方守护进程库。


六、总结

改动项之前之后
守护进程库外部 xdaemon自研 tools/daemon.go(108 行)
日志文件数守护模式 2 个任何模式 1 个 kefu-YYYY-MM-DD.log
fd 继承xdaemon 显式重定向 fd子进程继承父进程 pipe,透明
API 调用d.Run() + 多项配置tools.RunDaemon(5, 10)
崩溃重启
im_enter_direct_msg触发 AI 回复跳过,不回复

核心设计原则:进程守护只做进程管理,日志只归日志系统管。两个关注点彻底解耦,代码职责清晰,维护成本显著降低。

程序员老狼

程序员老狼

新浪前高级开发工程师,Golang、PHP 全栈开发者,十余年后端架构实战经验。自研唯一客服系统及配套浏览器自动化插件,专注企业客服生态与 RPA 自动化技术。

了解更多 → 企业备案域名 · 聊城变量网络科技有限公司