Skip to content

21|数据库基础

go-gateway 的管理后台需要持久化路由配置。路由规则不能只在内存里存一份,进程重启后就丢了。数据库负责把配置持久化到磁盘,让网关重启后能从数据库恢复路由表。Go 标准库提供了 database/sql 包,定义了统一的 SQL 接口,配合数据库驱动(如 sqlitemysql)就能操作关系型数据库。

一、database/sql 概览

database/sql 不直接实现任何数据库协议,它只定义接口。具体操作需要引入对应的数据库驱动:

bash
go get github.com/mattn/go-sqlite3    # SQLite
go get -u github.com/go-sql-driver/mysql  # MySQL

使用时先导入驱动(用 _ 匿名导入,只执行驱动的 init 函数注册自己):

go
import (
	"database/sql"
	_ "github.com/mattn/go-sqlite3"
)

二、连接数据库

go
package main

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

	_ "github.com/mattn/go-sqlite3"
)

func main() {
	db, err := sql.Open("sqlite3", "./gateway.db")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	if err := db.Ping(); err != nil {
		log.Fatal(err)
	}
	fmt.Println("connected")
}

sql.Open 只是创建一个数据库对象,不会立即建立连接。db.Ping() 才真正尝试连接数据库,用于验证连接参数是否正确。defer db.Close() 在函数返回前关闭连接池——但 database/sql 的连接池是惰性的,Close 只是标记不再使用,不会立刻断开所有连接。

MySQL 连接字符串:

go
db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/gateway?charset=utf8mb4&parseTime=True")

三、创建表

路由配置表的结构:

go
func initSchema(db *sql.DB) error {
	query := `
	CREATE TABLE IF NOT EXISTS routes (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		prefix TEXT NOT NULL UNIQUE,
		backend TEXT NOT NULL,
		created_at DATETIME DEFAULT CURRENT_TIMESTAMP
	)`
	_, err := db.Exec(query)
	return err
}

db.Exec 执行不返回结果集的 SQL(CREATE、INSERT、UPDATE、DELETE)。返回值是 sql.Result,包含受影响行数和最后插入的 ID。

四、插入数据

go
func addRoute(db *sql.DB, prefix, backend string) error {
	_, err := db.Exec(
		"INSERT INTO routes (prefix, backend) VALUES (?, ?)",
		prefix, backend,
	)
	return err
}

? 是占位符,database/sql 会自动处理转义,防止 SQL 注入。不同数据库的占位符语法不同:MySQL 用 ?,PostgreSQL 用 $1$2,SQLite 用 ?$1database/sql 统一用 ?,驱动层负责转换。

五、查询数据

go
func listRoutes(db *sql.DB) ([]Route, error) {
	rows, err := db.Query("SELECT id, prefix, backend FROM routes")
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var routes []Route
	for rows.Next() {
		var r Route
		if err := rows.Scan(&r.ID, &r.Prefix, &r.Backend); err != nil {
			return nil, err
		}
		routes = append(routes, r)
	}
	return routes, rows.Err()
}

db.Query 返回 *sql.Rows,遍历用 rows.Next(),每行用 rows.Scan 把列值扫描到变量里。rows.Close() 必须调用,否则连接不会回收到连接池。用 defer rows.Close() 是最安全的做法。

查询完所有行后,还要检查 rows.Err()rows.Next() 返回 false 可能是因为遍历完了,也可能是因为发生了错误。不检查 rows.Err() 会漏掉遍历过程中的错误。

查询单行用 db.QueryRow

go
func getRoute(db *sql.DB, id int) (*Route, error) {
	var r Route
	err := db.QueryRow(
		"SELECT id, prefix, backend FROM routes WHERE id = ?", id,
	).Scan(&r.ID, &r.Prefix, &r.Backend)
	if err == sql.ErrNoRows {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	return &r, nil
}

sql.ErrNoRows 表示查询结果为空,这不是错误,只是没找到数据。返回 nil, nil 让调用方判断记录是否存在。

六、预处理语句

频繁执行的 SQL 应该预处理,数据库只解析一次,后续执行直接复用执行计划:

go
stmt, err := db.Prepare("INSERT INTO routes (prefix, backend) VALUES (?, ?)")
if err != nil {
	return err
}
defer stmt.Close()

for _, route := range routes {
	_, err := stmt.Exec(route.Prefix, route.Backend)
	if err != nil {
		return err
	}
}

db.Prepare 返回 *sql.Stmt,可以多次执行。defer stmt.Close() 释放预处理语句占用的数据库资源。

七、事务

多条 SQL 需要原子性执行时用事务。要么全部成功,要么全部回滚:

go
func batchAddRoutes(db *sql.DB, routes []Route) error {
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback() // 如果提交成功,Rollback 不会生效

	for _, route := range routes {
		_, err := tx.Exec("INSERT INTO routes (prefix, backend) VALUES (?, ?)",
			route.Prefix, route.Backend)
		if err != nil {
			return err // 自动回滚(因为 defer 了 Rollback)
		}
	}

	return tx.Commit()
}

事务对象 tx 有自己的 ExecQuery 方法,和 db 的方法签名一样。事务里的操作在提交前对其他连接不可见。defer tx.Rollback() 是一个安全网——如果函数中途返回错误,事务自动回滚;如果 tx.Commit() 成功,Rollback 变成空操作。

八、连接池

database/sql 内部维护了一个连接池,默认行为:

参数默认值说明
MaxOpenConns无限制最大打开连接数
MaxIdleConns2最大空闲连接数
ConnMaxLifetime0(不限制)连接最大存活时间

生产环境应该根据并发量调整:

go db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(5)
	db.SetConnMaxLifetime(5 * time.Minute)

连接数设置过小,并发请求会排队等待;设置过大,数据库端压力增加。通常从低值开始压测,逐步调整。

九、常见错误

忘记 rows.Close()

go
rows, _ := db.Query("SELECT ...")
// 忘了 rows.Close()

rows 不关闭,底层连接不会回收到连接池。并发量稍高就会耗尽连接数,后续查询卡住。

在循环里用 db.Query

go
for _, id := range ids {
	row := db.QueryRow("SELECT ... WHERE id = ?", id)
	// ...
}

每条查询都创建新的预处理语句和执行计划。应该用 db.Prepare 预处理一次,循环里用 stmt.QueryRow

混淆 nil 和 sql.ErrNoRows

go
var r Route
err := db.QueryRow("...").Scan(&r.ID)
if err != nil {
	return err // 错误:sql.ErrNoRows 不应该当成错误返回
}

sql.ErrNoRows 是正常结果(查询为空),应该单独处理。

事务里用 db 而不是 tx

go
tx, _ := db.Begin()
tx.Exec("INSERT ...")
db.Exec("INSERT ...") // 错误:这条不在事务里

事务里的所有操作必须用 tx 执行,用 db 会创建新的连接,不受事务控制。