背景
最近对一个 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
}
两步接管:
log.SetOutput(w)→ 标准库log包的输出走DailyLogWriteros.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 平台差异——属性文件
Windows:HideWindow: true 避免弹黑窗
func newSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
HideWindow: true,
}
}
Unix/Linux:Setsid: 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 回复 | 跳过,不回复 |
核心设计原则:进程守护只做进程管理,日志只归日志系统管。两个关注点彻底解耦,代码职责清晰,维护成本显著降低。