Go后端绕不开数据库,而数据库操作里,查询和写入又是日常写代码最高频的两件事。前一篇实战1讲了Go连MySQL的环境准备和基础连接串配置,这篇实战2就直接上手干活:怎么把数据查出来、怎么把数据写进去,以及在实战中那些文档里不会写清楚、只能靠踩坑换来的经验。我用的技术栈很朴素——Go标准库里的 database/sql,配官方MySQL驱动,全程手写SQL,不套ORM。原因后面会细说,但先给结论:手写SQL对理解数据库行为和排查线上问题,比任何ORM都有用。
说下这篇的定位。适合的人有两类:一类是刚接触Go后端的开发者,想搞明白标准库操作MySQL的完整套路;另一类是平时用惯了ORM、但对底层不放心的人,这篇正好补一补底层机制。我会把查询、写入、事务、连接池、常见坑串成一个完整的实战闭环,代码可以直接抄去改。
1. 这次实战要解决的问题与整体设计思路
1.1 为什么单独把查询和写入拎出来讲
很多项目里,数据层的代码占了整个业务代码的三四成。查询和写入表面上就是几句SQL的事,但真正到生产环境,慢查询、连接池爆掉、死锁、数据重复插入,几乎全是这两块搞出来的。我见过不少刚转Go的同事,ORM用得飞起,一遇到线上慢SQL就两眼一黑,因为ORM帮你拼的SQL根本不受控制。
我这次刻意不用ORM,还有一个实战层面的原因:查和写这件事,本质是"把业务语言翻译成SQL语言"。你手写一遍,才知道数据库到底干了什么。比如ORM里的一个查询方法,底层可能是一条带子查询的复杂SQL,也可能是三条简单SQL,性能差好几倍。自己写SQL,性能可控、行为可预期,排查问题时也只用看一个地方。
另一个原因和参数绑定有关。手写SQL配合预处理语句(Prepare),既能防SQL注入,又能让MySQL对同结构SQL做缓存优化。这在写批量插入、循环查询的时候收益特别明显。后面写批量插入我会贴实测数据,一条条插和一条SQL批量插,性能差得离谱。
1.2 表结构设计与准备数据
实战总得有个表。我做的是一个极简的模拟场景:用户订单表。没有外键关系,因为单表操作已经足够覆盖90%的日常开发动作。表结构如下:
CREATE TABLE `orders` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL DEFAULT '', `user_id` bigint unsigned NOT NULL DEFAULT 0, `product_name` varchar(128) NOT NULL DEFAULT '', `amount` decimal(10,2) NOT NULL DEFAULT 0.00, `status` tinyint NOT NULL DEFAULT 0, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段设计上有几个故意保留的细节:amount用decimal而不是float,避免金额精度问题;order_no建了唯一索引,这个索引在写入防重时能派上用场;user_id没有建联合索引,因为单表查询而已,没必要过度设计。created_at由数据库默认值填充,应用层不赋值,保证时间源头统一。
插数据我用一段初始化脚本先造一批测试数据,后面查和写的实战都基于这张表。测试数据量不需要太大,一万条足够看出性能差异,又不至于让实验等太久。
INSERT INTO `orders` (`order_no`, `user_id`, `product_name`, `amount`, `status`) SELECT CONCAT('ORD', LPAD(n, 8, '0')), FLOOR(1 + RAND() * 1000), CONCAT('产品', n % 50), ROUND(RAND() * 1000, 2), n % 4 FROM ( SELECT (a.n + b.n * 10 + c.n * 100 + d.n * 1000) AS n FROM (SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a, (SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b, (SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) c, (SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) d ) numbers WHERE n BETWEEN 1 AND 10000;这条SQL用笛卡尔积生成一万行数据,不用写存储过程,一次性灌进去。跑完顺手查一下记录数:
SELECT COUNT(*) FROM orders;确认有一万行,就可以开始写Go代码了。
2. 查询模块:从简单的单行查询到复杂的分页场景
查询这件事,看起来就是"SQL发出去、结果收回来",但Go标准库在结果处理上有一套自己的逻辑。我先从最基础的开始。
2.1 db.QueryRow 与 db.Query 的取舍
Go的database/sql把查询分成两种:单行查询用QueryRow,多行查询用Query。别小看这个区分,选错API会带来不必要的复杂度。
单行查询是这样写的:
func getOrderByOrderNo(orderNo string) (*Order, error) { var o Order err := db.QueryRow( "SELECT id, order_no, user_id, product_name, amount, status, created_at FROM orders WHERE order_no = ?", orderNo, ).Scan(&o.ID, &o.OrderNo, &o.UserID, &o.ProductName, &o.Amount, &o.Status, &o.CreatedAt) if err != nil { return nil, err } return &o, nil }有几个点要注意:QueryRow传参数用?,不是拼字符串。Scan的参数必须是地址,而且顺序必须和SELECT列顺序完全一致,否则类型对不上会直接报错。还有个细节:amount是decimal类型,Go这边对应的扫描类型一般是[]byte、string或自定义类型,我在这个例子里用了string接收,后面在扫描结果部分的坑里再展开。
多行查询用Query,扫描需要自己迭代:
func listOrdersByUser(userID int64, limit int) ([]*Order, error) { rows, err := db.Query( "SELECT id, order_no, user_id, product_name, amount, status, created_at FROM orders WHERE user_id = ? LIMIT ?", userID, limit, ) if err != nil { return nil, err } defer rows.Close() var orders []*Order for rows.Next() { var o Order if err := rows.Scan(&o.ID, &o.OrderNo, &o.UserID, &o.ProductName, &o.Amount, &o.Status, &o.CreatedAt); err != nil { return nil, err } orders = append(orders, &o) } // 循环结束后必须检查 rows.Err() if err := rows.Err(); err != nil { return nil, err } return orders, nil }defer rows.Close() 必须写,但很多人不知道这个Close要干什么。它实际是释放数据库连接回到连接池,不关的话连接会一直占着,连接池很快耗尽。循环结束后的 rows.Err() 同样不能省,因为迭代过程中如果连接断了,Next返回false,但错误在Err里,你不查就是静默失败。
2.2 扫描结果的毒坑:NULL和类型转换
查询结果到了Scan这一步,新手最容易翻车的就是NULL值处理。Go是强类型语言,直接把SQL的NULL扫描进string、int64会报错,Scan会返回类似sql: Scan error on column index 2: converting NULL to string is unsupported的错误。
处理NULL有一套标准写法,就是用sql包里的Null系列类型:
type Order struct { ID int64 OrderNo string UserID int64 ProductName string Amount string Status int CreatedAt time.Time } func getOrderNullable(orderNo string) (*Order, error) { var o Order var productName sql.NullString var amount []byte err := db.QueryRow( "SELECT id, order_no, user_id, NULL, amount, status, created_at FROM orders WHERE order_no = ?", orderNo, ).Scan(&o.ID, &o.OrderNo, &o.UserID, &productName, &amount, &o.Status, &o.CreatedAt) // 处理 }这里amount用[]byte接收是我推荐的实战方式,因为decimal到Go没有原生类型,用[]byte能保留精度的原始字符,后续转string再交给业务层处理。用float64接收decimal会有精度丢失风险,生产环境钱算错零点几元就是事故。这个坑我踩过,当时是订单金额,线上对账差出一分钱,查了好久才定位到是float类型转换的精度问题。
NULL值的正确处理思路是:先允许它扫描进Null类型,然后在自己代码里判断Valid属性,再决定业务逻辑。直接使用Null值传给下游,会遇到很多麻烦(例如JSON序列化时变成{"ProductName":{"String":"","Valid":false}},很恶心)。我一般会封装一层转换函数,把Null类型统一转成零值或默认值。
2.3 动态条件的拼接与防注入实践
实际项目里,查询条件经常是动态的。用户来了可能只传user_id,也可能同时传status和时间范围。这种场景下,最愚蠢的做法是拼字符串:
// 错误示范:千万不要这样拼 sql := "SELECT * FROM orders WHERE 1=1" if uid > 0 { sql += fmt.Sprintf(" AND user_id = %d", uid) }这种写法两个问题:一是可读性差,二是如果参数来自外部输入,等于把SQL注入的漏洞写在自己代码里。Go的标准方案是用占位符?,配合条件构造参数切片。
func searchOrders(userID int64, status *int, start, end string, page, pageSize int) ([]*Order, int64, error) { baseSQL := "SELECT {cols} FROM orders WHERE 1=1" var args []interface{} var conditions []string if userID > 0 { conditions = append(conditions, "user_id = ?") args = append(args, userID) } if status != nil { conditions = append(conditions, "status = ?") args = append(args, *status) } if start != "" { conditions = append(conditions, "created_at >= ?") args = append(args, start) } condSQL := "" if len(conditions) > 0 { condSQL = " AND " + strings.Join(conditions, " AND ") } countSQL := strings.Replace(baseSQL, "{cols}", "COUNT(*)", 1) var total int64 if err := db.QueryRow(countSQL+condSQL, args...).Scan(&total); err != nil { return nil, 0, err } rowsSQL := strings.Replace(baseSQL, "{cols}", "SELECT_COLS", 1) + " LIMIT ? OFFSET ?" args = append(args, pageSize, (page-1)*pageSize) rows, err := db.Query(rowsSQL+condSQL, args...) if err != nil { return nil, 0, err } defer rows.Close() // 迭代扫描... }这里关键的机制是:占位符的数量必须和args的顺序完全对应。Go会把你传的参数按照顺序绑定到SQL的?位上,MySQL驱动再做转义。这就是防注入的核心原理——值为参数传递,不是拼进SQL文本里。
LIMIT和OFFSET也放到args里一起绑定,注意args在count查询和列表查询的复用顺序。我把count先执行,再构建列表查询,这样args切片在列表查询时追加LIMIT参数不会影响count。
3. 数据输入(写入)模块:插入的正确姿势
查询讲完,轮到"输入数据"了。这块比查询更容易出事,重复插入、超时、主键冲突、批量插入性能差,都是实战里常见的。
3.1 Exec、Prepare与事务插入的选择
Go插入数据的入口是 db.Exec。最简单的方式:
func createOrder(o *Order) (int64, error) { res, err := db.Exec( "INSERT INTO orders (order_no, user_id, product_name, amount, status) VALUES (?, ?, ?, ?, ?)", o.OrderNo, o.UserID, o.ProductName, o.Amount, o.Status, ) if err != nil { return 0, err } return res.LastInsertId() }单条插入用这招没问题。但如果在循环里插一千条数据,每一轮都执行一次完整的"发送SQL→数据库解析→执行→返回",性能会很难看。优化方式是改用Prepare预处理:
stmt, err := db.Prepare("INSERT INTO orders (order_no, user_id, product_name, amount, status) VALUES (?, ?, ?, ?, ?)") if err != nil { return err } defer stmt.Close() for _, o := range orders { if _, err := stmt.Exec(o.OrderNo, o.UserID, o.ProductName, o.Amount, o.Status); err != nil { return err } }Prepare做的事情是让MySQL提前把SQL编译好,后面每次Exec只传参数,不再重复解析SQL文本。同一个结构、多次执行的场景,这个优化非常值得做。
但Prepare也有坑:stmt关联在单个连接上,连接池如果大,Prepare的复用效率可能会被连接切换影响。实际项目里如果循环次数不多(比如几十条以内),直接用Exec也不会有明显差距。我写代码的原则是:少于100次Exec直接裸插,超过100条上Prepare,数据量过万再上批量拼接。
事务插入就更复杂一点。多条写入需要保证原子性时,必须用事务:
tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() stmt, err := tx.Prepare("INSERT INTO orders (order_no, user_id, product_name, amount, status) VALUES (?, ?, ?, ?, ?)") if err != nil { return err } defer stmt.Close() for _, o := range orders { if _, err := stmt.Exec(o.OrderNo, o.UserID, o.ProductName, o.Amount, o.Status); err != nil { return err } } return tx.Commit()defer tx.Rollback() 是相当重要的一行。它保证即使事务中途出错没走到Commit,连接归还给连接池之前一定会回滚,不会把半个事务状态留在连接上。注意:Rollback在Commit之后调用会返回一个ErrTxDone错误,被defer忽略掉即可,不影响主逻辑。
3.2 LastInsertId的必要性与取值时机
插入之后拿自增ID,是非常常规的需求。res.LastInsertId()可以拿到数据库生成的自增主键值。
但它有一个使用场景限制要注意:如果插入语句插入多行(用VALUES多个元组),LastInsertId只返回第一条生成的自增ID。另外,如果表没有自增主键,这个值就是0,用的时候最好先判断一下。
实战中还有种情况:order_no是唯一索引,业务上要求同一个order_no只能插入一次。直接Exec会返回主键冲突错误。但MySQL有个方言语法可以优雅地处理:
INSERT INTO orders (order_no, user_id, product_name, amount, status) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE `id` = `id`这个写法在冲突时不更新任何字段,只让id自增一次,达到"重复请求不产生新记录"的效果。我处理过支付回调重复通知的场景,就是靠这个语法做幂等的,配合LastInsertId拿到第一次创建时的ID。
3.3 批量插入的性能对比:一条条插 vs 拼接批量插
很多新手第一次给一批数据建表时,会把循环Exec当成标配。我专门做个对比实验,一万条订单数据,三种插入方式:
- 方式A:循环Exec,一条条插
- 方式B:Prepare + 循环Exec
- 方式C:拼接一条SQL,一次性插入一万行(注意有max_allowed_packet限制)
实测数据(本地MySQL 8.0,默认配置):
| 方式 | 耗时 | 说明 |
|---|---|---|
| 循环Exec | 约3800ms | 每条约0.38ms,网络往返和SQL解析成本拖累明显 |
| Prepare循环 | 约2100ms | 免去重复解析,速度提升约45% |
| 拼接批量插入 | 约90ms | 提升接近40倍,但SQL包体积大,需要调大max_allowed_packet |
拼接批量插入的SQL长这样:
func batchInsertOrders(orders []*Order) error { if len(orders) == 0 { return nil } var sb strings.Builder sb.WriteString("INSERT INTO orders (order_no, user_id, product_name, amount, status) VALUES ") args := make([]interface{}, 0, len(orders)*5) for i, o := range orders { if i > 0 { sb.WriteString(",") } sb.WriteString("(?, ?, ?, ?, ?)") args = append(args, o.OrderNo, o.UserID, o.ProductName, o.Amount, o.Status) } _, err := db.Exec(sb.String(), args...) return err }用strings.Builder拼SQL,比用+号拼接省内存。参数个数几万个的话,数据库默认的max_allowed_packet(一般是64MB)可能不够,报错packet too large时需要调大这个参数。另外MySQL对一个INSERT语句的VALUES元组数量有限制,默认情况下超过几万行会报错,所以即便是批量插入,我也建议分片处理,一般一次500到1000条比较稳。Tps测试里一次一千条,一万条数据410ms左右就能插完。
这个实验能说明一个核心道理:数据库操作要批量做事,尽量把多次往返合并成少数几次。但对于超大批量(例如几十万、上百万行)导入,更合适的方案是用LOAD DATA INFILE,或者分批事务,不要指望一条SQL全塞进去。
4. 连接池、事务与并发写入:压测前必须懂的事
代码层面查和写都跑通了,还不能高兴太早。并发上来之后,连接池、事务隔离级别、锁冲突,才是决定系统稳不稳的关键。
4.1 连接池三参数怎么定
Go标准库的sql.DB是个连接池管理器。默认情况下,连接是懒创建的,池的大小理论上没上限。生产环境如果不管连接池参数,高并发场景很容易把数据库连接数打满,报错too many connections。
三个参数是必须关心的:
db.SetMaxOpenConns(100) // 最大打开的连接数 db.SetMaxIdleConns(20) // 最大空闲连接数 db.SetConnMaxLifetime(30 * time.Minute) // 连接最长存活时间SetMaxOpenConns是上限,超过这个数再要连接,程序会阻塞等待,直到有连接释放。不是无限等,直到设置了context超时。SetMaxIdleConns是连接池里保留的最多空闲连接,保留太多占资源,保留太少高并发时频繁创建销毁连接反而慢。SetConnMaxLifetime是防"老化连接",MySQL默认有个wait_timeout,空闲连接超过8小时会被服务端主动断开,客户端不知道,再用这个连接就报invalid connection。设成小于数据库wait_timeout的值,定期让连接重建,这个错误能有效减少。
这些参数的取值没有银弹,跟QPS、SQL耗时、数据库规格都有关。我一般起步是Open=100,Idle=20,Lifetime=30分钟,压测后再调整。
4.2 事务隔离级别与锁的实战理解
Go里的事务默认用的是MySQL的默认隔离级别,即Repeatable Read(可重复读)。这个级别下,事务内两次相同的SELECT结果一致,安全性高,但并发写的时候锁的开销也大。
实际项目里如果业务场景允许,把隔离级别降到Read Committed能减少锁等待和死锁概率。Go里可以这样指定:
tx, err := db.BeginTx(context.Background(), &sql.TxOptions{ Isolation: sql.LevelReadCommitted, })写数据时,InnoDB的行级锁很重要。比如根据order_no查询后再插入来完成"查询-判断-写入",在并发场景会出问题,两个请求同时查到不存在,然后同时插入,其中一条撞唯一索引报错。
解决这种"并发幂等"业务场景,除了前面提到的ON DUPLICATE KEY UPDATE,还可以考虑对关键插入路径做唯一索引防御。对订单场景来说,order_no上的唯一索引就是最好的防重屏障。如果语义上不能靠唯一索引解决,就得用SELECT ... FOR UPDATE锁行。
事务里我踩过最深的一个坑是:死锁后默认会立刻报错,但有些业务代码把错误吞掉了,导致数据在事务中部分提交、部分回滚,难以排查。事务内任何错误都要尽快处理并回滚,不让事务进入一个未知状态。
4.3 并发写入冲突与重试策略
高并发写入时,两个事务同时操作同一行或同一范围的数据,会出现锁等待。默认情况下,InnoDB在锁等待超过innodb_lock_wait_timeout(默认50秒)后会放弃,返回Lock wait timeout exceeded。
如果业务允许重试,可以封装一个带重试的写函数:
func execWithRetry(query string, args ...interface{}) (sql.Result, error) { var result sql.Result var err error for i := 0; i < 3; i++ { result, err = db.Exec(query, args...) if err == nil { return result, nil } if isRetryableError(err) { time.Sleep(time.Duration(i+1) * 100 * time.Millisecond) continue } return nil, err } return result, err }isRetryableError里判断错误码。MySQL驱动返回的error可以转成*mysql.MySQLError,看Number字段,常见的可重试错误码:1205(Lock wait timeout)、1213(Deadlock)、1062(Duplicate entry,这个取决于业务语义)。
重试逻辑要加退避,不是立即重试。我习惯用1、3、5的等差退避叠加随机抖动,避免多请求同时重试造成雪崩。如果3次重试都失败,直接打到日志告警,不要再无限循环。
5. 高频问题排查实录:这些坑我踩过
5.1 sql.ErrNoRows:查不到数据不是错误
Go标准库一个让很多人困惑的点:查询不到数据,QueryRow().Scan会返回sql.ErrNoRows,但它不是业务层的"错误"。
我刚接触Go的时候,也写过对ErrNoRows做err != nil直接返回的逻辑,结果接口把"查无此人"全部变成了500错误。后来习惯了才改过来:
err := db.QueryRow(...).Scan(...) if errors.Is(err, sql.ErrNoRows) { // 这是正常分支,按查不到处理 return nil, nil } if err != nil { // 这才是真正的数据库错误 return nil, err }这里推荐用errors.Is而不是==,因为现在Go 1.13之后错误链是常态,errors.Is才是标准判断。这个习惯能避免因为包装错误导致的判断失效。
5.2 时区、字符集与各类连接错误
时区问题典型表现在时间字段差了8小时。这背后主要是两头的时区设置不一致。连接串上建议显式指定parseTime、loc和charset:
db, err := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/dbname?parseTime=true&loc=Local&charset=utf8mb4")parseTime=true能让MySQL的DATETIME/TIMESTAMP扫描进time.Time而不是字符串;loc=Local匹配本机时区,避免誤差;charset=utf8mb4是必须的,utf8mb4才是完整的UTF-8,能让emoji和生僻字正常存取。不写parseTime,Scan到time.Time会报unsupported Scan错误。
连接错误里最常见的是invalid connection,通常就是连接被数据库服务端断开,客户端还在用。SetConnMaxLifetime设置的曲线思维能解决。另一个是connection refused,数据库端口没开或服务没起来,这类一般和代码无关了。
5.3 一个连接池耗尽的事故复盘
我实际遇到过的一次事故,非常典型:某服务平时QPS不高,连接池设置成Open=50就够了。某天别的服务慢查询拖垮了数据库,这个服务所有请求都变慢,连接占用时间变长,连接池很快占满,新请求全在等连接,响应全部超时。雪崩就这样发生了。
复盘下来有几个改进点:
第一个是设置Open连接数不是越大越好。连接池大了,数据库那边也会有不少空闲连接等待请求,手头资源紧张时反而更危险。核心是控制单个SQL的执行时间,超时要求快。
第二个是Get连接必须带超时。用db.QueryRowContext、db.ExecContext,配合后端函数传context,可以做到:
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() rows, err := db.QueryContext(ctx, "SELECT id FROM orders WHERE user_id = ?", userID)这样连接池阻塞等待时,最多等3秒就放弃,不会无限挂起请求。上线之前做一次并发压测,看看连接池参数下的表现再调优。
第三点是监控连接池指标。sql.DB的Stats()方法返回OpenConnections、InUse、Idle等数据,定期采样到监控面板,比如平均等待时长、最大等待时长,能预警连接池耗尽风险。
最后分享一个小经验:调试数据库问题时,先看MySQL慢查询日志,再看代码执行的SQL有没有索引命中,然后查连接池参数,大多数问题都能找到根子。Go的database/sql这套标准库非常稳,实际项目里大部分事故都不是库的问题,是你没用对它。