Golang MySQL 教程:从入门到实践
前言
在现代软件开发中,数据库是不可或缺的一部分。MySQL 作为世界上最流行的开源关系型数据库之一,被广泛应用于各种规模的应用中。Golang,以其并发特性、高性能和简洁的语法,成为了构建网络服务和后端应用的热门选择。将 Golang 与 MySQL 结合,能够构建出既高效又可靠的应用。
本教程将带你从零开始,学习如何在 Golang 中使用标准库 database/sql
以及常用的第三方驱动来连接、操作 MySQL 数据库。我们将从最基础的连接、CRUD 操作讲起,逐步深入到预处理语句、事务、错误处理等实践中常用的技术。无论你是 Go 语言新手,还是希望提升数据库操作技能的开发者,都能从中获益。
1. 准备工作
在开始之前,请确保你已经安装了以下软件:
- Golang 环境: 确保你的机器上已经安装了 Go 语言环境(推荐使用较新版本,如 1.18+)。可以通过在终端运行
go version
来检查。 - MySQL 服务器: 你需要在本地或者可以通过网络访问的机器上安装并运行一个 MySQL 数据库服务器。
- 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 的连接信息:主机名(通常是 localhost
或 127.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
连接数据库的基本步骤是:
- 导入
database/sql
包。 - 导入特定的数据库驱动包(通常以空白标识符
_
导入,只执行其init
函数注册驱动)。 - 使用
sql.Open()
函数打开一个数据库连接。 - 检查连接是否成功 (
db.Ping()
)。 - 确保在程序结束时关闭数据库连接 (
db.Close()
)。
数据库连接字符串 (DSN)
sql.Open()
函数需要一个数据库连接字符串,对于 MySQL 驱动 go-sql-driver/mysql
,这个字符串通常称为 DSN (Data Source Name),其格式如下:
[username[:password]@][protocol[(address)]]/dbname[?param1=value1¶mN=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
字符串中的user
和password
替换为你实际的 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 语句时。
为什么使用预处理语句?
- 安全性: 预处理语句是防止 SQL 注入的推荐方法。参数值在发送到数据库之前就已经与 SQL 命令结构分离,数据库会区别对待它们,不会将参数值误解释为 SQL 代码的一部分。
- 性能: 数据库可以缓存预处理语句的执行计划。当语句被多次执行时,数据库可以直接使用缓存的计划,跳过解析和优化阶段,从而提高执行效率。
- 效率: 对于需要多次执行的语句,只需要解析一次。
在 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。如果Valid
为true
,则可以使用sql.NullXxx.Value
(或者sql.NullString.String
,sql.NullInt64.Int64
等特定字段) 来获取实际值。 - 在插入或更新数据时,如果 Go 变量的值应该对应数据库的 NULL,可以将
sql.NullXxx
类型的Valid
字段设置为false
,并将其作为参数传递给Exec
或Query
。
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.Is
或errors.As
来检查错误的类型或链条。 - 处理
sql.ErrNoRows
: 对于QueryRow
返回的*sql.Row
的Scan
方法,当查询没有找到任何行时会返回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/sql
或sqlx
灵活。 - Context: 在现代 Go 应用中,应该将
context.Context
传递给数据库操作函数,以便支持请求取消、超时控制等。database/sql
的许多方法都有 Context 版本,例如db.ExecContext
,db.QueryContext
,tx.ExecContext
等。 - 更复杂的查询: 学习如何在 Go 中构建和执行更复杂的 SQL 查询,包括 JOIN、子查询、聚合函数等。
从 database/sql
入手是理解 Go 数据库操作基础的绝佳方式。当你熟练掌握了它,再考虑引入 sqlx
或 ORM 框架会更容易理解它们的原理和取舍。
希望这篇教程对你有所帮助!现在你可以尝试将这些知识应用到你的 Go 项目中,与 MySQL 数据库愉快地交互了。祝你编程愉快!