
该应用程序与使用数据库有关的意外行为导致DBA和开发人员之间发生战争:DBA大喊:“您的应用程序删除了数据库”,开发人员-“但是,以前一切正常!” 最糟糕的是,DBA和开发人员无法互相帮助:有些人不了解应用程序和驱动程序的细微差别,另一些人不了解与基础架构相关的功能。 避免这种情况将很不错。
您必须了解,浏览go-database-sql.org通常是不够的。 最好用他人的经验武装自己。 如果这是一次鲜血和金钱损失的经验,那就更好了。
我的名字叫Ryabinkov Artemy ,本文是对我在Saints HighLoad 2019大会上的报告的免费解释。
工具
您可以在go-database-sql.org上找到有关如何使用任何类似SQL的数据库的最少必需信息。 如果您尚未阅读,请阅读。
sqlx
在我看来,Go的强大之处在于简单性。 例如,这表示Go习惯以裸SQL编写查询(ORM不受欢迎)。 这既是优势又是其他困难的根源。
因此,采用标准的database/sql
语言包,您将需要扩展其接口。 一旦发生这种情况,请查看github.com/jmoiron/sqlx 。 让我向您展示一些有关此扩展如何简化您的生活的示例。
使用StructScan无需手动将数据从列移入结构属性。
type Place struct { Country string City sql.NullString TelephoneCode int `db:"telcode"` } var p Place err = rows.StructScan(&p)
使用NamedQuery ,您可以将结构属性用作查询中的占位符。
p := Place{Country: "South Africa"} sql := `.. WHERE country=:country` rows, err := db.NamedQuery(sql, p)
使用“ 获取并选择”使您无需手动编写从数据库获取行的循环。
var p Place var pp []Place
车手
database/sql
是用于处理数据库的一组接口,而sqlx
是它们的扩展。 为了使这些接口起作用,它们需要一个实现。 驱动程序负责实施。
最受欢迎的驱动程序:
github.com/jackc/pgx-这是您要使用的驱动程序。 怎么了
- 积极支持和发展 。
- 如果在没有
database/sql
接口的情况下使用它,则可以提高生产率 。 - 支持
PostgreSQL
在SQL
标准之外实现的60多种PostgreSQL 。 - 方便地实现驱动程序内部事件记录的功能。
pgx
人类可读的错误 ,而lib/pq
引发恐慌攻击。 如果您没有惊慌,程序将崩溃。 ( 您不应在Go中使用恐慌,这与异常不同。 )- 使用
pgx
,我们可以独立配置每个连接 。 - 支持
PostgreSQL
逻辑复制协议 。
4KB
通常,我们编写此循环以从数据库获取数据:
rows, err := s.db.QueryContext(ctx, sql) for rows.Next() { err = rows.Scan(...) }
在驱动程序内部,我们通过将数据存储在4KB缓冲区中来获取数据。 rows.Next()
产生网络行程并填充缓冲区。 如果缓冲区不足,那么我们将进入网络以获取剩余数据。 更多的网络访问-更低的处理速度。 另一方面,由于缓冲区限制为4KB,所以请不要忘记整个进程的内存。
但是,当然,我想最大程度地松开缓冲区数量,以减少对网络的请求数量并减少我们的服务延迟。 我们增加了这个机会,并尝试找出综合测试的预期加速:
$ go test -v -run=XXX -bench=. -benchmem goos: linux goarch: amd64 pkg: github.com/furdarius/pgxexperiments/bufsize BenchmarkBufferSize/4KB 5 315763978 ns/op 53112832 B/op 12967 allocs/op BenchmarkBufferSize/8KB 5 300140961 ns/op 53082521 B/op 6479 allocs/op BenchmarkBufferSize/16KB 5 298477972 ns/op 52910489 B/op 3229 allocs/op BenchmarkBufferSize/1MB 5 299602670 ns/op 52848230 B/op 50 allocs/op PASS ok github.com/furdarius/pgxexperiments/bufsize 10.964s
可以看出,处理速度没有太大差异。 为什么这样
事实证明,我们受Postgres自身内部用于发送数据的缓冲区大小的限制。 该缓冲区的固定大小为8KB 。 使用strace
您可以看到 OS在读取的系统调用中返回了8192
字节。 tcpdump
通过数据包的大小来确认这一点。
Tom Lane ( Postgres内核的核心开发人员之一 ) 评论如下:
至少传统上,这至少是Unix计算机中管道缓冲区的大小,因此,原则上,这是通过Unix套接字发送数据的最佳块大小。
Andres Freund ( EnterpriseDB的Postgres开发人员 ) 认为 ,迄今为止8KB缓冲区并不是最佳的实现选项,您需要在不同大小和不同套接字配置下测试行为。
我们还必须记住,PgBouncer也有一个缓冲区,可以使用pkt_buf
参数配置其大小。
OID
pgx( v3 )驱动程序的另一个功能:对于每个连接,它都会向数据库发出请求以获取有关对象ID ( OID )的信息。
这些标识符已添加到Postgres中,以唯一标识内部对象:行,表,函数等。
驱动程序使用OIDs
知识来了解将哪种数据库列添加到哪种语言原语中以添加数据。 为此, pgx
支持这样的表( 键是类型名,值是Object ID )
map[string]Value{ "_aclitem": 2, "_bool": 3, "_int4": 4, "_int8": 55, ... }
这种实现导致以下事实:与数据库的每个已建立连接的驱动程序都会发出大约三个请求,以形成具有Object ID
的表。 在数据库和应用程序的正常操作模式下,Go中的连接池允许您不生成与数据库的新连接。 但是,即使数据库降级了一点点,应用程序侧的连接池也已耗尽,并且每单位时间生成的连接数显着增加。 对OIDs
请求非常繁重,因此,驱动程序可以使数据库进入临界状态。
这是将此类请求注入我们的数据库之一的时刻:

在正常模式下, 每分钟15 个事务 ,降级期间最多可跳跃6500个事务 。
怎么办
首先,从上方限制池的大小。
对于database/sql
可以使用DB.SetMaxOpenConns函数来完成。 如果放弃database/sql
接口并使用pgx.ConnPool
( 由驱动程序本身实现的连接池 ),则可以MaxConnections
指定MaxConnections
( 默认值为5 )。
顺便说一句,当使用pgx.ConnPool
驱动程序将重用有关接收到的OIDs
信息,并且不会为每个新连接对数据库进行查询。
如果您不想拒绝database/sql
,则可以自己缓存有关OIDs
信息。
github.com/jackc/pgx/stdlib.OpenDB(pgx.ConnConfig{ CustomConnInfo: func(c *pgx.Conn) (*pgtype.ConnInfo, error) { cachedOids =
这是一种可行的方法,但是在两种情况下使用它可能会很危险:
- 您在Postgres中使用枚举或域类型;
- 如果向导失败,则将应用程序切换到由逻辑复制注入的副本。
满足这些条件将导致缓存的OIDs
无效。 但是我们无法清理它们,因为我们不知道换新基地的时刻。
在Postgres
世界中,通常使用物理复制来组织高可用性,这种复制会一点一点地复制数据库实例,因此OIDs
缓存的问题很少在野外出现。 ( 但是最好与您的DBA一起检查备用数据库的工作方式 )。
在pgx
驱动程序pgx
的下一个主要版本中, 将没有针对OIDs
活动。 现在,驱动程序将仅依赖在代码中OIDs
的OIDs
列表。 对于自定义类型,您将需要在应用程序端控制反序列化:驱动程序将简单地放弃一块内存作为字节数组。
记录与监控
监视和记录将有助于在基础崩溃之前发现问题。
database/sql
提供了DB.Stats()方法。 返回的状态快照将使您了解驱动程序内部正在发生的情况。
type DBStats struct { MaxOpenConnections int
如果直接在pgx
使用池,则ConnPool.Stat()方法将为您提供类似信息:
type ConnPoolStat struct { MaxConnections int CurrentConnections int AvailableConnections int }
日志记录同样重要,并且pgx
允许您执行此操作。 驱动程序接受Logger
接口,通过实现该接口,您可以获得驱动程序内部发生的所有事件。
type Logger interface {
最有可能的是,您甚至不必自己实现此接口。 开箱即用的pgx
中有一组适用于最受欢迎的记录器的适配器 ,例如uber-go / zap , sirupsen / logrus , rs / zerolog 。
基础设施
在使用Postgres
时,几乎总是使用连接池 ,它将是PgBouncer ( 或奥德赛 -如果您是Yandex的话 )。
为什么这样,您可以阅读出色的文章brandur.org/postgres-connections 。 简而言之,当客户端数量超过100时,处理请求的速度开始下降。 发生这种情况的原因是Postgres本身实现的功能:为每个连接启动一个独立的进程,删除快照的机制以及使用共享内存进行交互-所有这些都会影响。
这是各种连接池实现的基准 :

具有和不具有PgBouncer的基准带宽。

结果,您的基础结构将如下所示:

Server
是处理用户请求的过程。 此过程将kubernetes
3个副本( 至少 )。 另外,在铁质服务器上有Postgres
,由PgBouncer'
覆盖。 PgBouncer
本身PgBouncer
单线程的,因此我们启动了多个启动器,使用HAProxy
可以平衡流量。 结果,我们在数据库中获得了这样的查询执行链: → HAProxy → PgBouncer → Postgres
。
PgBouncer
可以在三种模式下工作:
- 会话池 -对于每个会话,在整个生命周期内都会发出一个连接并将其分配给该连接。
- 事务池 -事务运行时连接有效。 交易完成后,
PgBouncer
会立即建立此连接并将其返回给另一笔交易。 此模式可以很好地处理化合物。 - 语句池 - 不建议使用的模式。 创建它仅是为了支持PL / Proxy 。
您可以看到每种模式下可用属性的矩阵 。 我们选择事务池 ,但是它在使用Prepared Statements
方面有局限性。
事务池+预备语句
假设我们要准备一个请求然后执行它。 在某个时候,我们开始一个事务,在该事务中发送一个Prepare请求,然后从数据库中获取已准备请求的ID。

之后,我们会在其他任何时间生成另一笔交易。 在其中,我们转到数据库,并希望使用带有指定参数的标识符来满足请求。

在事务池模式下,可以在不同的连接中执行两个事务,但是语句ID仅在一个连接中有效。 尝试执行请求时,我们得到一个prepared statement does not exist
错误。
最不愉快的是:由于在开发和测试过程中负载很小,因此PgBouncer
经常发出相同的连接,并且一切正常。 但是,一旦我们推出产品,请求就会因错误而下降。
现在,在以下代码中找到“ Prepared Statements
:
sql := `select * from places where city = ?` rows, err := s.db.Query(sql, city)
你不会看到他的! 查询准备将隐式发生在Query()
内部。 同时,请求的准备和执行将在不同的事务中进行,我们将完全收到我上述的所有信息。
怎么办
第一个最简单的选择是将PgBouncer
切换到Session pooling
。 一个连接分配给该会话,所有事务都开始进入该连接,并且准备好的请求正常工作。 但是在这种模式下,化合物的利用效率尚待提高。 因此,不考虑此选项。
第二种选择是在客户端准备请求 。 我不想这样做有两个原因:
- 潜在的SQL漏洞。 开发人员可能会忘记或错误地进行转义。
- 每次必须用手书写时,都转义查询参数。
另一种选择是将每个请求显式包装在事务中 。 毕竟,只要交易有效, PgBouncer
就不会建立连接。 这行得通,但是,除了代码中的冗长之外,我们还获得了更多的网络调用:开始,准备,执行,提交。 每个请求共4个网络通话。 延迟在增长。
但我希望它既安全又方便又有效。 并且有这样的选择! 您可以明确告知驱动程序您要使用简单查询模式 。 在这种模式下,将不需要任何准备,整个请求将通过一个网络呼叫传递。 在这种情况下,驱动程序将自行屏蔽每个参数( 必须在基本级别或建立连接时激活standard_conforming_strings
)。
cfg := pgx.ConnConfig{ ... RuntimeParams: map[string]string{ "standard_conforming_strings": "on", }, PreferSimpleProtocol: true, }
取消要求
以下问题与在应用程序端取消请求有关。
看一下这段代码。 陷阱在哪里?
rows, err := s.db.QueryContext(ctx, ...)
Go有一个控制程序执行流程的方法-context.Context 。 在此代码中,我们将ctx
传递ctx
驱动程序,以便在关闭上下文时,驱动程序在数据库级别取消请求。
同时,我们希望通过取消没有人等待的请求来节省资源。 但是,当请求被PgBouncer
1.7版会将信息发送到该连接,该连接已准备就绪,可以使用,然后将其返回到池中。 PgBouncer'
这种行为会误导驱动程序,该驱动程序在发送下一个请求时会立即收到ReadyForQuery
作为响应。 最后,我们捕获了意外的ReadyForQuery错误 。
从PgBouncer
1.8版开始,此行为已得到修复 。 使用当前版本的PgBouncer
。
而且,尽管在这种情况下,错误将消失-有趣的行为仍将保留。 在某些情况下,我们的应用程序可能收到的不是其请求的答案,而是相邻请求的答案(主要是请求与所请求数据的类型和顺序匹配)。 即,例如,对于where user_id = 2
的查询where user_id = 42
将返回where user_id = 42
的查询的响应。 这是由于在不同级别处理取消请求:在驱动程序池和保镖池级别。
延迟取消
要取消请求,我们需要创建与数据库的新连接并请求取消。 Postgres
为每个连接创建一个单独的过程。 我们发送命令以在特定过程中取消当前请求。 为此,请创建一个新的连接,并在其中将感兴趣的进程ID(PID)传送给我们。 但是,当取消命令飞向基地时,被取消的请求可能会自行结束。

Postgres
将执行命令并在给定的过程中取消当前请求。 但是当前请求不会是我们最初想要取消的请求。 由于使用PgBouncer
与Postgres
一起使用时的这种行为 PgBouncer
更安全的PgBouncer
不要在驱动程序级别取消请求。 为此,您可以设置CustomCancel
,即使使用context.Context
,该CustomCancel
也不会取消请求。
cfg := pgx.ConnConfig{ ... CustomCancel: func(_ *pgx.Conn) error { return nil }, }
Postgres清单
我没有得出结论,而是决定制作一份与Postgres合作的清单。 这应该可以使这篇文章适合我的想法。