SQLite是一款轻量级的嵌入式数据库,不需要单独部署服务进程,数据全部存在一个文件里,特别适合桌面工具、小型服务、测试环境这类场景。Go语言的标准库提供了database/sql这套统一的数据库访问接口,只要注册了对应的驱动,就能用同一套API操作MySQL、PostgreSQL、SQLite等不同数据库。本文就以SQLite为例,从驱动注册讲到事务处理,把完整的操作流程梳理一遍。

一、选择并注册SQLite驱动
Go的标准库本身不包含任何具体数据库的驱动,只定义了一套接口规范,具体实现需要第三方库来完成。操作SQLite最常用的驱动有两个:一个是基于CGO绑定的github.com/mattn/go-sqlite3,它直接调用SQLite的C源码,功能完整、性能稳定;另一个是纯Go实现的modernc.org/sqlite,不依赖CGO,交叉编译更省心。
两者怎么选?如果你只在本地开发,部署环境与开发环境一致,mattn/go-sqlite3是更成熟的选择;如果你的项目需要交叉编译到其他平台(比如在Mac上编译Linux二进制),又不想折腾GCC工具链,那就选modernc.org/sqlite。下面的示例以mattn/go-sqlite3为主,文末会补充纯Go驱动的用法差异。
安装驱动只需要一条命令:
go get github.com/mattn/go-sqlite3</code>
安装完成后,在代码中通过下划线导入注册驱动。注意这个下划号不能少,它表示只执行包的初始化函数init(),在这个函数里驱动会调用sql.Register把自己注册进标准库的驱动列表:
package main
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
func main() {
// 打开数据库,如果文件不存在会自动创建
db, err := sql.Open("sqlite3", "./data.db")
if err != nil {
panic(err)
}
defer db.Close()
// Open只是验证参数,Ping才会真正建立连接
if err := db.Ping(); err != nil {
panic(err)
}
}这里有一个新手常犯的错误要说明一下:sql.Open并不会立即连接数据库,它只是做参数校验并返回一个*sql.DB对象供后续使用。真正的连接发生在第一次执行SQL语句时,或者像上面那样主动调用db.Ping()来触发。另外,data.db这个文件不存在也没关系,驱动会自动创建。如果使用:memory:作为文件名,则会在内存中创建一个临时数据库,程序结束后数据消失,非常适合单元测试。
二、创建表与增删改操作
建表、插入、更新、删除这类不返回结果集的语句,统一使用db.Exec方法执行。它的返回值包括一个sql.Result对象,可以从中拿到受影响的行数和自增ID。下面这个例子演示了建表和插入数据的完整流程:
func createTable(db *sql.DB) error {
query := `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
age INTEGER DEFAULT 0,
email TEXT UNIQUE
)`
_, err := db.Exec(query)
return err
}
func insertUser(db *sql.DB, name string, age int, email string) (int64, error) {
res, err := db.Exec(
"INSERT INTO users(name, age, email) VALUES(?, ?, ?)",
name, age, email,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}注意SQL语句中的占位符。SQLite驱动使用?作为占位符,而MySQL驱动用的是?,PostgreSQL驱动则用$1、$2这种编号形式,这是切换数据库时最容易踩的坑。占位符的值由驱动负责转义,天然避免了SQL注入风险,所以永远不要用fmt.Sprintf拼接SQL语句。举个例子,如果用户输入的name值里包含单引号,直接拼接会让SQL报语法错误,恶意输入甚至能删掉整张表。
更新和删除的写法与插入类似,区别在于通常更关心RowsAffected而不是LastInsertId:
func updateUserAge(db *sql.DB, id int64, age int) error {
res, err := db.Exec("UPDATE users SET age = ? WHERE id = ?", age, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
fmt.Printf("更新了 %d 行\n", n)
return nil
}
func deleteUser(db *sql.DB, id int64) error {
_, err := db.Exec("DELETE FROM users WHERE id = ?", id)
return err
}三、查询数据与Scan绑定
查询操作使用db.Query或db.QueryRow。前者返回*sql.Rows结果集,需要手动迭代并关闭;后者只取一行,如果查不到数据会返回sql.ErrNoRows错误。先看单行查询的写法:
func queryUser(db *sql.DB, id int64) {
var name string
var age int
err := db.QueryRow("SELECT name, age FROM users WHERE id = ?", id).
Scan(&name, &age)
switch {
case err == sql.ErrNoRows:
fmt.Println("没有查到该用户")
case err != nil:
fmt.Println("查询出错:", err)
default:
fmt.Printf("用户: %s, 年龄: %d\n", name, age)
}
}多行查询则需要配合rows.Next()循环迭代,并且务必用defer rows.Close()释放连接。如果不关闭,连接会一直被占用,连接池会慢慢耗尽,这是生产环境里非常隐蔽的泄漏点。如果只想确认某条数据是否存在,还可以用db.QueryRow("SELECT COUNT(*) ...").Scan(&count)的方式统计行数。
func listUsers(db *sql.DB) {
rows, err := db.Query("SELECT id, name, age FROM users WHERE age > ? ORDER BY id", 18)
if err != nil {
panic(err)
}
defer rows.Close()
for rows.Next() {
var id int64
var name string
var age int
if err := rows.Scan(&id, &name, &age); err != nil {
panic(err)
}
fmt.Printf("ID:%d 姓名:%s 年龄:%d\n", id, name, age)
}
// 检查迭代过程中是否发生错误
if err := rows.Err(); err != nil {
panic(err)
}
}Scan方法的参数必须与SELECT的字段数量和顺序一一对应,多一个少一个都会报错。Scan支持的类型很灵活,比如数据库里的TEXT字段可以扫描到string,也可以扫描到[]byte,甚至可以用sql.NullString来接收可能为NULL的列。如果直接用string接收NULL值,Scan会返回错误,这一点在处理可空字段时必须留意。
四、预处理与事务处理
当同一条SQL需要反复执行时,预处理语句能带来两方面的好处:一是数据库只需解析一次执行计划,批量插入时性能提升明显;二是参数复用更安全。用db.Prepare创建预处理语句后,返回的stmt对象同样需要关闭:
func batchInsert(db *sql.DB, users [][3]interface{}) error {
stmt, err := db.Prepare("INSERT INTO users(name, age, email) VALUES(?, ?, ?)")
if err != nil {
return err
}
defer stmt.Close()
for _, u := range users {
if _, err := stmt.Exec(u[0], u[1], u[2]); err != nil {
return err
}
}
return nil
}事务保证一组操作要么全部成功,要么全部回滚。典型场景是转账操作:扣款和入账必须同时成功,否则数据就不一致了。在database/sql中,事务通过db.Begin()开启,返回一个*sql.Tx对象,之后所有的SQL都执行在这个事务对象上,最后调用Commit提交或Rollback回滚:
func transfer(db *sql.DB, fromID, toID int64, amount float64) error {
tx, err := db.Begin()
if err != nil {
return err
}
// defer回滚:如果后面Commit成功,Rollback会返回错误并被忽略
defer tx.Rollback()
_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
if err != nil {
return err
}
_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
if err != nil {
return err
}
return tx.Commit()
}这里有个实用技巧值得记住:defer tx.Rollback()放在开头是安全的做法。因为事务一旦提交成功,再调用Rollback会返回一个错误但不会造成任何破坏,直接忽略即可;而如果中途任何一步出错导致函数提前返回,Rollback会自动执行,避免事务悬挂。这种写法比在每处错误分支手写回滚简洁得多。
五、常见问题与驱动选型补充
使用mattn/go-sqlite3最常见的问题是编译报错,提示找不到gcc或者cgo相关错误。根本原因是这个驱动依赖CGO,编译时需要本机有C编译器。解决办法有三种:安装对应的GCC工具链(Linux安装build-essential,Mac安装Xcode Command Line Tools);或者切换到纯Go实现的modernc.org/sqlite驱动;再或者开启SQLite的内嵌编译模式,通过编译标签-tags sqlite_omit_load_extension裁剪动态加载功能。
切换到纯Go驱动只需要改两处:导入路径换成_ "modernc.org/sqlite",驱动名从sqlite3换成sqlite,其余所有database/sql的API完全不变。这正是标准库抽象层的价值所在,业务代码几乎零改动就能换驱动。
import (
"database/sql"
_ "modernc.org/sqlite"
)
func main() {
// 驱动名是 sqlite,不是 sqlite3
db, err := sql.Open("sqlite", "./data.db")
if err != nil {
panic(err)
}
defer db.Close()
}最后再提两个使用细节。第一,SQLite对并发写入的支持有限,整个数据库同一时刻只有一个写入者,如果程序有高并发写入需求,建议开启WAL模式(执行PRAGMA journal_mode=WAL;),能显著提升读写并存的性能。第二,*sql.DB是线程安全的,应该在程序启动时创建一次,全局共享使用,千万不要在每次请求里都Open,那样既浪费资源又容易把文件句柄耗尽。掌握这些要点之后,用Go操作SQLite就基本不会有大的障碍了。
Go语言SQLitedatabase/sql修改时间:2026-09-07 17:50:49