Golang MySQL 教程:从入门到实践 – wiki基地


Golang MySQL 教程:从入门到实践

前言

在现代软件开发中,数据库是不可或缺的一部分。MySQL 作为世界上最流行的开源关系型数据库之一,被广泛应用于各种规模的应用中。Golang,以其并发特性、高性能和简洁的语法,成为了构建网络服务和后端应用的热门选择。将 Golang 与 MySQL 结合,能够构建出既高效又可靠的应用。

本教程将带你从零开始,学习如何在 Golang 中使用标准库 database/sql 以及常用的第三方驱动来连接、操作 MySQL 数据库。我们将从最基础的连接、CRUD 操作讲起,逐步深入到预处理语句、事务、错误处理等实践中常用的技术。无论你是 Go 语言新手,还是希望提升数据库操作技能的开发者,都能从中获益。

1. 准备工作

在开始之前,请确保你已经安装了以下软件:

  1. Golang 环境: 确保你的机器上已经安装了 Go 语言环境(推荐使用较新版本,如 1.18+)。可以通过在终端运行 go version 来检查。
  2. MySQL 服务器: 你需要在本地或者可以通过网络访问的机器上安装并运行一个 MySQL 数据库服务器。
  3. MySQL 客户端: 为了方便创建数据库、表以及测试,建议安装一个 MySQL 客户端工具,如 MySQL Shell, DBeaver, Navicat 等。

创建一个示例数据库和表

为了方便后续的代码示例,我们创建一个简单的数据库和表。假设我们要创建一个名为 godb_demo 的数据库,并在其中创建一个 users 表,包含用户的 ID、姓名和年龄。

打开你的 MySQL 客户端,执行以下 SQL 语句:

“`sql
— 创建数据库 (如果不存在)
CREATE DATABASE IF NOT EXISTS godb_demo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

— 切换到数据库
USE godb_demo;

— 创建 users 表 (如果不存在)
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
age INT NOT NULL
);

— 插入一些示例数据 (可选,后续代码会插入)
— INSERT INTO users (name, age) VALUES (‘Alice’, 30);
— INSERT INTO users (name, age) VALUES (‘Bob’, 25);
“`

确保你记住了 MySQL 的连接信息:主机名(通常是 localhost127.0.0.1)、端口(默认为 3306)、用户名和密码。

2. 安装 MySQL 驱动

Go 语言的标准库 database/sql 提供了一个通用的数据库抽象层,它本身并不包含特定数据库的驱动。我们需要为 MySQL 安装一个第三方驱动,这个驱动实现了 database/sql 定义的接口,从而允许我们通过标准库来操作 MySQL。

最常用的 Go MySQL 驱动是 go-sql-driver/mysql。在你的 Go 项目目录中,打开终端,运行以下命令来安装:

bash
go get github.com/go-sql-driver/mysql

这会将驱动下载到你的 Go 模块缓存中,并在你的 go.mod 文件中记录依赖。

3. 连接到 MySQL

使用 database/sql 连接数据库的基本步骤是:

  1. 导入 database/sql 包。
  2. 导入特定的数据库驱动包(通常以空白标识符 _ 导入,只执行其 init 函数注册驱动)。
  3. 使用 sql.Open() 函数打开一个数据库连接。
  4. 检查连接是否成功 (db.Ping())。
  5. 确保在程序结束时关闭数据库连接 (db.Close())。

数据库连接字符串 (DSN)

sql.Open() 函数需要一个数据库连接字符串,对于 MySQL 驱动 go-sql-driver/mysql,这个字符串通常称为 DSN (Data Source Name),其格式如下:

[username[:password]@][protocol[(address)]]/dbname[?param1=value1&paramN=valueN]

  • username, password: 你的 MySQL 用户名和密码。
  • protocol: 连接协议,通常是 tcp
  • address: MySQL 服务器的地址,例如 127.0.0.1:3306
  • dbname: 要连接的数据库名称,例如 godb_demo
  • param=value: 可选的参数,用于配置连接,例如 charset=utf8mb4, parseTime=True, loc=Local 等。

一个完整的 DSN 示例可能是:
"user:password@tcp(127.0.0.1:3306)/godb_demo?charset=utf8mb4&parseTime=True&loc=Local"

注意:parseTime=True 参数非常重要,它会将 MySQL 的 DATETIME, TIMESTAMP 等类型自动解析为 Go 的 time.Time 类型。loc=Local 通常用于指定时区,确保时间处理的正确性。

示例代码:连接数据库

创建一个 Go 文件,例如 main.go

“`go
package main

import (
“database/sql”
“fmt”
“log”

_ "github.com/go-sql-driver/mysql" // 导入 MySQL 驱动

)

// db 是一个全局变量,代表数据库连接池
var db *sql.DB

func initDB() (err error) {
// DSN (Data Source Name): 用户名:密码@tcp(主机名:端口)/数据库名?参数
// 请根据你的实际情况修改用户名、密码、主机和数据库名
dsn := “user:password@tcp(127.0.0.1:3306)/godb_demo?charset=utf8mb4&parseTime=True&loc=Local”

// sql.Open 不会立即建立连接,它只是验证驱动和 DSN 的格式
db, err = sql.Open("mysql", dsn)
if err != nil {
    // sql.Open 中的错误是驱动名或 DSN 格式错误
    // 这里的 log.Fatalf 会导致程序退出
    return fmt.Errorf("数据库连接失败: %w", err)
}

// 尝试 Ping 数据库,确保连接是有效的
err = db.Ping()
if err != nil {
    // Ping 中的错误是实际连接数据库时发生的错误
    db.Close() // Ping 失败时关闭连接
    return fmt.Errorf("无法连接到数据库: %w", err)
}

fmt.Println("数据库连接成功!")
return nil

}

func main() {
err := initDB()
if err != nil {
log.Fatalf(“初始化数据库失败: %v”, err)
}
defer db.Close() // 确保程序退出前关闭数据库连接

// 后续的数据库操作将在这里进行
fmt.Println("程序将继续执行其他数据库操作...")

// 为了演示效果,这里可以暂停一下或者执行一些假操作
// time.Sleep(time.Second * 5)

}
“`

注意:

  • 请将 dsn 字符串中的 userpassword 替换为你实际的 MySQL 用户名和密码。
  • _ "github.com/go-sql-driver/mysql" 是通过空白标识符导入,它的 init() 函数会自动注册驱动到 database/sql 中。
  • sql.Open 函数返回一个 *sql.DB 对象和一个错误。*sql.DB 代表一个数据库连接池,而不是单个连接。它是并发安全的,因此可以在多个 goroutine 中共享。
  • db.Ping() 是真正尝试建立与数据库的连接并验证连接的可用性。
  • defer db.Close() 确保在 main 函数(或调用 initDB 的函数)返回前关闭数据库连接池。

运行此代码,如果一切正常,你应该会看到 “数据库连接成功!” 的输出。如果失败,请检查你的 DSN、MySQL 服务是否运行以及网络配置。

4. 基本的 CRUD 操作

连接成功后,我们可以开始执行 SQL 语句进行 CRUD (Create, Read, Update, Delete) 操作。

database/sql 提供了两种主要的方法来执行 SQL 语句:

  • db.Exec(): 用于执行 INSERT, UPDATE, DELETE, CREATE TABLE 等不返回行的语句。它返回一个 sql.Result 对象,可以获取受影响的行数和最后插入的 ID。
  • db.Query(): 用于执行 SELECT 语句,返回多行结果。它返回一个 *sql.Rows 对象,需要遍历并扫描每一行的数据。
  • db.QueryRow(): 用于执行 SELECT 语句,返回单行结果(即使查询结果有多行,它也只处理第一行)。它返回一个 *sql.Row 对象。

4.1 创建 (Insert) 数据

使用 db.Exec() 插入数据。

“`go
// 插入用户
func insertUser(name string, age int) (int64, error) {
// 注意:这里使用了占位符 “?”,可以防止 SQL 注入
result, err := db.Exec(“INSERT INTO users (name, age) VALUES (?, ?)”, name, age)
if err != nil {
return 0, fmt.Errorf(“插入用户失败: %w”, err)
}

// 获取最后插入的行的 ID
id, err := result.LastInsertId()
if err != nil {
    return 0, fmt.Errorf("获取最后插入 ID 失败: %w", err)
}

// 获取受影响的行数
rowsAffected, err := result.RowsAffected()
if err != nil {
    // 注意:有些数据库或驱动可能不支持 RowsAffected
    log.Printf("警告: 获取受影响行数失败: %v", err)
} else {
    fmt.Printf("成功插入用户 '%s',ID: %d,影响行数: %d\n", name, id, rowsAffected)
}


return id, nil

}

// 在 main 函数中调用
func main() {
err := initDB()
if err != nil {
log.Fatalf(“初始化数据库失败: %v”, err)
}
defer db.Close()

fmt.Println("数据库连接成功!")

// 插入一些数据
_, err = insertUser("Alice", 30)
if err != nil {
    log.Printf("插入 Alice 失败: %v", err)
}

_, err = insertUser("Bob", 25)
if err != nil {
    log.Printf("插入 Bob 失败: %v", err)
}

_, err = insertUser("Charlie", 35)
if err != nil {
    log.Printf("插入 Charlie 失败: %v", err)
}

fmt.Println("用户插入完成。")

// ... 其他操作 ...

}
“`

重要: 在 SQL 语句中使用占位符(? 对于 MySQL 驱动)来代替直接将变量拼接到 SQL 字符串中。这样做可以有效地防止 SQL 注入攻击,并且通常性能更好(数据库可以缓存执行计划)。将要插入的值作为 Exec 函数的可变参数传入。

4.2 读取 (Read) 数据

读取数据有两种常见场景:读取多行和读取单行。

读取多行 (使用 db.Query)

“`go
// User 结构体用于存储用户数据
type User struct {
ID int
Name string
Age int
}

// 获取所有用户
func getAllUsers() ([]User, error) {
rows, err := db.Query(“SELECT id, name, age FROM users”)
if err != nil {
return nil, fmt.Errorf(“查询所有用户失败: %w”, err)
}
// 非常重要:确保在使用完 rows 后关闭它,释放数据库连接资源
defer rows.Close()

var users []User
// 遍历每一行结果
for rows.Next() {
    var user User
    // 将当前行的列值扫描到 struct 的字段中
    // 列的顺序必须与 SELECT 语句中的列顺序一致
    err := rows.Scan(&user.ID, &user.Name, &user.Age)
    if err != nil {
        // 在迭代过程中遇到的错误,例如扫描错误
        return nil, fmt.Errorf("扫描用户数据失败: %w", err)
    }
    users = append(users, user)
}

// 检查在遍历过程中是否发生了错误
err = rows.Err()
if err != nil {
    return nil, fmt.Errorf("遍历用户结果集失败: %w", err)
}

return users, nil

}

// 在 main 函数中调用 (在插入用户后)
func main() {
// … initDB, defer db.Close(), insertUser calls …

fmt.Println("\n--- 所有用户 ---")
users, err := getAllUsers()
if err != nil {
    log.Printf("获取所有用户失败: %v", err)
} else {
    for _, user := range users {
        fmt.Printf("ID: %d, Name: %s, Age: %d\n", user.ID, user.Name, user.Age)
    }
}

// ... 其他操作 ...

}
“`

重要事项:

  • defer rows.Close()强制性的!即使你提前退出循环,也要确保 rows 被关闭。否则,数据库连接会一直被占用,可能导致连接池耗尽。
  • rows.Next() 移动到下一行。如果没有更多行,返回 false
  • rows.Scan() 将当前行的列值复制到提供的变量地址中。变量的类型必须与数据库列的类型兼容,并且参数的数量和顺序必须与 SELECT 语句中的列对应。
  • for rows.Next() 循环结束后,始终检查 rows.Err() 来捕获在迭代过程中可能发生的错误(例如网络问题)。

读取单行 (使用 db.QueryRow)

当你确定查询只返回一行(例如通过主键查询),可以使用 db.QueryRow()

“`go
// 根据 ID 获取单个用户
func getUserByID(id int) (*User, error) {
// QueryRow 适用于只期望返回一行结果的查询
row := db.QueryRow(“SELECT id, name, age FROM users WHERE id = ?”, id)

var user User
// Scan 会将结果扫描到变量中。如果在查询过程中发生错误,或者结果集为空,
// Scan 会返回延迟的错误(包括 sql.ErrNoRows)
err := row.Scan(&user.ID, &user.Name, &user.Age)
if err != nil {
    // 处理查询不到结果的情况
    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("用户 ID %d 不存在", id)
    }
    // 处理其他错误
    return nil, fmt.Errorf("扫描用户 ID %d 数据失败: %w", id, err)
}

return &user, nil

}

// 在 main 函数中调用 (在获取所有用户后)
func main() {
// … initDB, defer db.Close(), insertUser calls, getAllUsers call …

fmt.Println("\n--- 根据 ID 获取用户 ---")
// 假设 ID 为 1 的用户存在
user, err := getUserByID(1)
if err != nil {
    log.Printf("获取用户 ID 1 失败: %v", err)
} else {
    fmt.Printf("获取用户: ID: %d, Name: %s, Age: %d\n", user.ID, user.Name, user.Age)
}

// 尝试获取一个不存在的用户,演示 sql.ErrNoRows
_, err = getUserByID(999)
if err != nil {
    // 打印错误,会看到 "用户 ID 999 不存在"
    log.Printf("获取用户 ID 999 失败: %v", err)
}

// ... 其他操作 ...

}
“`

db.QueryRow() 返回一个 *sql.Row 对象。注意 *sql.Row 没有 Close 方法,因为它只代表一行数据,并且在 Scan 调用后就会释放相关的连接。它的错误是延迟的,只有在调用 Scan 时才会返回,包括 sql.ErrNoRows

4.3 更新 (Update) 数据

使用 db.Exec() 更新数据。

“`go
// 更新用户年龄
func updateUserAge(id int, newAge int) (int64, error) {
result, err := db.Exec(“UPDATE users SET age = ? WHERE id = ?”, newAge, id)
if err != nil {
return 0, fmt.Errorf(“更新用户 ID %d 失败: %w”, id, err)
}

// 获取受影响的行数
rowsAffected, err := result.RowsAffected()
if err != nil {
    log.Printf("警告: 更新用户 ID %d,获取受影响行数失败: %v", id, err)
} else {
    fmt.Printf("成功更新用户 ID %d 的年龄为 %d,影响行数: %d\n", id, newAge, rowsAffected)
}


return rowsAffected, nil

}

// 在 main 函数中调用 (在获取用户后)
func main() {
// … initDB, defer db.Close(), insertUser calls, getAllUsers call, getUserByID call …

fmt.Println("\n--- 更新用户 ---")
// 更新 ID 为 1 的用户的年龄
_, err := updateUserAge(1, 31)
if err != nil {
    log.Printf("更新用户 ID 1 失败: %v", err)
}

// 再次获取 ID 为 1 的用户,验证更新
user, err := getUserByID(1)
if err != nil {
    log.Printf("更新后获取用户 ID 1 失败: %v", err)
} else {
    fmt.Printf("更新后用户: ID: %d, Name: %s, Age: %d\n", user.ID, user.Name, user.Age)
}

// ... 其他操作 ...

}
“`

同样,使用占位符 ? 来传递更新的值和条件。Exec 返回的 sql.Result 可以用来获取更新操作影响的行数。

4.4 删除 (Delete) 数据

使用 db.Exec() 删除数据。

“`go
// 删除用户
func deleteUser(id int) (int64, error) {
result, err := db.Exec(“DELETE FROM users WHERE id = ?”, id)
if err != nil {
return 0, fmt.Errorf(“删除用户 ID %d 失败: %w”, id, err)
}

// 获取受影响的行数
rowsAffected, err := result.RowsAffected()
if err != nil {
    log.Printf("警告: 删除用户 ID %d,获取受影响行数失败: %v", id, err)
} else {
    fmt.Printf("成功删除用户 ID %d,影响行数: %d\n", id, rowsAffected)
}

return rowsAffected, nil

}

// 在 main 函数中调用 (在更新用户后)
func main() {
// … initDB, defer db.Close(), insertUser calls, getAllUsers call, getUserByID call, updateUserAge call …

fmt.Println("\n--- 删除用户 ---")
// 删除 ID 为 2 的用户 (Bob)
_, err := deleteUser(2)
if err != nil {
    log.Printf("删除用户 ID 2 失败: %v", err)
}

// 再次获取所有用户,验证删除
fmt.Println("\n--- 删除后的所有用户 ---")
users, err := getAllUsers()
if err != nil {
    log.Printf("删除后获取所有用户失败: %v", err)
} else {
    for _, user := range users {
        fmt.Printf("ID: %d, Name: %s, Age: %d\n", user.ID, user.Name, user.Age)
    }
}

// ... 其他操作 ...

}
“`

删除操作与更新类似,也使用 db.Exec 并通过 RowsAffected 获取受影响的行数。

至此,你已经掌握了使用 Go 的 database/sql 进行基本的 CRUD 操作。

5. 预处理语句 (Prepared Statements)

预处理语句是数据库操作中的一个重要概念,尤其是在需要多次执行相同或相似的 SQL 语句时。

为什么使用预处理语句?

  1. 安全性: 预处理语句是防止 SQL 注入的推荐方法。参数值在发送到数据库之前就已经与 SQL 命令结构分离,数据库会区别对待它们,不会将参数值误解释为 SQL 代码的一部分。
  2. 性能: 数据库可以缓存预处理语句的执行计划。当语句被多次执行时,数据库可以直接使用缓存的计划,跳过解析和优化阶段,从而提高执行效率。
  3. 效率: 对于需要多次执行的语句,只需要解析一次。

database/sql 中,可以使用 db.Prepare()tx.Prepare() (在事务中) 来创建一个预处理语句。

“`go
// 使用预处理语句插入用户
func insertUserPrepared(name string, age int) (int64, error) {
// Prepare 会创建一个预处理语句对象,可以多次执行
stmt, err := db.Prepare(“INSERT INTO users (name, age) VALUES (?, ?)”)
if err != nil {
return 0, fmt.Errorf(“准备插入语句失败: %w”, err)
}
// 确保在使用完 statement 后关闭它
defer stmt.Close()

// 使用 Exec 方法执行预处理语句,传入参数
result, err := stmt.Exec(name, age)
if err != nil {
    return 0, fmt.Errorf("执行插入语句失败: %w", err)
}

id, err := result.LastInsertId()
if err != nil {
    return 0, fmt.Errorf("获取最后插入 ID 失败: %w", err)
}
fmt.Printf("成功通过预处理语句插入用户 '%s',ID: %d\n", name, id)

return id, nil

}

// 在 main 函数中调用 (可以替换之前的 insertUser 调用)
func main() {
// … initDB, defer db.Close() …

fmt.Println("\n--- 使用预处理语句插入用户 ---")
_, err = insertUserPrepared("David", 40)
if err != nil {
    log.Printf("通过预处理语句插入 David 失败: %v", err)
}

_, err = insertUserPrepared("Eve", 22)
if err != nil {
    log.Printf("通过预处理语句插入 Eve 失败: %v", err)
}

fmt.Println("通过预处理语句插入完成。")

// ... 其他操作 ...

}
“`

重要事项:

  • db.Prepare() 返回一个 *sql.Stmt 对象,代表预处理语句。
  • defer stmt.Close()强制性的!确保在使用完 Stmt 对象后关闭它,释放相关的数据库资源。
  • 使用 stmt.Exec()stmt.Query() (对于 SELECT 语句) 来执行预处理语句,参数作为方法的变长参数传入。

你也可以对 SELECT 语句使用预处理语句:

“`go
// 使用预处理语句根据年龄范围获取用户
func getUsersByAgeRangePrepared(minAge, maxAge int) ([]User, error) {
// Prepare 预处理 SELECT 语句
stmt, err := db.Prepare(“SELECT id, name, age FROM users WHERE age BETWEEN ? AND ?”)
if err != nil {
return nil, fmt.Errorf(“准备查询语句失败: %w”, err)
}
defer stmt.Close() // 确保关闭预处理语句

// 使用 Query 方法执行预处理语句,传入参数
rows, err := stmt.Query(minAge, maxAge)
if err != nil {
    return nil, fmt.Errorf("执行查询语句失败: %w", err)
}
defer rows.Close() // 确保关闭结果集

var users []User
for rows.Next() {
    var user User
    err := rows.Scan(&user.ID, &user.Name, &user.Age)
    if err != nil {
        return nil, fmt.Errorf("扫描用户数据失败: %w", err)
    }
    users = append(users, user)
}

if err = rows.Err(); err != nil {
    return nil, fmt.Errorf("遍历用户结果集失败: %w", err)
}

return users, nil

}

// 在 main 函数中调用
func main() {
// … initDB, defer db.Close(), insertion calls …

fmt.Println("\n--- 通过预处理语句查询年龄在 20-30 岁之间的用户 ---")
users, err := getUsersByAgeRangePrepared(20, 30)
if err != nil {
    log.Printf("查询年龄范围用户失败: %v", err)
} else {
    for _, user := range users {
        fmt.Printf("ID: %d, Name: %s, Age: %d\n", user.ID, user.Name, user.Age)
    }
}

// ... 其他操作 ...

}
“`

6. 事务 (Transactions)

事务是一系列数据库操作的集合,这些操作要么全部成功,要么全部失败。这保证了数据的一致性和完整性 (ACID 特性)。

database/sql 中,通过 db.Begin() 方法开始一个事务,它返回一个 *sql.Tx 对象。所有属于该事务的操作都应该通过这个 *sql.Tx 对象来执行(使用 tx.Exec(), tx.Query(), tx.Prepare() 等方法)。

事务结束时,需要调用 tx.Commit() 来提交事务(使所有更改永久生效),或者调用 tx.Rollback() 来回滚事务(撤销所有更改)。

示例:模拟转账操作

假设我们在 godb_demo 数据库中新建一个 accounts 表:

“`sql
— 切换到数据库
USE godb_demo;

— 创建 accounts 表 (如果不存在)
CREATE TABLE IF NOT EXISTS accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
balance DECIMAL(10, 2) NOT NULL DEFAULT 0.00
);

— 插入一些示例数据
— INSERT INTO accounts (name, balance) VALUES (‘Alice’, 1000.00);
— INSERT INTO accounts (name, balance) VALUES (‘Bob’, 500.00);
“`

现在我们编写一个 Go 函数来模拟从一个账户向另一个账户转账。

“`go
// TransferBalance 从 fromAccountID 转移 amount 到 toAccountID
func TransferBalance(fromAccountID, toAccountID int, amount float64) error {
// 1. 开始一个事务
tx, err := db.Begin()
if err != nil {
return fmt.Errorf(“开始事务失败: %w”, err)
}

// 使用 defer tx.Rollback() 确保在函数退出时(除非已经 Commit),事务被回滚
// 这个 defer 语句只有在 tx.Commit() 返回 nil 后才需要被取消 (或者不执行)
// 通常做法是,在遇到错误时直接 return,此时 defer 会执行 Rollback
// 如果所有操作成功,在最后调用 tx.Commit()
// Go 1.1 版本后,对已提交或回滚的事务再次 Rollback 是无害的
defer tx.Rollback() // Rollback is safe even if Commit succeeds

// 2. 从源账户扣除金额
// 注意:在事务中使用 tx.Exec 或 tx.Query/QueryRow
_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromAccountID)
if err != nil {
    return fmt.Errorf("从账户 %d 扣除金额失败: %w", fromAccountID, err)
}

// 为了模拟失败场景,可以在这里故意引入一个错误,例如:
// if fromAccountID == 1 {
//  return fmt.Errorf("模拟扣款失败")
// }

// 3. 向目标账户增加金额
_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toAccountID)
if err != nil {
    return fmt.Errorf("向账户 %d 增加金额失败: %w", toAccountID, err)
}

// 4. 提交事务
err = tx.Commit()
if err != nil {
    return fmt.Errorf("提交事务失败: %w", err)
}

fmt.Printf("成功从账户 %d 向账户 %d 转账 %.2f\n", fromAccountID, toAccountID, amount)
return nil

}

// 在 main 函数中调用
func main() {
// … initDB, defer db.Close() …

// 在运行转账前,确保 accounts 表有数据
// 可以手动插入,或者用 Exec 插入
_, err := db.Exec("INSERT INTO accounts (name, balance) VALUES ('Alice', 1000.00), ('Bob', 500.00)")
if err != nil {
    log.Printf("插入初始账户数据失败: %v", err)
} else {
    fmt.Println("插入初始账户数据成功。")
}

fmt.Println("\n--- 模拟转账 ---")
// 假设 Alice ID 为 1,Bob ID 为 2
err = TransferBalance(1, 2, 100.00)
if err != nil {
    log.Printf("转账失败: %v", err)
}

// 转账后检查账户余额
rows, err := db.Query("SELECT id, name, balance FROM accounts")
if err != nil {
    log.Printf("查询账户余额失败: %v", err)
} else {
    defer rows.Close()
    fmt.Println("\n--- 转账后账户余额 ---")
    for rows.Next() {
        var id int
        var name string
        var balance float64 // 注意:使用 float64 或 decimal 类型来扫描 DECIMAL
        err := rows.Scan(&id, &name, &balance)
        if err != nil {
            log.Printf("扫描账户数据失败: %v", err)
            continue
        }
        fmt.Printf("ID: %d, Name: %s, Balance: %.2f\n", id, name, balance)
    }
    if err = rows.Err(); err != nil {
        log.Printf("遍历账户结果集失败: %v", err)
    }
}

// ... 其他操作 ...

}
“`

重要事项:

  • 使用 db.Begin() 获取 *sql.Tx 对象。
  • 事务内的所有数据库操作都必须通过 *sql.Tx 对象调用相应的方法(如 tx.Exec, tx.Query)。
  • defer tx.Rollback() 是一个常见的模式,用于确保在函数正常返回(通过 Commit)或异常退出时事务被回滚。Go 1.1 之后,对已提交或已回滚的事务调用 Rollback 不会产生错误。
  • 只有在所有操作都成功后,才调用 tx.Commit()。如果在 Commit 调用之前发生任何错误,defer tx.Rollback() 会被执行。

7. 处理 Nullable 值

在数据库中,某些列可能允许存储 NULL 值。在 Go 中,基本类型(如 int, string, float64)不能直接表示 NULL。如果尝试将一个 NULL 数据库值扫描到 Go 的基本类型变量中,rows.Scan()row.Scan() 会返回一个错误。

为了正确处理数据库中的 NULL 值,database/sql 提供了特殊的类型,如 sql.NullString, sql.NullInt64, sql.NullBool, sql.NullFloat64, sql.NullTime 等。这些类型通常包含两个字段:

  • Value: 存储非 NULL 值时的实际数据。
  • Valid: 一个布尔值,如果值为非 NULL,则为 true;如果值为 NULL,则为 false

示例:处理可能为 NULL 的 email 字段

修改 users 表,添加一个可选的 email 字段:

sql
ALTER TABLE users ADD COLUMN email VARCHAR(255) NULL;

现在更新我们的 User 结构体和相关的查询/插入函数:

“`go
// User 结构体更新,email 字段使用 sql.NullString
type User struct {
ID int
Name string
Age int
Email sql.NullString // email 可以为 NULL
}

// 插入用户 (可以包含 email 或不包含)
func insertUserWithEmail(name string, age int, email string) (int64, error) {
// 如果 email 为空字符串,则插入 NULL
var nullEmail sql.NullString
if email != “” {
nullEmail = sql.NullString{String: email, Valid: true}
} // 如果 email 是 “”,nullEmail 的 Valid 默认为 false,String 为空,表示 NULL

result, err := db.Exec("INSERT INTO users (name, age, email) VALUES (?, ?, ?)", name, age, nullEmail)
if err != nil {
    return 0, fmt.Errorf("插入带邮箱用户失败: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
    return 0, fmt.Errorf("获取最后插入 ID 失败: %w", err)
}
fmt.Printf("成功插入用户 '%s' (email: %s),ID: %d\n", name, email, id)
return id, nil

}

// 获取所有用户 (处理 email 字段)
func getAllUsersWithEmail() ([]User, error) {
rows, err := db.Query(“SELECT id, name, age, email FROM users”)
if err != nil {
return nil, fmt.Errorf(“查询所有用户失败: %w”, err)
}
defer rows.Close()

var users []User
for rows.Next() {
    var user User
    // 将 email 字段扫描到 sql.NullString
    err := rows.Scan(&user.ID, &user.Name, &user.Age, &user.Email)
    if err != nil {
        return nil, fmt.Errorf("扫描用户数据失败: %w", err)
    }
    users = append(users, user)
}

if err = rows.Err(); err != nil {
    return nil, fmt.Errorf("遍历用户结果集失败: %w", err)
}

return users, nil

}

// 在 main 函数中调用
func main() {
// … initDB, defer db.Close() …

fmt.Println("\n--- 插入带邮箱用户 ---")
_, err := insertUserWithEmail("Frank", 28, "[email protected]")
if err != nil {
    log.Printf("插入 Frank 失败: %v", err)
}
_, err = insertUserWithEmail("Grace", 32, "") // 插入 NULL email
if err != nil {
    log.Printf("插入 Grace 失败: %v", err)
}


fmt.Println("\n--- 获取所有用户 (含邮箱) ---")
users, err := getAllUsersWithEmail()
if err != nil {
    log.Printf("获取所有用户失败: %v", err)
} else {
    for _, user := range users {
        emailStr := "NULL"
        // 检查 sql.NullString 的 Valid 字段
        if user.Email.Valid {
            emailStr = user.Email.String
        }
        fmt.Printf("ID: %d, Name: %s, Age: %d, Email: %s\n", user.ID, user.Name, user.Age, emailStr)
    }
}

// ... 其他操作 ...

}
“`

重要事项:

  • 对于数据库中可能为 NULL 的字段,在 Go 结构体中应使用 sql.NullXxx 类型来表示。
  • Scan 时,将对应的列扫描到 sql.NullXxx 类型的变量地址。
  • 读取值时,通过检查 sql.NullXxx.Valid 字段来判断值是否为 NULL。如果 Validtrue,则可以使用 sql.NullXxx.Value (或者 sql.NullString.String, sql.NullInt64.Int64 等特定字段) 来获取实际值。
  • 在插入或更新数据时,如果 Go 变量的值应该对应数据库的 NULL,可以将 sql.NullXxx 类型的 Valid 字段设置为 false,并将其作为参数传递给 ExecQuery

8. 连接池配置

database/sql*sql.DB 对象实际上是一个连接池。它会在需要时创建连接,并在连接空闲时保留它们以供重用。正确配置连接池参数对于应用性能和资源管理至关重要。

*sql.DB 提供了以下方法来配置连接池:

  • SetMaxOpenConns(n int): 设置与数据库建立的最大连接数。0 表示无限制,但建议设置一个合理的上限。如果所有连接都在使用中,新的请求将会等待。
  • SetMaxIdleConns(n int): 设置连接池中允许的最大空闲连接数。空闲连接在被回收之前会保留一段时间。设置过低可能导致连接频繁创建和销毁,设置过高可能占用过多数据库资源。通常,此值应小于或等于 MaxOpenConns。0 表示不保留空闲连接。
  • SetConnMaxLifetime(d time.Duration): 设置连接的最大可复用时间。达到这个时间后,连接将被关闭并丢弃,即使它仍然空闲或正在使用中(正在使用的连接会在当前查询完成后关闭)。这有助于处理数据库或中间件可能设置的连接最大存活时间,避免连接长时间空闲被服务器端断开而 Go 端不知道(”connection reset by peer” 错误)。0 表示连接可以无限期重用。

示例:配置连接池

initDB 函数中添加配置:

“`go
func initDB() (err error) {
dsn := “user:password@tcp(127.0.0.1:3306)/godb_demo?charset=utf8mb4&parseTime=True&loc=Local”

db, err = sql.Open("mysql", dsn)
if err != nil {
    return fmt.Errorf("数据库连接失败: %w", err)
}

// 配置连接池
db.SetMaxOpenConns(100)              // 设置最大打开连接数
db.SetMaxIdleConns(10)               // 设置最大空闲连接数
db.SetConnMaxLifetime(time.Minute * 5) // 设置连接最大可复用时间

err = db.Ping()
if err != nil {
    db.Close()
    return fmt.Errorf("无法连接到数据库: %w", err)
}

fmt.Println("数据库连接成功!")
return nil

}
“`

连接池的参数需要根据你的应用负载、数据库服务器配置和资源限制进行调整。没有一个通用的最佳配置,通常需要在测试环境中进行压测和监控来找到合适的参数。

9. 错误处理最佳实践

在数据库操作中,错误处理至关重要。一些常见的错误处理实践:

  • 检查所有错误: database/sql 中的几乎所有操作都返回 error。务必检查这些错误并进行适当的处理(例如日志记录、向上层返回错误)。
  • 使用 fmt.Errorf 包装错误: 当你在函数中处理并返回错误时,使用 fmt.Errorf 包装原始错误(使用 %w 动词),这样调用者可以使用 errors.Iserrors.As 来检查错误的类型或链条。
  • 处理 sql.ErrNoRows: 对于 QueryRow 返回的 *sql.RowScan 方法,当查询没有找到任何行时会返回 sql.ErrNoRows。这是一个预期的结果,通常不应将其视为严重的错误,而是表示“找不到数据”。
  • defer 的重要性: 务必使用 defer rows.Close()defer stmt.Close() 来释放资源。在事务中,使用 defer tx.Rollback() 是一个健壮的模式。
  • 日志记录: 在错误发生时记录详细的日志,包括错误信息、发生错误的函数/代码位置以及可能的 SQL 语句(注意不要在日志中暴露敏感数据)。

在前面的示例中,我们已经结合了一些错误处理的最佳实践,例如使用 %w 包装错误,检查 sql.ErrNoRows,以及使用 defer

10. 总结与进一步学习

至此,你已经掌握了使用 Go 语言标准库 database/sql 操作 MySQL 的核心知识和实践技巧,包括:

  • 安装并使用 MySQL 驱动。
  • 连接到 MySQL 数据库。
  • 执行基本的 INSERT, SELECT, UPDATE, DELETE 操作。
  • 理解并使用预处理语句提高安全性与性能。
  • 使用事务保证数据的一致性。
  • 处理数据库中的 NULL 值。
  • 配置数据库连接池。
  • 基本的错误处理。

database/sql 提供了强大的基础能力,但它是一个相对底层的抽象。在实际项目中,你可能会遇到更复杂的场景,例如:

  • 结构体扫描: 手动将 rows.Scan 结果扫描到结构体字段可能比较繁琐,特别是当字段很多时。可以考虑使用第三方库如 sqlx,它在 database/sql 的基础上提供了更方便的结构体与数据库行之间的映射功能。
  • ORM (Object-Relational Mapping): 如果你需要更高级的抽象,将 Go 结构体与数据库表完全映射,并希望使用 Go 对象的方式进行数据库操作,可以考虑使用 ORM 框架,如 GORM 或 Xorm。ORM 框架通常提供了更丰富的功能,但学习曲线可能更陡峭,并且在某些高性能场景下可能不如直接使用 database/sqlsqlx 灵活。
  • Context: 在现代 Go 应用中,应该将 context.Context 传递给数据库操作函数,以便支持请求取消、超时控制等。database/sql 的许多方法都有 Context 版本,例如 db.ExecContext, db.QueryContext, tx.ExecContext 等。
  • 更复杂的查询: 学习如何在 Go 中构建和执行更复杂的 SQL 查询,包括 JOIN、子查询、聚合函数等。

database/sql 入手是理解 Go 数据库操作基础的绝佳方式。当你熟练掌握了它,再考虑引入 sqlx 或 ORM 框架会更容易理解它们的原理和取舍。

希望这篇教程对你有所帮助!现在你可以尝试将这些知识应用到你的 Go 项目中,与 MySQL 数据库愉快地交互了。祝你编程愉快!

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部