数据库操作:Go 与 SQL 的完美邂逅

Go 的 database/sql 包提供统一数据库访问抽象,内置连接池管理和事务支持。本文详解 CRUD 操作、批量插入优化、事务隔离级别和读写分离策略

数据库操作:Go 与 SQL 的完美邂逅

几乎每一个真实的应用都需要和数据库打交道。无论是用户信息、订单数据还是日志记录,数据库都是我们存储和查询数据的核心工具。

Go 语言通过 database/sql 包提供了一个优雅而强大的数据库访问接口。它不直接实现任何数据库驱动,而是定义了一套标准接口,让各种数据库驱动(MySQL 、PostgreSQL 、SQLite 等)来实现具体的连接和操作。

今天我们就来学习如何用 Go 操作数据库,从基础的增删改查到高级的事务处理。

准备工作

在开始之前,你需要安装对应的数据库驱动。Go 社区为各种主流数据库都提供了驱动:

# MySQL
go get -u github.com/go-sql-driver/mysql

# PostgreSQL
go get -u github.com/lib/pq

# SQLite
go get -u github.com/mattn/go-sqlite3

本文以 MySQL 为例,但代码对其他数据库也适用(只需要改一下连接字符串)。

连接数据库

首先,我们需要打开一个数据库连接:

package main

import (
	"database/sql"
	"fmt"
	"log"
	
	_ "github.com/go-sql-driver/mysql"
)

func main() {
	// 连接字符串格式:用户名:密码@协议(地址)/数据库名?参数
	dsn := "root:password@tcp(localhost:3306)/myapp?charset=utf8mb4&parseTime=True"
	
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		log.Fatal("打开数据库失败:", err)
	}
	defer db.Close()
	
	// 测试连接是否成功
	err = db.Ping()
	if err != nil {
		log.Fatal("连接数据库失败:", err)
	}
	
	fmt.Println("数据库连接成功!")
}

几个重要的点:

  1. sql.Open 不会立即建立连接,它只是初始化一个 *sql.DB 对象。真正的连接是在第一次执行查询时建立的。
  2. db.Ping() 用于测试连接,它会立即尝试建立一个连接。
  3. defer db.Close() 确保程序结束时关闭连接池。
  4. 导入驱动时用 _ 是因为我们只需要它的 init() 函数来注册驱动,不需要直接使用它。

连接池配置

database/sql 自动管理一个连接池,你可以配置它的行为:

db.SetMaxOpenConns(25)      // 最大打开连接数
db.SetMaxIdleConns(10)      // 最大空闲连接数
db.SetConnMaxLifetime(5 * time.Minute)  // 连接最大生命周期

合理的配置可以显著提升应用性能。一般来说:

  • MaxOpenConns 设置为数据库允许的最大连接数(比如 MySQL 默认是 151)
  • MaxIdleConns 设置为 MaxOpenConns 的一半左右
  • ConnMaxLifetime 避免连接过久导致的问题

创建表

让我们先创建一个示例表:

createTableSQL := `
CREATE TABLE IF NOT EXISTS users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    age INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`

_, err := db.Exec(createTableSQL)
if err != nil {
	log.Fatal("创建表失败:", err)
}

fmt.Println("表创建成功!")

db.Exec() 用于执行不返回结果集的 SQL 语句(CREATE、INSERT、UPDATE、DELETE 等)。

插入数据

单条插入

insertSQL := "INSERT INTO users (name, email, age) VALUES (?, ?, ?)"
result, err := db.Exec(insertSQL, "张三", "zhangsan@example.com", 25)
if err != nil {
	log.Fatal("插入失败:", err)
}

// 获取插入的 ID
id, err := result.LastInsertId()
if err != nil {
	log.Fatal("获取 ID 失败:", err)
}

// 获取影响的行数
rows, err := result.RowsAffected()
if err != nil {
	log.Fatal("获取影响行数失败:", err)
}

fmt.Printf("插入成功!ID: %d, 影响行数: %d\n", id, rows)

注意 Go 的 ? 占位符会自动处理 SQL 注入问题,这比字符串拼接安全得多。

批量插入

users := []struct {
	Name  string
	Email string
	Age   int
}{
	{"李四", "lisi@example.com", 30},
	{"王五", "wangwu@example.com", 28},
	{"赵六", "zhaoliu@example.com", 35},
}

// 开启事务批量插入
tx, err := db.Begin()
if err != nil {
	log.Fatal("开启事务失败:", err)
}

stmt, err := tx.Prepare("INSERT INTO users (name, email, age) VALUES (?, ?, ?)")
if err != nil {
	tx.Rollback()
	log.Fatal("预处理失败:", err)
}
defer stmt.Close()

for _, u := range users {
	_, err := stmt.Exec(u.Name, u.Email, u.Age)
	if err != nil {
		tx.Rollback()
		log.Printf("插入 %s 失败: %v", u.Name, err)
	}
}

err = tx.Commit()
if err != nil {
	log.Fatal("提交事务失败:", err)
}

fmt.Println("批量插入成功!")

使用事务和预处理语句可以显著提升批量插入的性能。

查询数据

查询单条记录

var id int
var name, email string
var age int
var createdAt time.Time

querySQL := "SELECT id, name, email, age, created_at FROM users WHERE id = ?"
err := db.QueryRow(querySQL, 1).Scan(&id, &name, &email, &age, &createdAt)

switch {
case err == sql.ErrNoRows:
	fmt.Println("用户不存在")
case err != nil:
	log.Fatal("查询失败:", err)
default:
	fmt.Printf("用户: %s (%s), 年龄: %d, 创建时间: %v\n",
		name, email, age, createdAt)
}

db.QueryRow() 用于查询单条记录,它比 db.Query() 更高效。Scan() 会把结果映射到你提供的变量中。

查询多条记录

querySQL := "SELECT id, name, email, age FROM users WHERE age > ?"
rows, err := db.Query(querySQL, 25)
if err != nil {
	log.Fatal("查询失败:", err)
}
defer rows.Close()

var users []User
for rows.Next() {
	var u User
	err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age)
	if err != nil {
		log.Fatal("扫描失败:", err)
	}
	users = append(users, u)
}

// 检查迭代过程中是否有错误
err = rows.Err()
if err != nil {
	log.Fatal("迭代失败:", err)
}

fmt.Printf("找到 %d 个用户:\n", len(users))
for _, u := range users {
	fmt.Printf("- %s (%s), 年龄: %d\n", u.Name, u.Email, u.Age)
}

注意:

  1. 一定要 defer rows.Close(),否则会泄漏连接
  2. 用 rows.Err() 检查迭代错误,因为 rows.Next() 可能因为错误而返回 false
  3. Scan 的顺序必须和 SELECT 的字段顺序一致

查询到 Map

如果你不想定义结构体,可以查询到 map[string]interface{}:

rows, _ := db.Query("SELECT * FROM users")
columns, _ := rows.Columns()

for rows.Next() {
	values := make([]interface{}, len(columns))
	valuePtrs := make([]interface{}, len(columns))
	for i := range values {
		valuePtrs[i] = &values[i]
	}
	
	rows.Scan(valuePtrs...)
	
	row := make(map[string]interface{})
	for i, col := range columns {
		row[col] = values[i]
	}
	
	fmt.Println(row)
}

更新和删除

// 更新
updateSQL := "UPDATE users SET age = ? WHERE id = ?"
result, err := db.Exec(updateSQL, 26, 1)
if err != nil {
	log.Fatal("更新失败:", err)
}

rows, _ := result.RowsAffected()
fmt.Printf("更新了 %d 行\n", rows)

// 删除
deleteSQL := "DELETE FROM users WHERE id = ?"
result, err = db.Exec(deleteSQL, 1)
if err != nil {
	log.Fatal("删除失败:", err)
}

rows, _ = result.RowsAffected()
fmt.Printf("删除了 %d 行\n", rows)

预处理语句

预处理语句可以提升性能(数据库可以缓存执行计划)并防止 SQL 注入:

stmt, err := db.Prepare("SELECT name, email FROM users WHERE age > ?")
if err != nil {
	log.Fatal("预处理失败:", err)
}
defer stmt.Close()

// 多次执行
rows1, _ := stmt.Query(25)
// 处理结果...

rows2, _ := stmt.Query(30)
// 处理结果...

事务处理

事务确保一组操作要么全部成功,要么全部失败:

func transferMoney(db *sql.DB, fromID, toID int, amount float64) error {
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	
	// 从 fromID 扣款
	_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
	if err != nil {
		tx.Rollback()
		return err
	}
	
	// 向 toID 加款
	_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
	if err != nil {
		tx.Rollback()
		return err
	}
	
	return tx.Commit()
}

err := transferMoney(db, 1, 2, 100.00)
if err != nil {
	log.Fatal("转账失败:", err)
}
fmt.Println("转账成功!")

带重试的事务

网络抖动可能导致事务失败,我们可以加重试逻辑:

func withRetry(db *sql.DB, fn func(tx *sql.Tx) error, maxRetries int) error {
	for i := 0; i < maxRetries; i++ {
		tx, err := db.Begin()
		if err != nil {
			return err
		}
		
		err = fn(tx)
		if err != nil {
			tx.Rollback()
			
			// 如果是死锁错误,重试
			if isDeadlockError(err) {
				time.Sleep(time.Duration(i*100) * time.Millisecond)
				continue
			}
			return err
		}
		
		err = tx.Commit()
		if err == nil {
			return nil
		}
	}
	return fmt.Errorf("达到最大重试次数")
}

func isDeadlockError(err error) bool {
	// 根据数据库类型判断死锁错误
	return strings.Contains(err.Error(), "Deadlock")
}

NULL 值处理

数据库中的 NULL 值在 Go 中需要特殊处理:

import "database/sql"

type User struct {
	ID    int
	Name  string
	Email sql.NullString  // 可能为 NULL
	Age   sql.NullInt64   // 可能为 NULL
}

var u User
err := db.QueryRow("SELECT id, name, email, age FROM users WHERE id = ?", 1).
	Scan(&u.ID, &u.Name, &u.Email, &u.Age)

if u.Email.Valid {
	fmt.Println("邮箱:", u.Email.String)
} else {
	fmt.Println("邮箱: NULL")
}

if u.Age.Valid {
	fmt.Println("年龄:", u.Age.Int64)
} else {
	fmt.Println("年龄: NULL")
}

sql.NullString、sql.NullInt64、sql.NullFloat64、sql.NullBool、sql.NullTime 等类型都有两个字段:

  • Valid:布尔值,表示值是否有效(非 NULL)
  • 具体值:String、Int64、Float64、Bool、Time

实战:用户管理系统

让我们把所学知识综合起来,实现一个简单的用户管理系统:

package main

import (
	"database/sql"
	"fmt"
	"log"
	"time"
	
	_ "github.com/go-sql-driver/mysql"
)

type User struct {
	ID        int
	Name      string
	Email     string
	Age       int
	CreatedAt time.Time
}

type UserManager struct {
	db *sql.DB
}

func NewUserManager(db *sql.DB) *UserManager {
	return &UserManager{db: db}
}

func (m *UserManager) Create(name, email string, age int) (int, error) {
	result, err := m.db.Exec(
		"INSERT INTO users (name, email, age) VALUES (?, ?, ?)",
		name, email, age,
	)
	if err != nil {
		return 0, err
	}
	
	id, err := result.LastInsertId()
	return int(id), err
}

func (m *UserManager) GetByID(id int) (*User, error) {
	var u User
	err := m.db.QueryRow(
		"SELECT id, name, email, age, created_at FROM users WHERE id = ?", id,
	).Scan(&u.ID, &u.Name, &u.Email, &u.Age, &u.CreatedAt)
	
	if err == sql.ErrNoRows {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	
	return &u, nil
}

func (m *UserManager) List(minAge int) ([]User, error) {
	rows, err := m.db.Query(
		"SELECT id, name, email, age, created_at FROM users WHERE age >= ? ORDER BY id",
		minAge,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	
	var users []User
	for rows.Next() {
		var u User
		err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age, &u.CreatedAt)
		if err != nil {
			return nil, err
		}
		users = append(users, u)
	}
	
	return users, rows.Err()
}

func (m *UserManager) Update(id int, name, email string, age int) error {
	_, err := m.db.Exec(
		"UPDATE users SET name = ?, email = ?, age = ? WHERE id = ?",
		name, email, age, id,
	)
	return err
}

func (m *UserManager) Delete(id int) error {
	_, err := m.db.Exec("DELETE FROM users WHERE id = ?", id)
	return err
}

func main() {
	db, err := sql.Open("mysql", "root:password@tcp(localhost:3306)/myapp")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
	
	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(10)
	
	manager := NewUserManager(db)
	
	// 创建用户
	id, err := manager.Create("张三", "zhangsan@example.com", 25)
	if err != nil {
		log.Fatal("创建失败:", err)
	}
	fmt.Println("创建用户 ID:", id)
	
	// 查询用户
	user, err := manager.GetByID(id)
	if err != nil {
		log.Fatal("查询失败:", err)
	}
	fmt.Printf("用户: %+v\n", user)
	
	// 更新用户
	err = manager.Update(id, "张三(已更新)", "zhangsan_new@example.com", 26)
	if err != nil {
		log.Fatal("更新失败:", err)
	}
	
	// 列出用户
	users, err := manager.List(20)
	if err != nil {
		log.Fatal("列表失败:", err)
	}
	fmt.Printf("找到 %d 个用户\n", len(users))
	
	// 删除用户
	err = manager.Delete(id)
	if err != nil {
		log.Fatal("删除失败:", err)
	}
	fmt.Println("删除成功")
}

小结

今天我们学习了 Go 的数据库操作:

  1. 连接数据库:sql.Open 和连接池配置
  2. CRUD 操作:增删改查的基本用法
  3. 查询技巧:单条、多条、Map 映射
  4. 预处理语句:提升性能和安全性
  5. 事务处理:确保数据一致性
  6. NULL 值处理:使用 sql.Null* 类型
  7. 实战应用:用户管理系统

database/sql 是 Go 标准库中的瑰宝,它简洁而强大。虽然市面上有很多 ORM 框架(如 GORM),但理解底层的 database/sql 能让你写出更高效、更可控的代码。

练习时间

  1. 创建一个博客系统,包含文章表和评论表,实现文章的增删改查和评论功能
  2. 实现一个分页查询函数,支持按字段排序
  3. 写一个数据迁移工具,把一个表的数据导入到另一个表
  4. 实现一个连接池监控工具,定期打印连接池状态

我们下篇见!

database/sql 的连接池管理

连接池参数详解

sql.DB 内部的连接池由三个关键参数控制:

db, _ := sql.Open("postgres", dsn)

db.SetMaxOpenConns(25)      // 最大打开连接数
db.SetMaxIdleConns(5)       // 最大空闲连接数
db.SetConnMaxLifetime(5 * time.Minute) // 连接最大生命周期
  • MaxOpenConns:同时打开的连接数上限。超过此限制的新请求会排队等待。默认值是 0(无限制),但生产环境应设置合理值。
  • MaxIdleConns:保持空闲状态的连接数。保持一定量的空闲连接可以减少频繁创建/关闭连接的开销。
  • ConnMaxLifetime:连接最长存活时间。超过此时间的连接会被标记为过期,在下次使用时关闭。这个设置对数据库主从切换非常有用。

连接池的工作原理

package main

import (
    "database/sql"
    "fmt"
    "time"
    
    _ "github.com/lib/pq"
)

func main() {
    db, err := sql.Open("postgres", "user=postgres dbname=test sslmode=disable")
    if err != nil {
        panic(err)
    }
    defer db.Close()

    // 设置连接池参数
    db.SetMaxOpenConns(20)
    db.SetMaxIdleConns(5)
    db.SetConnMaxLifetime(30 * time.Minute)

    // 验证连接
    if err := db.Ping(); err != nil {
        panic(err)
    }

    // 获取连接池统计信息
    stats := db.Stats()
    fmt.Printf("空闲连接: %d\n", stats.Idle)
    fmt.Printf("正在使用: %d\n", stats.InUse)
    fmt.Printf("等待请求: %d\n", stats.WaitCount)
}

批量插入优化

使用批量 INSERT

package main

import (
    "database/sql"
    "fmt"
    "strings"
    
    _ "github.com/lib/pq"
)

func BatchInsert(db *sql.DB, users []User) error {
    if len(users) == 0 {
        return nil
    }

    // 构建批量插入 SQL
    valueStrings := make([]string, 0, len(users))
    valueArgs := make([]interface{}, 0, len(users)*2)
    
    for i, user := range users {
        valueStrings = append(valueStrings, fmt.Sprintf("($%d, $%d)", i*2+1, i*2+2))
        valueArgs = append(valueArgs, user.Name, user.Email)
    }

    query := fmt.Sprintf(
        "INSERT INTO users (name, email) VALUES %s",
        strings.Join(valueStrings, ","),
    )
    
    _, err := db.Exec(query, valueArgs...)
    return err
}

// 更高效的批量插入:使用 COPY
type User struct {
    Name  string
    Email string
}

func CopyInsert(db *sql.DB, users []User) error {
    txn, err := db.Begin()
    if err != nil {
        return err
    }
    defer txn.Rollback()

    stmt, err := txn.Prepare(`COPY users (name, email) FROM STDIN`)
    if err != nil {
        return err
    }
    defer stmt.Close()

    for _, user := range users {
        _, err = stmt.Exec(user.Name, user.Email)
        if err != nil {
            return err
        }
    }

    return txn.Commit()
}

事务隔离级别

package main

import (
    "context"
    "database/sql"
    "fmt"
    
    _ "github.com/lib/pq"
)

func demonstrateIsolation(db *sql.DB) error {
    ctx := context.Background()
    
    // 设置事务隔离级别
    tx, err := db.BeginTx(ctx, &sql.TxOptions{
        Isolation: sql.LevelSerializable,
        ReadOnly:  false,
    })
    if err != nil {
        return err
    }
    defer tx.Rollback()

    var balance float64
    err = tx.QueryRow("SELECT balance FROM accounts WHERE id = $1", 1).Scan(&balance)
    if err != nil {
        return err
    }

    fmt.Printf("当前余额: %.2f\n", balance)

    // 更新余额
    _, err = tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", 100, 1)
    if err != nil {
        return err
    }

    return tx.Commit()
}

func main() {
    fmt.Println("事务隔离级别:")
    fmt.Println("- ReadUncommitted: 最低级别,允许脏读")
    fmt.Println("- ReadCommitted: 防止脏读,允许不可重复读(默认)")
    fmt.Println("- WriteCommitted: PostgreSQL 特有")
    fmt.Println("- RepeatableRead: 防止不可重复读,允许幻读")
    fmt.Println("- Snapshot: SQL Server 特有")
    fmt.Println("- Serializable: 最高级别,完全隔离")
    fmt.Println("- Linearizable: 分布式系统级别")
}

性能对比:批量插入方式

插入方式速度适用场景
单条 INSERT基准(1x)少量数据
批量 INSERT5-10x中小批量
COPY 命令10-50x大批量
预处理语句批量3-5x需要参数化

常见错误与 FAQ

Q: sql.Open() 会立即建立连接吗?

A: 不会。sql.Open() 只是创建一个连接池对象,不会立即创建连接。第一个真正的数据库操作才会触发连接创建。

Q: db.Close() 会等待所有连接关闭吗?

A: 会。Close() 会阻止新的查询并等待所有正在使用的连接释放后,关闭所有连接。

Q: context 超时会回滚事务吗?

A: 会。如果 context 在事务执行期间被取消,事务会自动回滚。

Q: 如何处理连接泄漏?

A: 确保每次 Query()/QueryRow() 都使用 defer rows.Close(),每次 Begin() 要么 Commit() 要么 Rollback()。

延伸阅读


进阶概念

database/sql 抽象层

database/sql 是 Go 标准库提供的数据库访问抽象层。它本身不实现任何数据库驱动,而是定义了一套标准接口(driver.Conn、driver.Driver、driver.Stmt、driver.Tx 等)。各数据库厂商(MySQL、PostgreSQL、SQLite、SQL Server 等)提供独立的驱动包来实现这些接口。

这种设计的优点是应用代码与具体数据库无关,理论上可以通过更换驱动和连接字符串来切换数据库。但 SQL 方言的差异(MySQL 的 LIMIT vs PostgreSQL 的 LIMIT/OFFSET 语法差异较小,但数据类型和函数差异较大)仍然需要在迁移时手动处理。

连接池深入理解

database/sql 内置连接池,通过以下参数控制:

func ConfigurePool(db *sql.DB) {
    db.SetMaxOpenConns(25)        // 最大打开连接数
    db.SetMaxIdleConns(10)        // 最大空闲连接数
    db.SetConnMaxLifetime(5*time.Minute)  // 连接最大存活时间
    db.SetConnMaxIdleTime(1*time.Minute)  // 连接最大空闲时间(Go 1.15+)
}
  • MaxOpenConns:最大并发连接数。超过后新请求会排队等待连接释放。建议根据数据库的 max_connections 设置,留出余量给数据库管理连接。
  • MaxIdleConns:最大空闲连接数。连接使用完后不会立即关闭,而是留在空闲池。设置为 MaxOpenConns 的一半是较常见的实践。
  • ConnMaxLifetime:连接的最大存活时间。数据库连接可能因为网络问题或数据库端超时而失效,定期刷新可以避免使用僵尸连接。
  • ConnMaxIdleTime:连接在空闲池中的最大空闲时间。超过后会被关闭,避免在业务低谷期占用资源。

SQL 注入防护

Go 的 database/sql 使用占位符(? for MySQL, $N for PostgreSQL, @p1 for SQL Server)而非字符串拼接。参数由驱动层安全转义后传递给数据库,从根本上杜绝了 SQL 注入:

// 安全:参数化查询
rows, err := db.Query("SELECT * FROM users WHERE name = ?", userInput)

// 危险:字符串拼接
rows, err := db.Query("SELECT * FROM users WHERE name = '" + userInput + "'")

事务隔离级别

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelReadCommitted,  // 读已提交
    ReadOnly:  false,
})

// 可选隔离级别:
// sql.LevelDefault
// sql.LevelReadUncommitted  // 读未提交
// sql.LevelReadCommitted    // 读已提交
// sql.LevelWriteCommitted   // 写已提交(PostgreSQL)
// sql.LevelRepeatableRead   // 可重复读
// sql.LevelSnapshot         // 快照隔离(SQL Server)
// sql.LevelSerializable     // 串行化
// sql.LevelLinearizable     // 线性化

选择合适的隔离级别是数据库设计的重要决策。读已提交是 MySQL 和 PostgreSQL 的默认级别,能避免脏读但在高并发下可能出现幻读和不可重复读。可重复读在 MySQL InnoDB 中通过 MVCC 实现,能避免不可重复读。串行化最安全但性能最差,仅在原子性要求极高的场景使用。

实战案例

1. 完整 CRUD 封装

type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Create(ctx context.Context, name, email string, age int) (int64, error) {
    result, err := r.db.ExecContext(ctx,
        "INSERT INTO users (name, email, age, created_at) VALUES (?, ?, ?, ?)",
        name, email, age, time.Now())
    if err != nil {
        return 0, err
    }
    return result.LastInsertId()
}

func (r *UserRepository) GetByID(ctx context.Context, id int64) (*User, error) {
    var u User
    row := r.db.QueryRowContext(ctx,
        "SELECT id, name, email, age, created_at FROM users WHERE id = ?", id)
    err := row.Scan(&u.ID, &u.Name, &u.Email, &u.Age, &u.CreatedAt)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return &u, err
}

func (r *UserRepository) Update(ctx context.Context, id int64, name, email string, age int) error {
    _, err := r.db.ExecContext(ctx,
        "UPDATE users SET name = ?, email = ?, age = ? WHERE id = ?",
        name, email, age, id)
    return err
}

func (r *UserRepository) Delete(ctx context.Context, id int64) error {
    _, err := r.db.ExecContext(ctx, "DELETE FROM users WHERE id = ?", id)
    return err
}

func (r *UserRepository) List(ctx context.Context, offset, limit int) ([]User, int64, error) {
    var total int64
    err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&total)
    if err != nil {
        return nil, 0, err
    }

    rows, err := r.db.QueryContext(ctx,
        "SELECT id, name, email, age, created_at FROM users LIMIT ? OFFSET ?",
        limit, offset)
    if err != nil {
        return nil, 0, err
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        var u User
        if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age, &u.CreatedAt); err != nil {
            return nil, 0, err
        }
        users = append(users, u)
    }
    return users, total, rows.Err()
}

2. 批量插入优化(MySQL)

func BatchInsertUsers(db *sql.DB, users []User) error {
    if len(users) == 0 {
        return nil
    }

    // 方式一:多值语法(推荐)
    valueList := make([]string, len(users))
    args := make([]interface{}, 0, len(users)*3)
    for i, u := range users {
        valueList[i] = "(?, ?, ?)"
        args = append(args, u.Name, u.Email, u.Age)
    }
    query := "INSERT INTO users (name, email, age) VALUES " +
        strings.Join(valueList, ", ")
    _, err := db.Exec(query, args...)
    return err
}

3. 带重试的事务

func WithRetry(db *sql.DB, fn func(tx *sql.Tx) error, maxRetries int, delay time.Duration) error {
    var lastErr error
    for i := 0; i < maxRetries; i++ {
        tx, err := db.Begin()
        if err != nil {
            return err
        }
        err = fn(tx)
        if err != nil {
            tx.Rollback()
            lastErr = err
            if isDeadlock(err) {
                time.Sleep(delay * time.Duration(i+1))
                continue
            }
            return err
        }
        if err := tx.Commit(); err != nil {
            lastErr = err
            continue
        }
        return nil
    }
    return fmt.Errorf("事务重试 %d 次后失败: %w", maxRetries, lastErr)
}

func isDeadlock(err error) bool {
    if err == nil {
        return false
    }
    msg := strings.ToLower(err.Error())
    return strings.Contains(msg, "deadlock") || strings.Contains(msg, "lock wait timeout")
}

性能对比:不同批量插入方式

func BenchmarkSingleInsert(b *testing.B) {
    db := setupDB(); defer db.Close()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        db.Exec("INSERT INTO test (v) VALUES (?)", i)
    }
}

func BenchmarkStatementBatch(b *testing.B) {
    db := setupDB(); defer db.Close()
    stmt, _ := db.Prepare("INSERT INTO test (v) VALUES (?)")
    defer stmt.Close()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        stmt.Exec(i)
    }
}

func BenchmarkMultiValueBatch(b *testing.B) {
    db := setupDB(); defer db.Close()
    batchSize := 100
    b.ResetTimer()
    for i := 0; i < b.N; i += batchSize {
        n := min(batchSize, b.N-i)
        values := strings.Repeat("(?),", n)
        values = values[:len(values)-1]
        args := make([]interface{}, n)
        for j := 0; j < n; j++ {
            args[j] = i + j
        }
        db.Exec("INSERT INTO test (v) VALUES "+values, args...)
    }
}
// 结论(MySQL):单条插入最慢(网络往返每次一次),预处理次之,
// 多值批量插入最快(多个值一次网络往返)
方式网络往返次数适用场景相对速度
单条 ExecN 次(每行一次)少量数据1x(基准)
预处理单条N 次中等数据量2-3x
事务包裹1 次(+ 每行一次)需原子性3-5x
多值批量1 次(每批一次)大量数据50-100x
LOAD DATA1 次超大量数据100-500x

FAQ

Q1:sql.Open 和 db.Ping 有什么区别?
A:sql.Open 只初始化连接池驱动,不会立即建立连接。只有调用 db.Ping 或执行第一次查询时才会真正连接数据库。

Q2:sql.NullString 和指针 *string 如何选择?
A:sql.NullString 语义更清晰,明确表达了可能为 NULL;指针写法更简洁但 nil 语义不如 Valid 明确。ORM 框架通常会帮你做此转换。

Q3:连接数一直在增长怎么办?
A:检查是否有忘记 rows.Close() 或 stmt.Close() 的地方。未关闭的查询会持续占用连接直到 GC。用 pprof 或 db.Stats() 监控连接统计。

Q4:事务中查询没有返回最新数据?
A:你可能用的是 db.Query 而不是 tx.Query。事务外的查询不会读取事务中的未提交数据。

Q5:如何处理数据库连接断开?
A:database/sql 会自动重连,但要注意首次连接失败和连接泄漏问题。设置 ConnMaxLifetime 避免僵尸连接。

Q6:如何安全地实现分页?
A:同时查询总记录数和使用 LIMIT/OFFSET 分页。对于大数据量,考虑用游标分页(keyset pagination)避免 OFFSET 性能问题。

Q7:Context 超时会影响正在执行的查询吗?
A:是的。QueryContext/ExecContext 传递的 ctx 过期后,驱动层会尝试取消查询。但要注意并非所有驱动都完美支持取消。

Q8:预处理语句(Prepare)有什么优势?
A:数据库可缓存执行计划,避免每次解析 SQL;参数由驱动转义,天然防注入;重复执行时性能更好。

最佳实践

  1. 始终使用 Context:所有查询都应传递上下文,支持超时和取消
  2. 检查 rows.Err():rows.Next() 返回 false 可能是因为迭代完毕,也可能是因为错误
  3. 配置连接池:根据实际并发量调整 MaxOpenConns 和 MaxIdleConns
  4. 使用事务:多个写操作应在事务中执行,保证原子性
  5. 参数化查询:永远不要拼接 SQL 字符串
  6. 批量插入用多值语法:MySQL 的 VALUES (...), (...) 比单次插入快几十倍
  7. 使用预处理语句:重复查询时性能更好
  8. 监控连接池状态:定期输出 db.Stats() 排查连接泄漏

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练