返回

Go程序连接MariaDB诡异报错排查与解决

Linux

Go 程序中 "dial unix ... no such file or directory" 怪异现象排查与解决

最近遇到了一个非常头疼的问题, 几乎要抓狂了, 在使用 Go 语言的 database/sql 包连接 MariaDB 时, 报 "dial unix /var/lib/mysql/mysql.sock: connect: no such file or directory" 错误, 但情况很诡异。这似乎是个系统层面问题,所以这篇文章会从多个角度排查。

一、问题现象复现

简单一下:我用 Go 程序连接 CentOS Stream 8 上的 MariaDB 10.3.28,通过 UNIX Socket 通信。我已经禁用了 fapolicydSELinux,通常情况下这俩货最容易惹麻烦。

怪事来了:

  1. 连接数据库 之前,我用 os.Stat("/var/lib/mysql/mysql.sock") 检查文件,一切正常。
  2. 数据库连接成功,执行查询,数据返回,也都 OK。
  3. 但是!连接数据库 之后,再次 os.Stat 就会报 stat /var/lib/mysql/mysql.sock: no such file or directory
  4. 当连接池里的连接因为 SetConnMaxLifetime 到期后,就无法重连了,所有查询都失败(报同样的错)。
  5. 更神奇的是,我调用 db.Close() 之后os.Stat 又能找到 /var/lib/mysql/mysql.sock 了!

这咋回事?Socket 文件一直存在,难道是系统在数据库连接期间阻止了进程访问这个 Socket?

Socket 文件权限是 0777,所有父目录都有 o+x 权限。MariaDB 的一些配置如下:

max_connections=200
wait_timeout=120
open_files_limit=1200

local_infile=0
skip_name_resolve
skip_show_database
symbolic_links=0

太奇怪了!希望有人能指点一下该怎么排查这个问题。

二、问题原因分析

出现这种现象, 可能的原因有很多, 且往往与操作系统、文件系统、以及 MySQL/MariaDB 的配置和行为有关。下面列出一些潜在的原因:

  1. 文件符耗尽: Go 的 database/sql 包内部使用了连接池。 每个数据库连接都会占用一个文件描述符。 如果程序打开了大量连接,而没有及时关闭,或者系统的文件描述符限制设置得太低,可能会导致无法创建新的 Socket 连接, 甚至是无法stat已经存在的socket文件。
  2. Socket 文件被意外删除或移动: 尽管看起来不太可能,但在某些情况下,可能存在其他进程或脚本意外地删除或移动了 mysql.sock 文件。尽管这种可能性很小,还是值得检查。
  3. 挂载问题: /var/lib/mysql 目录可能是通过某种方式挂载的(例如 NFS、Samba 等),在网络或存储出现问题时,可能会导致文件系统访问异常。
  4. AppArmor/LSM: 虽然已经禁用了SELinuxfapolicyd,但内核有可能存在其他安全限制.
  5. 内核bug/系统调用拦截: 非常罕见的情况下,可能存在内核级别的 bug,或者某些安全软件/监控工具拦截了系统调用,导致 stat 操作失败。
  6. 并发问题: 如果在 Go 程序中, 多个 goroutine 并发地访问同一个数据库连接,可能存在一些未知的并发问题导致连接状态异常.
  7. MySQL/MariaDB Bug: 虽然已经排除了一些常见的配置问题,但 MariaDB 本身也可能存在一些罕见的 Bug,导致 Socket 文件在特定条件下出现问题。
  8. 磁盘空间不足/inode 耗尽: 如果 /var/lib/mysql 所在的磁盘分区空间不足,或者 inode 耗尽,可能会导致无法创建新的文件(包括 Socket 文件),尽管已有的 Socket 文件的 stat 调用可能会看起来不一致.
  9. 长路径名或特殊字符: 尽管本例中路径名并不长,但在某些极端情况下,如果路径名非常长,或者包含特殊字符,可能会导致 stat 操作失败。

三、解决方案与排查步骤

针对以上可能的原因, 这里提供相应的解决思路与步骤. 重要的是按步骤逐一排除, 不要跳过。

1. 检查文件描述符限制

  • 原理: Linux 系统对每个进程可以打开的文件描述符数量有限制。

  • 操作:

    • 查看当前用户的限制:ulimit -n
    • 查看整个系统的限制:cat /proc/sys/fs/file-max
    • 查看当前进程已打开的文件描述符数量: ls -l /proc/$PID/fd | wc -l (将 $PID 替换为你的 Go 程序的进程 ID)
*  **代码(如果你的程序中确实打开了很多连接,并且怀疑这个问题)** 

    ```go
    import (
        "database/sql"
        "fmt"
        "log"
        "time"
    		_ "github.com/go-sql-driver/mysql"
    )
    
    func main() {
          db, err := sql.Open("mysql", "user:password@unix(/var/lib/mysql/mysql.sock)/dbname")
    	if err != nil {
    		log.Fatal(err)
    	}
    	defer db.Close()
    
        //设置数据库的最大打开连接数
    	db.SetMaxOpenConns(25)  //可以从一个较小的值尝试,逐步调大。
      // 设置最大空闲连接数, 该值最好不大于最大打开连接数。
        db.SetMaxIdleConns(5)
         // 设置连接的最大生存时间,避免连接长时间不活动。
        db.SetConnMaxLifetime(5 * time.Minute)

        // 尝试连接数据库,做一些操作....

    	err = db.Ping()
        if err!=nil {
            fmt.Printf("db.Ping error:%v\n", err)
        }

        fmt.Println("Connected!")

        //你的其他逻辑...

    }
    ```
    **重要提示:**  修改完代码后,一定要重启 Go 程序,让新的设置生效。
    **进阶技巧:**  可以使用 `netstat -anp | grep mysql.sock` 进一步分析 Socket 连接的状态, 查看是否有大量 `CLOSE_WAIT` 或 `TIME_WAIT` 状态的连接. 如果有很多处于 `CLOSE_WAIT` 状态, 通常表示应用程序没有正确关闭连接。

2. 监控 Socket 文件

  • 原理: 持续监控 mysql.sock 文件,查看是否有其他进程对其进行了操作。

  • 操作:

    watch -n 1 'ls -l /var/lib/mysql/mysql.sock'
    

    或者,使用更强大的工具: inotifywait:

    inotifywait -m -r -e delete,move,create /var/lib/mysql
    

    这个命令会持续监控 /var/lib/mysql 目录下的文件创建、删除和移动事件。 如果在你的 Go 程序运行期间,mysql.sock 被删除或移动了,这里会显示出来。

3. 检查挂载情况

  • 原理: 确保 /var/lib/mysql 没有挂载问题。

  • 操作:

    mount | grep /var/lib/mysql
    df -h /var/lib/mysql
    

    查看挂载类型和磁盘空间使用情况。如果有输出,检查挂载选项是否正常(例如,是否有 ro 选项表示只读)。

4. 检查AppArmor/LSM (可选,但很重要)

  • 原理 : 即使禁用了 SELinux, AppArmor 或其他 Linux 安全模块 (LSM) 仍可能影响程序行为。

  • 操作
    * 查看AppArmor状态: sudo apparmor_status

    如果启用了 AppArmor, 可以尝试暂时禁用它(作为测试!不要在生产环境长期禁用)
    ```bash
    sudo systemctl stop apparmor
    sudo systemctl disable apparmor  # 彻底禁用,需要重启
    ```
    
    • 查看系统中安装的其他LSM, CentOS 默认可能没有安装其他LSM:
      cat /sys/kernel/security/lsm
      如果有输出,可能表示有其他安全模块。查阅其文档解决.

5. 内核与系统调用排查 (进阶)

  • 原理: 如果怀疑是内核bug 或 存在系统调用被拦截. 可以使用 strace 工具追踪 Go 程序进行的系统调用.

  • 操作

     strace -f -p $PID -e trace=file,network -o strace.log
    
将 `$PID` 替换为 Go 程序的进程ID。 这会把所有与文件和网络相关的系统调用记录到 `strace.log` 文件中。 分析这个日志文件,特别关注 `stat`、`connect`、`socket` 等系统调用的返回值。如果 `stat` 返回 `-1 ENOENT (No such file or directory)`,即使文件存在,那么很可能就是内核或系统调用的问题。

  注意: `strace` 会带来性能开销,建议只在测试环境使用。

* **strace更简单的用法:**  我们可以使用 `strace` 来直接运行并追踪 Go程序:
  ```bash
   strace -f -o strace_output.log go run your_go_program.go
  ```
   `-f` 选项让`strace`同时追踪子进程,因为`go run`会编译并启动一个新进程

6. 检查并发 (代码审查)

  • 原理: 检查代码,确保没有多个 goroutine 同时操作同一个数据库连接对象。 database/sql 的连接对象本身不是并发安全的. 连接池是安全的。

  • 操作: 仔细阅读代码,特别是与数据库操作相关的部分。 如果有多个 goroutine,确保它们要么使用不同的数据库连接,要么通过 channel 或 mutex 等机制进行同步。

* **代码改进的示例 (如果确实存在并发访问同一个连接的情况):** 

    ```go
    // 假设有一个全局的 db *sql.DB
    var db *sql.DB
    
    func queryData(query string, args ...interface{}) ([]Result, error) {
        // 从连接池获取一个连接。
        conn, err := db.Conn(context.Background())
        if err != nil {
            return nil, err
        }
        defer conn.Close() // 重要:用完后立即归还连接
    
        // 现在,你可以在这个 *sql.Conn 上安全地执行操作,而不会与其他 goroutine 冲突。
        rows, err := conn.QueryContext(context.Background(), query, args...)
          if err != nil{
           return nil,err
        }
        defer rows.Close()
          //...
    }
    ```
      关键点: 从 `db.Conn()` 获取一个独立的连接 (`*sql.Conn`), 然后在这个独立的连接上执行操作, 最后, 使用`defer conn.Close()` 确保连接被归还到连接池。

7. 升级 MariaDB / 尝试其他 MySQL 驱动

  • 原理: 如果怀疑是 MariaDB 的 Bug,可以尝试升级到最新版本,或者回退到之前的稳定版本。

  • 操作 :

    • 根据 MariaDB 官方文档的指引,升级或降级 MariaDB。
    • 可以尝试不同的 Go MySQL驱动:
      • go-sql-driver/mysql (这是你目前正在使用的)
      • mysql-master
        不同的驱动可能有不同的实现,有助于判断问题是否与特定的驱动有关。

8.检查磁盘/Inode

  • 原理: 磁盘满或者 inode 耗尽可能导致看似奇怪的问题.

  • 操作:

    df -h  #检查磁盘空间
    df -i  #检查inode 使用情况
    

9. 简化测试

  • 原理: 编写一个最简 Go 程序来复现问题,排除其他代码干扰。

  • 操作:
    ```go
    package main

    import (
    "database/sql"
    "fmt"
    "log"
    "os"
    "time"

    _ "github.com/go-sql-driver/mysql"
    

    )

    func main() {
    socketPath := "/var/lib/mysql/mysql.sock"

    // 检查 socket 文件是否存在(首次)
    _, err := os.Stat(socketPath)
    if err != nil {
    	log.Fatal("Initial stat error:", err)
    }
    fmt.Println("Initial stat OK")
    
    // 连接数据库
    dsn := fmt.Sprintf("user:password@unix(%s)/dbname", socketPath)
    db, err := sql.Open("mysql", dsn)
    if err != nil {
    	log.Fatal("Database connection error:", err)
    }
    defer db.Close()
     db.SetConnMaxLifetime(3*time.Second)
    
    
    // Ping 数据库
    err = db.Ping()
    if err != nil {
    	log.Fatal("Ping error:", err)
    }
    fmt.Println("Ping OK")
    
    // 再次检查 socket 文件是否存在
    _, err = os.Stat(socketPath)
    if err != nil {
    	log.Println("Stat error after connection:", err) // 注意这里使用 log.Println
    } else {
    	fmt.Println("Stat OK after connection")
    }
    

    //等待连接过期
    time.Sleep(5*time.Second)
    // 再次检查 socket 文件是否存在
    _, err = os.Stat(socketPath)
    if err != nil {
    log.Println("Stat error after sleep:", err)
    } else {
    fmt.Println("Stat OK after sleep")
    }
    //可以根据需要, 再发起新的ping()测试.
    }

   ```
   运行这段最简代码。如果问题依然存在,可以把这段代码提供给其他人,帮助他们复现和分析问题。 如果问题在这个简化版本中消失了, 那么问题很可能出在你原始程序的其他部分, 可以逐步对比查找.

通过以上这些步骤,你应该能够找到问题的根源并解决它。如果所有这些方法都试过了,还是没有解决问题,可能需要更深入地调查,或者向 MariaDB 社区寻求帮助. 建议将 strace 输出和最小可复现代码贴到 MariaDB 社区或 Go 社区, 会对其他人诊断问题非常有帮助.