Go - Mysql数据库(2)

339 阅读13分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第22天,点击查看活动详情

5.更新数据和事务

现在,我们准备看看如何修改数据和处理事务。如果您习惯于使用 「statement」对象来获取行以及更新数据的语言进行编程,这种区别可能看起来是人为的,但是在 Go 语言中,这种区别是有重要原因的。

修改数据的语句

使用 Exec(),最好是使用预编译语句,来完成 INSERTUPDATEDELETE 或其他不返回行的语句。以下示例显示如何插入行并检查有关该操作的元数据:

stmt, err := db.Prepare("INSERT INTO users(name) VALUES(?)")
if err != nil {
	log.Fatal(err)
}
res, err := stmt.Exec("Dolly")
if err != nil {
	log.Fatal(err)
}
lastId, err := res.LastInsertId()
if err != nil {
	log.Fatal(err)
}
rowCnt, err := res.RowsAffected()
if err != nil {
	log.Fatal(err)
}
log.Printf("ID = %d, affected = %d\n", lastId, rowCnt)

执行该语句将产生一个 sql.Result,它提供对语句元数据的访问:最后插入的 ID 和受影响的行数。

如果您不关心执行结果,该怎么办?如果您只想执行一个语句并检查是否有错误,而忽略结果呢?下面的两个语句不是做了同样的事情吗?

_, err := db.Exec("DELETE FROM users")  // OK
_, err := db.Query("DELETE FROM users") // BAD

答案是 no。它们 不会 做同样的事情,也请您永远不要这样使用 Query()Query() 将返回一个 sql.Rows,它将保留数据库连接,直到 sql.Rows 关闭。由于可能存在未读数据 (例如,更多的数据行),因此无法使用该连接。在上面的示例中,连接将 永远 不会被释放。垃圾收集器最终将为您关闭底层 net.Conn,但这可能需要很长时间。此外,database/sql 包会在连接池中继续跟踪连接,希望您在某个时候释放它,以便可以再次使用该连接。因此,此反模式是耗尽资源 (例如,连接过多) 的好方法。

使用事务

在 Go 中,事务本质上是一个保留与数据存储区连接的对象。它可以让您执行到目前为止所看到的所有操作,但可以保证它们将在同一连接上执行。

您可以通过调用 db.Begin() 开启事务,然后使用该函数生成的 Tx 对象上的 Commit() 或 Rollback() 方法来结束事务。在后台,Tx 从池中获得连接,并将其保留,以仅用于该事务。Tx 上的方法一对一映射到您可以在数据库本身上调用的方法,例如 Query() 等。

在事务中创建的预处理语句专门绑定到该事务。有关更多信息,请参见 预处理语句 (opens new window)

您不应该在 SQL 代码中混合使用与事务相关的函数(例如 Begin() 和 Commit())和 SQL 关键字(例如 BEGIN 和 COMMIT)。这可能会导致不良后果:

  • Tx 数据库对象可能保持打开状态,保留池中的连接而不返回它。
  • 数据库的状态可能与代表它的 Go 变量的状态不同步。
  • 您可能会认为您正在事务内部的单个连接上执行查询,而实际上 Go 已经为您创建了多个不可见的连接,并且某些语句不是该事务的一部分。

在事务内部进行操作时,应注意不要调用 db 变量。进行所有对您使用 db.Begin() 创建的 Tx 变量的调用。db 不在事务中,只有 Tx 对象在事务中。如果您进一步调用 db.Exec() 或类似方法,则这些调用将在事务范围之外发生在其他连接上。

如果您需要使用多个修改连接状态的语句,即使您本身不需要事务,也需要 Tx。例如:

  • 创建临时表,仅对一个连接可见。
  • 设置变量,例如 MySQL 的 SET @var:= somevalue 语法。
  • 更改连接选项,例如字符集或超时。

如果您需要执行上述任何操作,则需要将活动绑定到单个连接,而 Go 中唯一的方法是使用 Tx

6.使用预处理语句

预处理语句具有 Go 的所有常规好处:安全性、效率、便利性。但是它们的实现方式与您习惯的可能有所不同,尤其是在它们如何与 database/sql 的某些内部组件交互方面。

预处理语句和连接

在数据库级别,一条预处理语句绑定到单一数据库连接。典型的流程是,客户端将带有占位符的 SQL 语句发送到服务器进行预处理,服务器以语句 ID 进行响应,然后客户端通过发送其 ID 和参数来执行该语句。

但是,在 Go 中,连接不会直接向 database/sql 包的用户公开。您不需要针对连接做语句预处理。您可以在 DB 或 Tx 上进行。并且 database/sql 包中有一些方便的行为,例如自动重试。由于这些原因,存在于驱动程序级别上的预处理语句和连接之间的底层关联对您的代码是隐藏的。

运作方式如下:

  1. 当您预处理语句时,它是在池中的连接上预处理的。
  2. Stmt 对象会记住使用了哪个连接。
  3. 当您执行 Stmt 时,它将尝试使用那个连接。如果由于被关闭或忙于执行其他操作而无法使用时,它将从池中获取另一个连接,并在另一个连接上使用数据库重新预处那条理语句

由于在原始连接繁忙时,语句会根据需要重新预处理,因此数据库的高并发使用可能会导致大量连接繁忙,从而创建大量的预处理语句。这可能导致明显的语句泄漏,预处理和重新预处理语句比您想象的更频繁,甚至会遭遇服务器端语句数量的限制。

避免预处理语句

Go 在幕后为您创建预处理语句。例如,一个简单的 db.Query(sql,param1,param2) 的工作方式是:预处理 sql,然后使用参数执行它,最后关闭语句。

有时,预处理语句不是您想要的。这可能有几个原因:

  1. 数据库不支持预处理语句。例如,使用 MySQL 驱动程序时,您可以连接到 MemSQL 和 Sphinx,因为它们支持 MySQL 有线协议。但是它们不支持包含预处理语句的「二进制」协议,因此它们可能以令人困惑的方式失败。
  2. 这些语句的重用程度不足以使它们变得有价值,而且安全问题以其他方式处理,因此性能开销是不需要的。这方面的一个例子可以在 VividCortex 博客 (opens new window)上看到。

如果您不想使用预处理语句,则需要使用 fmt.Sprint() 或类似的方法来组装 SQL,并将其作为唯一参数传递给db.Query() 或 db.QueryRow()。而且您的驱动程序需要支持纯文本查询执行,这是通过 Execer 和 Queryer 接口在 Go 1.1 中添加的,文档在此 (opens new window)

事务中的预处理语句

在 Tx 中创建的预处理语句以独占方式绑定到它,因此先前有关重新预处理的警告并不适用。当您对 Tx 对象进行操作时,您的操作将直接映射到该对象下的一个且只有一个连接。

这也意味着在 Tx 内部创建的预处理语句不能单独使用。同样地,在 DB 上创建的预处理语句也不能在事务中使用,因为它们将绑定到不同的连接。

要在 Tx 中使用在事务外部已预处理过的预处理语句,可以使用 Tx.Stmt(),它将从在事务外部预处理的那一条语句创建一个特定于事务的新语句。为此,它采用现有的预处理语句,将连接设置为事务的连接,并在每次执行时重新预处理所有语句。这种行为及其实现是不可取的,甚至在 database/sql 源代码中还有一个 TODO 来改进它;我们建议您不要使用它。

在事务中使用预处理语句时必须谨慎。考虑以下示例:

tx, err := db.Begin()
if err != nil {
	log.Fatal(err)
}
defer tx.Rollback()
stmt, err := tx.Prepare("INSERT INTO foo VALUES (?)")
if err != nil {
	log.Fatal(err)
}
defer stmt.Close() // danger!
for i := 0; i < 10; i++ {
	_, err = stmt.Exec(i)
	if err != nil {
		log.Fatal(err)
	}
}
err = tx.Commit()
if err != nil {
	log.Fatal(err)
}
// stmt.Close()在这里运行!

在 Go 1.4 之前,如果关闭一个 *sql.Tx 将与其关联的连接释放回池中,但延迟调用关闭预处理语句的执行,是发生在这种情况 之后 的,这可能导致对底层连接的并发访问,导致连接状态不一致。如果您使用 Go 1.4 或更早版本,则应确保在提交或回滚事务之前始终关闭该语句。这个问题 (opens new window)在 Go 1.4 中已由 CR 131650043 (opens new window)修复。

参数占位符语法

预处理语句中占位符参数的语法是特定于数据库的。例如,下面比较 MySQL、PostgreSQL 和 Oracle:

MySQL                PostgreSQL                Oracle
============         ==================        =============
WHERE col = ?        WHERE col = $1            WHERE col = :col
VALUES(?, ?, ?)      VALUES($1, $2, $3)        VALUES(:val1, :val2, :val3)

7.处理错误

几乎所有使用 database/sql 类型的操作都将错误作为最后一个值返回。您应该始终检查这些错误,永远不要忽略它们。

在一些地方,错误行为是特殊情况,或者您可能需要了解一些额外信息。

来自迭代结果集产生的错误

考虑以下代码:

for rows.Next() {
    // ...
}
if err = rows.Err(); err!= nil {
    // 在这里处理错误
}

来自 rows.Err() 的错误可能是 rows.Next() 循环中各种错误的结果。除了正常完成循环外,循环可能出于其他原因退出,因此您始终需要检查循环是否正常终止。异常终止会自动调用 rows.Close(),尽管多次调用无害。

来自关闭结果集产生的错误

如前所述,如果过早退出循环,则应始终显式关闭sql.Rows。如果循环正常退出或由于错误退出,它将自动关闭,但您可能会错误地这样做:

for rows.Next() {
	// ...
	 break;  // 糟糕,行未关闭!内存泄漏...
}
// 执行通常的 "if err = rows.Err()" [此处省略] ...
// 在这里 [re?] 关闭总是安全的:
if err = rows.Close(); err != nil {
	// 但是如果出现错误该怎么办?
	log.Println(err)
}

由 rows.Close() 返回的错误是常规规则的唯一例外,该常规规则是最好捕获并检查所有数据库操作中的错误。如果 rows.Close() 返回一个错误,则不清楚应该做什么。记录错误消息或 panicing 可能是唯一明智的选择,如果这不明智,那么也许您应该忽略该错误。

来自 QueryRow() 的错误

考虑以下获取单行的代码:

var name string
err = db.QueryRow("select name from users where id = ?", 1).Scan(&name)
if err != nil  {
	log.Fatal(err)
}
fmt.Println(name)

如果没有 id = 1 的用户怎么办?那么结果中就没有行了,而且 .Scan() 也不会将值扫描到 name 中。那会发生什么呢?

Go 定义了一个特殊的错误常量,称为 sql.ErrNoRows,当结果为空时从 QueryRow() 返回该常量。在大多数情况下,这需要作为特殊情况进行处理。应用程序代码通常不会将空结果视为错误,如果不检查错误是否为这个特殊常量,就会导致意想不到的应用程序代码错误。

来自查询的错误会被推迟到调用 Scan() 时,然后再返回。上面的代码最好这样写:

var name string
err = db.QueryRow("select name from users where id = ?", 1).Scan(&name)
if err != nil {
	if err == sql.ErrNoRows {
		//没有行,但也没有错误发生
	} else {
		log.Fatal(err)
	}
}
fmt.Println(name)

有人可能会问,为什么将空结果集视为错误。空集没有任何错误。原因是 QueryRow() 方法需要使用此特殊情况,以便让调用者区分 QueryRow() 是否真的找到了一行;如果没有它,Scan() 将无法执行任何操作,您可能不会意识到您的变量根本没有从数据库中获得任何值。

只有在使用 QueryRow() 时才会遇到这个错误。如果您在其他地方遇到此错误,则说明您做错了什么。

识别特定的数据库错误

编写如下代码可能很有诱惑力:

rows, err := db.Query("SELECT someval FROM sometable")
// 错误包含:
// ERROR 1045 (28000):Access denied for user 'foo'@'::1' (using password: NO)
if strings.Contains(err.Error(), "Access denied")  {
	// 处理被拒绝的错误
}

然而,这并不是最好的方法。例如,字符串值可能会有所不同,具体取决于服务器使用哪种语言发送错误消息。最好通过比较错误编号来确定具体的错误是什么。

但是,执行此操作的机制因驱动程序而异,因为它本身不是 database/sql 的一部分。在本教程重点关注的 MySQL 驱动程序中,您可以编写以下代码:

if driverErr, ok := err.(*mysql.MySQLError); ok { // 现在可以直接访问错误编号
	if driverErr.Number == 1045 {
		// 处理被拒绝的错误
	}
}

同样,这里的 MySQLError 类型是由这个特定的驱动程序提供的,而且 .Number 字段可能在不同的驱动程序之间有所不同。然而,该数字的值是从 MySQL 的错误消息中获取的,因此是特定于数据库的,而不是特定于驱动程序的。

这段代码仍然很难看。与 1045(一个魔术般的数字)相比,这是一种代码味道。一些驱动程序(虽然不是 MySQL 驱动程序,但由于一些与本文无关的原因) 提供了错误标识符列表。例如,Postgres pq 驱动程序可以在 error.go (opens new window)中使用。还有一个由 VividCortex 维护的 MySQL 错误编号 (opens new window)的外部包。使用这样的列表,可以更好地编写上面的代码,如下所示:

if driverErr, ok := err.(*mysql.MySQLError); ok  {
	if driverErr.Number == mysqlerr.ER_ACCESS_DENIED_ERROR {
		// 处理被拒绝的错误
	}
}

处理连接错误

如果您与数据库的连接被删除、终止或出现错误怎么办?

发生这种情况时,您不需要实施任何逻辑来重试失败的语句。作为 database/sql 中 连接池 (opens new window)的一部分,内置了对失败连接的处理。如果您执行查询或其他语句,而底层连接失败,那么 Go 将重新打开一个新连接 (或仅从连接池获取另一个连接),然后重试,最多重试 10 次。

然而,这可能会有一些意想不到的后果。当其他错误情况发生时,可能会重试某些类型的错误。这也可能是特定于驱动程序的。MySQL 驱动程序发生的一个示例是,使用 kill 取消不需要的语句 (如长时间运行的查询) 会导致该语句最多重试 10 次。

8.处理 NULL

可为空的列很烦人,并且会导致很多难看的代码。如果可以,请避免使用它们。如果不可,那么您将需要使用 database/sql 包中的特殊类型来处理它们,或定义自己的类型。

可空的类型有布尔值、字符串、整数和浮点数。它们的使用方法如下:

for rows.Next() {
	var s sql.NullString
	err := rows.Scan(&s)
	// 检查 err
	if s.Valid {
	   // 使用 s.String
	} else {
	   // NULL 值
	}
}

可空类型的限制,以及在需要更有说服力的情况下避免可空列的原因:

  1. 没有 sql.NullUint64 或 sql.NullYourFavoriteType。您需要为此定义您自己的类型。
  2. 可空性是很棘手的,而且不是面向未来的。如果您认为某些东西不会是空的,但是您错了,那么您的程序将会崩溃,可能很少出现,以至于您无法在发布错误之前捕获它们。
  3. Go 的优点之一是每个变量都有一个有用的默认零值。这不是可为空的事情的工作方式。

如果您需要定义自己的类型来处理 NULL,您可以复制 sql.NullString 的设计来实现这一点。

如果您无法避免在数据库中包含 NULL 值,那么还有另一种方法可以解决大多数数据库系统支持的问题,那就是 COALESCE()。以下内容可能是您可以使用的内容,而无需引入大量的 sql.Null* 字段类型。

rows, err := db.Query(`
	SELECT
		name,
		COALESCE(other_field, '') as otherField
	WHERE id = ?
`, 42)

for rows.Next() {
	err := rows.Scan(&name, &otherField)
	// ..
	// 如果 `other_field` 为 NULL,那么 `otherField` 现在为空字符串。这也适用于其他数据类型。
}

9.未知的列

Scan() 函数要求您传递正确数量的目标变量。如果您不知道查询将返回什么结果,该怎么办?

如果您不知道查询将返回多少列,则可以使用 Columns() 查找列名列表。您可以检查此列表的长度以查看有多少列,并且可以使用正确的值数将一个切片传递到 Scan() 中。例如,某些 MySQL 分支会为 SHOW PROCESSLISTLIST 命令返回不同的列,因此您必须为此做好准备,否则会导致错误。这是一种实现方法;还有其他方法:

cols, err := rows.Columns()
if err != nil {
	// handle the error
} else {
	dest := []interface{}{ // Standard MySQL columns
		new(uint64), // id
		new(string), // host
		new(string), // user
		new(string), // db
		new(string), // command
		new(uint32), // time
		new(string), // state
		new(string), // info
	}
	if len(cols) == 11 {
		// Percona Server
	} else if len(cols) > 8 {
		// Handle this case
	}
	err = rows.Scan(dest...)
	// Work with the values in dest
}

如果您不知道列或其类型,则应使用 sql.RawBytes

cols, err := rows.Columns() // Remember to check err afterwards
vals := make([]interface{}, len(cols))
for i, _ := range cols {
	vals[i] = new(sql.RawBytes)
}
for rows.Next() {
	err = rows.Scan(vals...)
	// Now you can check each element of vals for nil-ness,
	// and you can use type introspection and type assertions
	// to fetch the column into a typed variable.
}

10.sql 连接池

在 database/sql 包中有一个基本连接池。控制或检查它的能力不多,但您可能会发现以下一些有用的知识:

  • 连接池意味着在单个数据库上执行两个连续的语句可能会打开两个连接并分别执行它们。对于程序员来说,很常见的情况是,他们的代码为什么会发生失常行为。例如,LOCK TABLES 后跟 INSERT 可能会阻塞,这是因为 INSERT 位于一个不持有表锁的连接上。
  • 连接是在需要并且池中没有可用的空闲连接时才创建的。
  • 默认情况下,连接数量没有限制。如果尝试一次执行很多操作,则可以创建任意数量的连接。这可能导致数据库返回错误,例如「连接过多」。
  • 在 Go 1.1 或更高版本中,您可以使用 db.SetMaxIdleConns(N) 来限制池中 空闲 连接的数量。不过,这并不限制池的大小。
  • 在 Go 1.2.1 或更高版本中,您可以使用 db.SetMaxOpenConns(N) 来限制与数据库的 总的 打开连接数。不幸的是,死锁错误 (opens new window)(修复 (opens new window)) 阻止 db.SetMaxOpenConns(N) 在 1.2 中安全使用。
  • 连接回收速度非常快。使用 db.SetMaxIdleConns(N) 设置大量空闲连接可以减少这种搅动,并有助于保持连接可重复使用。
  • 长时间保持空闲连接会导致问题 (例如 Microsoft Azure 上的 MySQL 中的 这个案例 (opens new window))。如果由于连接空闲时间太长而导致连接超时,请尝试 db.SetMaxIdleConns(0)
  • 您还可以通过设置 db.SetConnMaxLifetime(duration) 指定连接可重用的最长时间,因为重用长时间的连接可能会导致网络问题。这会延迟关闭未使用的连接,即可能会延迟关闭过期的连接。

11.注意事项尽管 database/sql 在您一旦习惯了就觉得很简单,但您可能会对它支持的用例的精妙之处感到惊讶。这在 Go 的核心库中很常见。

资源耗尽

如本网站所述,如果您不按预期使用 database/sql,肯定会给自己带来麻烦。通常是通过消耗某些资源或阻止其有效重用而造成的:

  • 打开和关闭数据库可能会导致资源耗尽。
  • 无法读取所有行或使用 rows.Close() 保留来自池的连接。
  • 对不返回行的语句使用 Query() 将保留来自池的连接。
  • 不了解 预处理语句 (opens new window)是如何工作的可能会导致大量额外的数据库活动。

uint64 值过大

这是一个令人惊讶的错误。如果设置了大的无符号整数的高位,则不能将其作为参数传递给语句:

_, err := db.Exec("INSERT INTO users(id) VALUES", math.MaxUint64) // Error

这将抛出错误。如果您使用 uint64 值,请务必小心,因为它们起初可能很小并且可以正常工作,但是随着时间的推移会增加并开始引发错误。

连接状态不匹配

有些事情会改变连接状态,这可能会引起问题,原因有两个:

  1. 某些连接状态,例如您是否在事务中,应该通过 Go 类型来处理。
  2. 您可能会假设您的查询在单个连接上运行,而事实并非如此。

例如,使用 USE 语句设置当前数据库是许多人通常要做的事情。但是在 Go 语言中,它只会影响运行它的连接。除非您处于事务中,否则您认为在该连接上执行的其他语句实际上可能在从池中获得的不同连接上运行,因此它们不会看到这种更改的影响。

此外,在更改连接之后,它将返回池,并可能污染某些其他代码的状态。这也是为什么不应该直接将 BEGIN 或 COMMIT 语句作为 SQL 命令发出的原因之一。

特定于数据库的语法

database/sql API 提供了面向行的数据库的抽象,但是特定的数据库和驱动程序的行为 和/或 语法可能有所不同,例如 预处理语句占位符 (opens new window)

多个结果集

Go 驱动程序不以任何方式支持单个查询的多个结果集,而且似乎也没有计划这样做,虽然有 功能请求 (opens new window)支持批量操作,例如批量复制。

这意味着,一个返回多个结果集的存储过程将无法正常工作。

调用存储过程

调用存储过程是特定于驱动程序的,但是在 MySQL 驱动程序中目前不支持。您似乎可以通过执行以下命令来调用返回单个结果集的简单过程:

err := db.QueryRow("CALL mydb.myprocedure").Scan(&result) // Error

实际上,这是行不通的。您将收到以下错误:错误 1312:PROCEDURE mydb.myprocedure 无法在给定的上下文中返回结果集。这是因为 MySQL 希望将连接设置为多语句模式,即使是针对单个结果也是如此,而驱动程序当前没有这样做 (请参见 此问题 (opens new window))。

多语句支持

database/sql 没有明确支持多条语句,这意味着其行为取决于后端:

_, err := db.Exec("DELETE FROM tbl1; DELETE FROM tbl2") // Error/unpredictable result

允许服务器根据需要进行解释,包括返回错误,仅执行第一条语句或同时执行这两项。

类似地,也没有办法在事务中批处理语句。事务中的每个语句都必须串行执行,结果中的资源 (比如一行或多行) 必须被扫描或关闭,以便底层连接可以自由地供下一个语句使用。这与不处理事务时的通常行为不同。在该场景中,完全有可能执行查询,循环遍历行,并在循环中对数据库进行查询 (这将发生在一个新的连接上):

rows, err := db.Query("select * from tbl1") // Uses connection 1
for rows.Next() {
	err = rows.Scan(&myvariable)
	// The following line will NOT use connection 1, which is already in-use
	db.Query("select * from tbl2 where id = ?", myvariable)
}

但是事务只绑定到一个连接,所以这对于事务是不可能的:

tx, err := db.Begin()
rows, err := tx.Query("select * from tbl1") // Uses tx's connection
for rows.Next() {
	err = rows.Scan(&myvariable)
	// ERROR! tx's connection is already busy!
	tx.Query("select * from tbl2 where id = ?", myvariable)
}

不过,Go 不会阻止您尝试。因此,如果您试图在第一条语句释放其资源并在其自身清除之前执行另一条语句,则可能会导致连接损坏。这也意味着,事务中的每个语句都会产生一组单独的数据库网络往返行程。