如何在Go中使用Postgres:做法,功能,细微差别


该应用程序与使用数据库有关的意外行为导致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 // Get   p     err = db.Get(&p, ".. LIMIT 1") // Select   pp   . err = db.Select(&pp, ".. WHERE telcode > ?", 50) 

车手


database/sql是用于处理数据库的一组接口,而sqlx是它们的扩展。 为了使这些接口起作用,它们需要一个实现。 驱动程序负责实施。


最受欢迎的驱动程序:


  • github.com/lib/pq- pure Go Postgres driver for database/sql. 该驱动程序长期以来一直是默认标准。 但是今天,它已经失去了相关性,作者尚未开发。
  • github.com/jackc/pgx- PostgreSQL driver and toolkit for Go. 今天最好选择此工具。

github.com/jackc/pgx-这是您要使用的驱动程序。 怎么了


  • 积极支持和发展
  • 如果在没有database/sql接口的情况下使用它,则可以提高生产率
  • 支持PostgreSQLSQL标准之外实现的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 LanePostgres内核的核心开发人员之一评论如下:


至少传统上,这至少是Unix计算机中管道缓冲区的大小,因此,原则上,这是通过Unix套接字发送数据的最佳块大小。

Andres FreundEnterpriseDB的Postgres开发人员认为 ,迄今为止8KB缓冲区并不是最佳的实现选项,您需要在不同大小和不同套接字配置下测试行为。


我们还必须记住,PgBouncer也有一个缓冲区,可以使用pkt_buf参数配置其大小。


OID


pgx( v3 )驱动程序的另一个功能:对于每个连接,它都会向数据库发出请求以获取有关对象IDOID )的信息。


这些标识符已添加到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 = //  OIDs   . info := pgtype.NewConnInfo() info.InitializeDataTypes(cachedOids) return info, nil } }) 

这是一种可行的方法,但是在两种情况下使用它可能会很危险:


  • 您在Postgres中使用枚举或域类型;
  • 如果向导失败,则将应用程序切换到由逻辑复制注入的副本。

满足这些条件将导致缓存的OIDs无效。 但是我们无法清理它们,因为我们不知道换新基地的时刻。


Postgres世界中,通常使用物理复制来组织高可用性,这种复制会一点一点地复制数据库实例,因此OIDs缓存的问题很少在野外出现。 ( 但是最好与您的DBA一起检查备用数据库的工作方式 )。


pgx驱动程序pgx的下一个主要版本中, 将没有针对OIDs活动。 现在,驱动程序将仅依赖在代码中OIDsOIDs列表。 对于自定义类型,您将需要在应用程序端控制反序列化:驱动程序将简单地放弃一块内存作为字节数组。


记录与监控


监视和记录将有助于在基础崩溃之前发现问题。


database/sql提供了DB.Stats()方法。 返回的状态快照将使您了解驱动程序内部正在发生的情况。


 type DBStats struct { MaxOpenConnections int // Pool State OpenConnections int InUse int Idle int // Counters WaitCount int64 WaitDuration time.Duration MaxIdleClosed int64 MaxLifetimeClosed int64 } 

如果直接在pgx使用池,则ConnPool.Stat()方法将为您提供类似信息:


 type ConnPoolStat struct { MaxConnections int CurrentConnections int AvailableConnections int } 

日志记录同样重要,并且pgx允许您执行此操作。 驱动程序接受Logger接口,通过实现该接口,您可以获得驱动程序内部发生的所有事件。


 type Logger interface { // Log a message at the given level with data key/value pairs. // data may be nil. Log(level LogLevel, msg string, data map[string]interface{}) } 

最有可能的是,您甚至不必自己实现此接口。 开箱即用的pgx中有一适用于最受欢迎的记录器的适配器 ,例如uber-go / zapsirupsen / logrusrs / 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将执行命令并在给定的过程中取消当前请求。 但是当前请求不会是我们最初想要取消的请求。 由于使用PgBouncerPostgres一起使用时的这种行为 PgBouncer更安全的PgBouncer不要在驱动程序级别取消请求。 为此,您可以设置CustomCancel ,即使使用context.Context ,该CustomCancel也不会取消请求。


 cfg := pgx.ConnConfig{ ... CustomCancel: func(_ *pgx.Conn) error { return nil }, } 

Postgres清单


我没有得出结论,而是决定制作一份与Postgres合作的清单。 这应该可以使这篇文章适合我的想法。


  • 使用github.com/jackc/pgx作为使用Postgres的驱动程序。
  • 从上方限制连接池的大小。
  • 缓存OIDs或使用pgx版本3时使用pgx.ConnPool
  • 使用DB.Stats()ConnPool.Stat()从连接池中收集指标。
  • 记录驱动程序中正在发生的事情。
  • 使用简单查询模式可以避免在PgBouncer事务模式下查询准备方面的问题。
  • PgBouncer更新到最新版本。
  • 取消应用程序的请求时要小心。

Source: https://habr.com/ru/post/zh-CN461935/


All Articles