CockroachDB权威指南——应用设计与实现

像所有数据库一样,CockroachDB 对来自应用程序代码的请求作出响应。应用程序如何请求和使用数据对应用程序性能和可扩展性有着巨大的影响。本章将回顾应用程序如何与 CockroachDB 一起工作,包括编写 CockroachDB 请求和事务模型的最佳实践。

由于 CockroachDB 与 PostgreSQL 的 wire 协议兼容,任何支持 PostgreSQL 的编程语言都可以与 CockroachDB 一起使用。通常,PostgreSQL 的编程惯例和最佳实践也适用于 CockroachDB。然而,由于 CockroachDB 是分布式的,因此在编程风格上与 PostgreSQL 存在一些差异。

虽然几乎可以使用任何常用的编程语言与 CockroachDB 配合工作,但在本章中,我们将把讨论局限于四种语言:Go、Java、Python 和 JavaScript。

在前面的章节中,我们展示了如何为每种语言安装驱动程序。有关驱动程序安装的说明,请参阅第 3 章,或者参考 CockroachDB 文档,获取更详细的指南,包括如何为其他语言或替代驱动程序安装驱动程序的指导。

CockroachDB 编程

CockroachDB 与 SQL 关系型数据库普遍兼容,尤其与 PostgreSQL 兼容。然而,由于其分布式特性和事务一致性模型,CockroachDB 有一些特定的编程惯例。

接下来的几节中,我们将回顾在 CockroachDB 服务器上编写应用程序时的基本原则,并查看一些特定于 CockroachDB 的问题。

执行 CRUD 操作

我们在第 3 章中为每种语言提供了基本的"Hello World"示例。现在让我们扩展这些示例,执行一些不那么简单的 CRUD 操作------创建、读取、更新、删除。

编程驱动程序在词汇上有所不同,但通常采用类似的语法。数据库程序的基本操作是:

  • 驱动程序建立一个表示与数据库服务器连接的连接对象。在本章中,我们将创建单独的连接,但应用程序通常会使用连接池来管理多个可重用的连接。
  • 连接对象用于执行 SQL 语句。
  • 一些语句返回结果集,可以用来遍历由 SELECT 语句、包括 RETURNING 子句的 DML 语句以及其他返回结果的语句返回的表格输出。

以下是 Java 中的基本模式:

ini 复制代码
package chapter06c;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;

public class example1 {

  public static void main(String[] args) {
    try {
      Class.forName("org.postgresql.Driver");
      String connectionURL = "jdbc:" + args[0];
      String userName = args[1];
      String passWord = args[2];

      Connection connection = DriverManager.getConnection(
          connectionURL, userName, passWord);
      Statement stmt = connection.createStatement();
      stmt.execute("DROP TABLE IF EXISTS names");
      stmt.execute("CREATE TABLE names (name String PRIMARY KEY NOT NULL)");
      stmt.execute("INSERT INTO names (name) VALUES('Ben'),('Jesse'),('Guy')");

      ResultSet results = stmt.executeQuery("SELECT name FROM names");
      while (results.next()) {
        System.out.println(results.getString(1));

      }
      results.close();
      stmt.close();
      connection.close();

    } catch (Exception e) {
      e.printStackTrace();
      System.exit(0);
    }
  }
}

我们创建了一个单独的连接对象和一个单独的语句对象,然后使用该语句来执行多个 SQL 命令。当我们执行查询时,我们创建了一个 ResultSet 对象,可以用来遍历结果。最后,我们关闭所有这些对象。

注意,我们可以通过位置或名称从 ResultSet 对象中检索列值------在这个示例中,我们提供了列的位置,但也可以指定列名。

接下来是 Python 中的相似逻辑。连接对象的 cursor() 方法创建一个游标对象,游标对象可以用来执行语句或遍历结果集:

python 复制代码
import psycopg2
import sys

def main():

  if ((len(sys.argv)) !=2):
    sys.exit("Error:No URL provided on command line")
  uri=sys.argv[1]

  connection = psycopg2.connect(uri)
  cursor=connection.cursor()
  cursor.execute("DROP TABLE IF EXISTS names")
  cursor.execute("""CREATE TABLE names
                    (name String PRIMARY KEY NOT NULL)""")
  cursor.execute("""INSERT INTO names (name)
                    VALUES('Ben'),('Jesse'),('Guy')""")
  cursor.execute("SELECT name FROM names")
  for row in cursor:
    print(row[0])
  cursor.close()
  connection.close()

main()

在 Node.js 程序中,我们做的是一样的:

javascript 复制代码
const CrClient = require('pg').Client;

async function main() {
    try {
        if (process.argv.length != 3) {
            console.log(`Usage: node ${process.argv[1]} CONNECTION_URI`);
            process.exit(1);
        }

        const connection = new CrClient(process.argv[2]);
        await connection.connect();

        await connection.query('DROP TABLE IF EXISTS names');
        await connection.query(`CREATE TABLE names
                                 (name String PRIMARY KEY NOT NULL)`);
        await connection.query(`INSERT INTO names (name)
                                VALUES('Ben'),('Jesse'),('Guy')`);

        const data = await connection.query('SELECT name from names');
        data.rows.forEach((row) => {
            console.log(row.name);
        });
    } catch (error) {
        console.error(error.stack);
    }
    process.exit(0);
}

main();

我们使用了"async/await"风格来处理异步数据库请求。如果这是你的编程风格,你也可以使用回调或 promises。node-postgres 驱动文档包含了使用每种编程风格的示例。

最后,让我们看一下如何在 Go 中执行相同的任务:

scss 复制代码
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/jackc/pgx"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "Missing URL argument")
		os.Exit(1)
	}
	uri := os.Args[1]
	conn, err := pgx.Connect(context.Background(), uri)
	if err != nil {
		fmt.Fprintf(os.Stderr, "CockroachDB error: %v\n", err)
	}
	execSQL(*conn, "DROP TABLE IF EXISTS names")
	execSQL(*conn, "CREATE TABLE names (name String PRIMARY KEY NOT NULL)")
	execSQL(*conn, "INSERT INTO names(name) VALUES('Ben'),('Jesse'),('Guy')")

	rows, err := conn.Query(context.Background(), "SELECT name FROM names")
	if err != nil {
		fmt.Fprintf(os.Stderr, "CockroachDB error: %v\n", err)
	}
	defer rows.Close()
	for rows.Next() {
		var name string
		err = rows.Scan(&name)
		fmt.Println(name)
	}
}

func execSQL(conn pgx.Conn, sql string) {
	result, err := conn.Exec(context.Background(), sql)
	if err != nil {
		fmt.Fprintf(os.Stderr, "CockroachDB error: %v\n", err)
		os.Exit(1)
	}
	fmt.Fprintf(os.Stdout, "%v rows affected\n", result.RowsAffected())
}

在 Go 示例中,我们创建了 execSQL 函数,以模块化重复的错误检查,尽管在生产代码中,我们通常会为每个查询独立进行错误检查。

连接池

创建小型可重用的例程来执行重复任务通常是一个好的实践。如果服务需要访问数据库,那么为每个例程提供一个专用连接可能看起来是自然的做法。这相比于单个共享连接具有明显的优势,因为它允许并发请求。例如,假设我们有一个简单的 Web 服务,每当在我们的拼车应用中开始一次新行程时,我们都会调用它。

我们可能会这样编写数据库逻辑:

scss 复制代码
async function newRide(city, riderId, vehicleId, startAddress) {
    const connection = new pg.Client(connectionString);
    await connection.connect();
    const sql = `INSERT INTO movr.rides
    (id, city,rider_id,vehicle_id,start_address,start_time)
    VALUES(gen_random_uuid(), $1,$2,$3,$4,now())`;
    await connection.query(sql, [city, riderId, vehicleId, startAddress]);
    await connection.end();
}

我们不希望将这些请求串行化,所以每次调用都为它分配一个独立的连接。不幸的是,创建连接有一个不容忽视的开销。当数据库访问比较简单时,创建和销毁连接所花费的时间可能会主导整体响应时间。但我们不能将所有请求都通过同一个连接,因为那样会限制并发查询的能力。

解决方案是使用连接池。连接池是一个可以被应用程序重用的连接集。这样可以避免不断创建和销毁连接的开销,并且可以控制数据库的最大并发量。

在 Node.js 中,我们可以如下创建连接池:

arduino 复制代码
const pool = new pg.Pool({
    connectionString,
    max: 40
});

现在,我们可以修改我们的例程,让它从连接池中获取连接:

scss 复制代码
async function newRidePool(city, riderId, vehicleId, startAddress) {
    const connection = await pool.connect();
    const sql = `INSERT INTO movr.rides
                 (id, city,rider_id,vehicle_id,start_address,start_time)
                 VALUES(gen_random_uuid(), $1,$2,$3,$4,now())`;
    await connection.query(sql, [city, riderId, vehicleId, startAddress]);
    await connection.release();
}

图 6-1 展示了两种方法在性能上的对比。使用 40 个并发请求时,连接池实现比独立连接的方式快了约 700%。连接池带来的好处会根据每个连接中执行的工作量以及应用程序发出的并发活动量而有所不同。然而,几乎总是建议使用连接池,而不是让所有线程使用一个单一的连接,或为每个线程分配一个独立的临时连接。

连接池中的连接可能会由于集群拓扑变化或网络中断而断开。建议配置"保持连接"设置,以定期检查连接。有关更多详细信息,请参见 CockroachDB 文档。

连接池和阻塞连接

大多数连接池实现会在所有池中连接都被使用时阻塞新的连接请求。因此,配置池中足够数量的连接以满足预期的并发需求是很重要的。CockroachDB 文档建议为整个集群的每个核心配置四个连接。例如,如果你有一个三节点的集群,每个节点有 8 个核心,你可能会配置 3 × 8 × 4 = 96 个连接。然而,这只是一个指导性建议------最优的连接数将很大程度上取决于每个连接的持续时间以及每个连接在应用程序执行非数据库任务时的空闲时间。

请记住,你确定的连接数应在你配置的所有连接池中共享。因此,举个例子,如果你计算出的理想连接数为 96,并且你有 4 个应用服务器,那么每个应用服务器应该配置 24 个连接(96 / 4)。

此外,在不使用连接时释放连接至关重要。例如,在 Node.js 示例中,我们函数末尾的 connection.release() 语句非常重要。

在 Java 中,有多种连接池选项。以下是使用 Hikari 框架的示例:

arduino 复制代码
import com.zaxxer.hikari.*;
import java.sql.*;

public class ConnectionPoolDemo {

  public static void main(String[] args) {
    try {
      Class.forName("org.postgresql.Driver");
      String connectionURL = "jdbc:" + args[0];
      String userName = args[1];
      String passWord = args[2];

      HikariConfig config = new HikariConfig();
      config.setJdbcUrl(connectionURL);
      config.setUsername(userName);
      config.setPassword(passWord);
      config.addDataSourceProperty("ssl", "true");
      config.addDataSourceProperty("sslMode", "require");
      config.addDataSourceProperty("reWriteBatchedInserts", "true");
      config.setAutoCommit(false);
      config.setMaximumPoolSize(40);
      config.setIdleTimeout(3000);

      HikariDataSource hikariPool = new HikariDataSource(config);

这个示例创建了一个包含 40 个连接的连接池,使用命令行传递的参数。一旦池创建完成,就可以通过以下方式从池中获取连接:

ini 复制代码
Connection connection = hikariPool.getConnection();

在 Go 的 pgx 驱动中,我们可以使用 pgxpool 包来创建和使用连接池:

less 复制代码
ctx := context.Background()
config, err := pgxpool.ParseConfig(uri)
config.MaxConns = 40
pool, err := pgxpool.ConnectConfig(ctx, config)
defer pool.Close()

我们可以通过以下方式从池中获取连接:

css 复制代码
connection, err := pool.Acquire(ctx)

Python 驱动 psycopg2 包含一个内置的连接池,我们可以轻松地进行配置,如下所示:

arduino 复制代码
import psycopg2
from psycopg2 import pool

def main():

  if ((len(sys.argv)) != 2):
    sys.exit("Error:No URL provided on command line")

  uri = sys.argv[1]
  pool = psycopg2.pool.ThreadedConnectionPool(10, 40, uri)
  # min connections = 10, max connections = 40

然后,我们可以通过以下方式连接到池:

ini 复制代码
connection = pool.getconn()

预编译和参数化语句

大多数 SQL 操作都是参数化的------相同的语句会多次执行,每次使用不同的输入参数。例如,我们可能有一个查找程序,用于根据指定的行程 ID 检索乘客姓名,如下所示:

vbnet 复制代码
SELECT u.name FROM movr.rides r
  JOIN movr.users u ON (r.rider_id=u.id)
 WHERE r.id='ffc3c373-63ec-43fe-98ff-311f29424d8b'

当然,我们会多次执行这个 SQL,每次指定不同的行程 ID。

在编写通用的查找函数时,使用字符串连接将参数附加到 SQL 语句中似乎是自然而然的做法。例如,在 Java 中,我们可能会尝试这样做:

java 复制代码
private static String getRiderName(String riderId) throws SQLException {
    Statement stmt = connection.createStatement();
    String sql = " SELECT u.name FROM movr.rides r "
                + "  JOIN movr.users u ON (r.rider_id=u.id) "
                + " WHERE r.id='"
                + riderId + "'";
    ResultSet rs = stmt.executeQuery(sql);
    rs.next();
    return (rs.getString("name"));
}

尽管这样做看起来很自然,但它实际上是一种极其不好的做法,具有性能和安全性上的缺点。

最显著的问题是,这段代码容易受到 SQL 注入的攻击。例如,假设应用程序可以被操控,将以下字符串传递给该函数:

ini 复制代码
riderName = getRiderName(
    "ffc3c373-63ec-43fe-98ff-311f29424d8b' UNION
    select credit_card from movr.users order by 1,name 'n");

生成的 SQL 语句将变成:

vbnet 复制代码
SELECT u.name FROM movr.rides r
  JOIN movr.users u ON (r.rider_id=u.id)
 WHERE r.id='ffc3c373-63ec-43fe-98ff-311f29424d8b'
 UNION select credit_card from movr.users order by 1,name

这样,函数就会返回信用卡号码以及乘客姓名。

当然,应用程序应该防止用户在 UI 层输入这样的字符串,但在数据库代码中引入这种漏洞是非常不好的做法。解决方案是使用预编译或参数化语句。如同前面 Java 示例中的做法,我们可以通过以下方式声明 prepareStatement

ini 复制代码
getRiderStmt = connection.prepareStatement(
   "SELECT u.name FROM movr.rides r "
 + " JOIN movr.users u ON (r.rider_id=u.id) "
 + " WHERE r.id=?");

"?"表示参数的占位符(有时称为绑定变量)。我们可以通过设置参数并执行语句来调用预编译语句:

ini 复制代码
getRiderStmt.setString(1, riderId);
ResultSet rs = getRiderStmt.executeQuery();
rs.next();
return (rs.getString("name"));

除了避免 SQL 注入,prepareStatements 通常执行得更快,因为 CockroachDB 更容易识别已经解析过的 SQL,从而避免了一些与检查本应是全新语句的开销。

正式地"预编译"语句是 Java 的做法。在其他语言中,仅仅使用占位符的 SQL 语句并在调用中提供值就足够了。例如,在 JavaScript 中:

ini 复制代码
const sql = `SELECT u.name FROM movr.rides r
                JOIN movr.users u ON (r.rider_id=u.id)
                WHERE r.id=$1`;
const results = await connection.query(sql, ['ffc3c373-63ec-43fe-98ff...']);
console.log(results.rows[0].name);

在 Python 中:

ini 复制代码
sql = """SELECT u.name FROM movr.rides r
            JOIN movr.users u ON (r.rider_id=u.id)
            WHERE r.id=%s"""
cursor.execute(sql,('ffc3c373-63ec-43fe-98ff-311f29424d8b',))
row=cursor.fetchone()
print(row[0])

在 Go 中:

sql 复制代码
sql := `SELECT u.name FROM movr.rides r
            JOIN movr.users u ON (r.rider_id=u.id)
            WHERE r.id=$1`
rows, err := conn.Query(ctx, sql, "ffc3c373-63ec-43fe-98ff-311f29424d8b")
rows.Next()
var name string
err = rows.Scan(&name)
fmt.Println(name)

批量插入

应用程序常常会在单个逻辑操作中插入多行数据。

当你有一个要插入的值数组时,直接通过循环插入这些值看起来是自然的做法,如这个 Python 示例所示:

bash 复制代码
for value in arrayValues:
    cursor.execute("INSERT INTO insertTestP1(id,x,y) VALUES ($1,$2,$3)",value)

一次插入大量数据每次插入一行是低效的------每个插入都需要一次网络往返,如果我们希望所有行在单个事务中提交,可能还会有事务性影响(因为单行插入会花费更长时间,事务冲突的机会和随后的重试会被放大)。

SQL 允许在单个操作中包含多个 VALUES,例如:

sql 复制代码
INSERT INTO insertTest(id,x,y)
VALUES (3,'x',1) ,
       (4,'y',2) ,
       (5,'x',5)

因此,如果需要,我们可以动态构造一个 INSERT 语句,将数据数组一次性插入。例如,在 Python 中,以下代码将生成并执行一个 INSERT 语句来插入一个任意长度的数组:

ini 复制代码
sql="INSERT INTO insertTestP(id,x,y) VALUES"
valueCount=0
for value in arrayValues:
    if valueCount>0:
        sql=sql+","
    sql=sql+"(%d,'%s',%d)" % value
    valueCount+=1
cursor.execute(sql)

请注意,这种写法容易受到 SQL 注入攻击。在 psycopg2extras 包中,有一个 execute_values 辅助函数,可以简化代码并减少 SQL 注入的风险:

arduino 复制代码
from psycopg2 import extras

<snip>

extras.execute_values(cursor,
 "INSERT INTO insertTestP1(id,x,y) VALUES %s",
 arrayValues)

使用批量插入的性能提升是显著的。图 6-2 展示了这种提升。

JDBC 包含 addBatchexecuteBatch 方法,这些方法允许你一次准备一个插入操作,然后在单个操作中提交所有插入的值。这样避免了需要连接一个庞大的 VALUES 列表,并且可以使用正式的参数。以下是 addBatchexecuteBatch 方法的示例:

ini 复制代码
String sql="INSERT INTO insertTest(id,x,y) VALUES (?,?,?)";
PreparedStatement InsertStmt = connection.prepareStatement(sql);

for (int arrayIdx = 1; arrayIdx < arrayCount; arrayIdx++) {
    InsertStmt.setInt(1, idArray.get(arrayIdx));
    InsertStmt.setString(2, xArray.get(arrayIdx));
    InsertStmt.setInt(3, yArray.get(arrayIdx));
    InsertStmt.addBatch();
}

InsertStmt.executeBatch();

我们像往常一样使用 setIntsetString 方法为预编译语句提供值,但不是直接执行,而是使用 addBatch 将这些插入操作添加到批量操作队列中。当准备好后,调用 executeBatch 一次性执行所有的插入操作。

除非 reWriteBatchedInserts 属性设置为 true,否则 JDBC 的 addBatch 方法对性能的提升非常有限。你可以在建立连接时设置 reWriteBatchedInserts

ini 复制代码
Class.forName("org.postgresql.Driver");
String connectionURL = "jdbc:" + args[0];
String userName = args[1];
String passWord = args[2];
Properties props = new Properties();
props.setProperty("user", userName);
props.setProperty("password", passWord);
props.setProperty("reWriteBatchedInserts", "true");

Connection connection = DriverManager.getConnection(connectionURL, props);

如果你是 Spring 开发者,可以考虑使用 JdbcTemplate。它包装了 JDBC,使你的代码更容易阅读和维护。

Node.js 库并不直接支持批量插入。然而,我们可以使用 pg-format 包来创建包含多个 VALUES 的 SQL 语句:

ini 复制代码
const pg = require('pg');
const format = require('pg-format');

async function main() {
    const connection = new pg.Client(connectionString);

    const sql = format('INSERT INTO insertTestP2(id,x,y) VALUES  %L', arrayData);

    await connection.query(sql);
}

Go 的 pgx 库提供了一个 Batch 类型和 SendBatch 方法,用于执行批量操作:

go 复制代码
batch := &pgx.Batch{}

for _, v := range arrayValues {
  batch.Queue(
    "INSERT INTO insertTest(id, x, y) VALUES ($1, $2, $3)",
    v.id, v.x, v.y,
  )
}

results := db.SendBatch(context.Background(), batch)
defer results.Close()

结果的分页

一些应用程序需要以批次返回数据。例如,一个在线应用程序可能希望分页返回信息------类似于你在 Google 搜索中看到的结果页面。

从语法角度来看,大多数驱动程序允许你一次滚动一行。但是,在许多情况下,你仍然需要在检索第一行之前将整个结果集加载到程序内存中。例如,Python 的 psycopg2 驱动提供了访问整个结果集(fetchall())、部分行(fetchmany())或单行(fetchone())的方法。然而,无论调用哪个方法,整个结果集始终都会从数据库传输到应用程序。

JDBC 驱动支持客户端游标,允许高效地从数据库批量拉取数据。批次的大小由 Statement 对象的 setFetchSize() 方法控制。默认情况下,fetchSize 设置为 0,这意味着在处理第一行之前,所有行都会被拉入应用程序。

我们可以调整 fetchSize,如果希望每个批次仅拉取少量行,如下所示:

ini 复制代码
Statement stmt = connection.createStatement();
stmt.setFetchSize(100);
results = stmt.executeQuery(
    "SELECT post_timestamp, summary "
  + "  FROM blog_posts "
  + "  ORDER BY post_timestamp DESC ");
for (int ri = 0; ri < 10; ri++) {
    if (results.next())
        System.out.println(results.getString("SUMMARY"));
}

当你更改 setFetchSize() 时,不需要修改循环逻辑,但在后台,PostgreSQL 驱动将以 setFetchSize() 设置的批次大小拉取行。图 6-3 显示了如果我们希望优化拉取前几行,这种方法是非常有效的。

在 Java 中,fetchSize 参数为分页提供了一个适当的解决方案。然而,其他驱动程序要么不支持有效的客户端游标,要么以某种方式实现,导致不自然的编码风格。

推荐的语言独立分页模式称为"KeySet 分页"。但是,在考虑这种技术之前,让我们先看一个"自然"的解决方案,它的性能特点非常差。

假设我们正在返回以下查询的博客文章分页:

sql 复制代码
SELECT post_timestamp, summary
  FROM blog_posts ORDER BY post_timestamp DESC

我们在 POST_TIMESTAMP 上有一个覆盖索引,该索引存储了 summary 列,因此我们可以按顺序高效地检索行。我们想要创建一些代码,以按发布顺序显示博客文章,并且每"页"显示一定数量的文章。

SQL 支持 OFFSETLIMIT 函数,允许我们跳过一定数量的行并限制返回的行数。这看起来是分页的理想解决方案;我们可以使用 OFFSET 跳到我们想要的页面,并使用 LIMIT 来限制结果的数量,只返回这一页的内容。在 Python 中,我们可能会这样编写代码:

ini 复制代码
def getPage(startIndex, nEntries):
  # 不要这样做!
  cursor = connection.cursor()
  sql = """SELECT post_timestamp, summary
            FROM blog_posts ORDER BY post_timestamp DESC
          OFFSET %s LIMIT %s"""
  cursor.execute(sql, (startIndex, nEntries))
  return cursor.fetchall()

这种方法的问题在于,OFFSET 要求我们处理所有数据,直到并包括感兴趣的第一行。因此,例如,如果我们指定一个一百万的偏移量,我们就必须检索并丢弃一百万行之前的所有数据。每一页的检索时间将比上一页更长。

正确的方法是按照关键顺序遍历结果集,这样我们就可以利用索引范围高效地检索行。当然,这种方法绝对需要我们在 WHERE 子句条件上有一个索引,理想情况下,这个索引应该是一个覆盖索引,包含 SELECT 列表中的列。因此,我们的 Python 方法应该如下所示:

ini 复制代码
def getPageKeySet(startTimeStamp, nEntries):
  cursor = connection.cursor()
  sql = """SELECT post_timestamp, summary
            FROM blog_posts
           WHERE post_timestamp < %s
           ORDER BY post_timestamp DESC
           LIMIT %s"""
  cursor.execute(sql, (startTimeStamp, nEntries))
  return cursor.fetchall()

我们需要跟踪从每页检索到的最旧博客时间戳,并将其传递到下次调用该方法时使用。

在某些情况下,确保数据页的一致性可能非常重要。由于每次方法调用发生在不同的时间,因此每个"页面"数据将反映数据库在不同的时间点的状态。如果这是一个问题,那么可以使用 AS OF SYSTEM TIME 来确保每个数据页面反映的是特定时间点的数据库状态:

ini 复制代码
def getPageKeySetST(startTimeStamp, nEntries, systemTime):
  cursor = connection.cursor()
  sql = """SELECT post_timestamp, summary
            FROM blog_posts
           AS OF SYSTEM TIME %s
           WHERE post_timestamp < %s
           ORDER BY post_timestamp DESC
           LIMIT %s
           """
  cursor.execute(sql, (systemTime, startTimeStamp, nEntries))
  return cursor.fetchall()

图 6-4 展示了使用 OFFSETLIMIT 会导致随着每次检索页面信息,开销不断增加。相比之下,KeySet 分页模式返回每一页的时间是相同的。

投影

在关系型数据库术语中,"投影"指的是从表中选择一部分列(或从实体中选择一些属性)。在实践中,投影由 SELECT 子句中的列列表表示。

虽然 SELECT 支持通配符投影(*),但在生产代码中几乎不应该使用它,因为它会导致不必要的列从数据库传输到应用程序。使用 * 可能看起来是一个方便的编程捷径,但在处理大结果集时,它会带来严重的性能惩罚。此外,如果表的结构发生变化,它还可能导致错误。

例如,假设我们正在检索一份用户 ID 和博客文章日期的列表,以便填充仪表板或进行其他实时诊断。以下代码看起来似乎是可以接受的:

ini 复制代码
ResultSet results = stmt.executeQuery(
    "SELECT * FROM blog_posts");
while (results.next()) {
    java.sql.Timestamp postTimestamp =
            results.getTimestamp("POST_TIMESTAMP");
    Integer userid = results.getInt("USERID");
    plotPost(userid, postTimestamp);
}

然而,在省略列名的几秒钟编程时间会使应用程序付出惨重代价。每次执行这段代码时,它不仅检索用户 ID 和时间戳,还会检索可能非常大的博客文章文本。因此,每个网络数据包可以承载的数据更少,网络往返次数增加。如果我们添加投影:

ini 复制代码
results = stmt.executeQuery("""
    SELECT userid, post_timestamp
    FROM blog_posts
    """
);

那么消耗的时间会大幅减少。图 6-5 展示了从远程集群检索 1000 万行结果集时节省的时间。

当然,节省的绝对时间将取决于总行大小与投影大小的比例,以及应用程序和服务器之间的网络延迟。此外,这种性能下降只会在从数据库中拉取的行数超过单个网络数据包可以容纳的数量时才会发生。对于单行检索,开销可以忽略不计。

客户端缓存

优化数据库请求的最佳方法是根本不发送请求。不管我们如何优化数据库------添加索引、增加内存、使用快速磁盘等------数据库请求都是阻塞操作,永远无法比本地计算更快。对于大多数应用程序,数据库访问是最慢的操作,也是应用程序响应时间中最关键的部分。

避免不必要的数据库调用的最有效方法之一是将频繁访问的静态数据缓存到应用程序代码中。除非数据有可能发生变化,否则避免重复地请求数据库中的相同数据。

例如,假设我们有一个函数,根据 userId 返回用户的姓名:

go 复制代码
func getUserName(userId string) string {
    conn, err := pool.Acquire(context.Background())
    defer conn.Release()
    if err != nil {
        fmt.Fprintf(os.Stderr, "CockroachDB error: %v\n", err)
    }
    sql := `SELECT name FROM movr.users WHERE id=$1`
    rows, err := conn.Query(context.Background(), sql, userId)
    defer rows.Close()
    if err != nil {
        fmt.Fprintf(os.Stderr, "CockroachDB error: %v\n", err)
    }
    if !rows.Next() {
        return "Invalid userId"
    } else {
        var name string
        rows.Scan(&name)
        return (name)
    }
}

将此函数扩展为带有客户端缓存的版本相对简单。我们只需声明并初始化一个 map 结构:

go 复制代码
var userCache map[string]string

userCache = make(map[string]string)

现在,在我们的函数中,我们首先检查这个 map 看是否能找到用户的姓名。只有当缓存中没有该姓名时,我们才会去数据库查询:

go 复制代码
func getCachedUserName(userId string) string {

    name, nameFound := userCache[userId]
    if !nameFound {
        conn, err := pool.Acquire(context.Background())
        defer conn.Release()
        if err != nil {
            fmt.Fprintf(os.Stderr, "CockroachDB error: %v\n", err)
        }
        fmt.Println("cache miss")
        sql := `SELECT name FROM movr.users WHERE id=$1`
        rows, err := conn.Query(context.Background(), sql, userId)
        defer rows.Close()
        if err != nil {
            fmt.Fprintf(os.Stderr, "CockroachDB error: %v\n", err)
        }
        if !rows.Next() {
            return "Invalid userId"
        } else {
            rows.Scan(&name)
            userCache[userId] = name
        }
    }
    return (name)
}

通过避免访问数据库所带来的性能提升大于任何数据库访问本身的调优,因为我们永远无法让数据库访问变成零成本操作。然而,请记住以下几点:

  • 缓存会占用客户端程序的内存。在许多环境中,内存是充足的,而考虑缓存的表相对较小。但是,对于大型表和内存受限的环境,实现缓存策略实际上可能会通过导致应用程序层或客户端的内存短缺而降低性能。
  • 如果被缓存的表在程序执行过程中被更新,那么除非实现某种复杂的同步机制,否则这些更改可能不会反映在缓存中。因此,本地缓存最好应用于静态表。

管理事务

事务提供了一个重要的机制,用于确保相关的修改要么作为一个整体成功,要么作为一个整体失败。我们在第 2 章中讨论了 CockroachDB 事务的内部工作原理。

编程事务的基本概念在各种 SQL 数据库中是通用的,甚至在一些非 SQL 系统中也是如此。一个事务是通过 BEGIN 语句开始的。多个 SQL 语句在事务范围内执行,然后通过 COMMIT 语句将所有更改持久化。如果在事务期间遇到错误,可以通过 ROLLBACK 语句放弃事务的所有工作。

示例 6-1 显示了一个相对简单的事务------在 Node.js 中实现------它在先检查是否有足够资金后,将资金从一个账户转移到另一个账户。

示例 6-1. 简单事务示例

vbnet 复制代码
try {
    await connection.query('BEGIN TRANSACTION');
    // 检查是否有足够的资金
    const results = await connection.query(
        'SELECT balance FROM accounts WHERE id=$1',
        [fromId]
    );
    const fromBalance = results.rows[0].balance;
    if (fromBalance < transferAmt) {
        throw Error('Insufficient funds');
    }
    // 转账
    await connection.query(`UPDATE accounts SET balance=balance-$1
                                WHERE id=$2`,
        [transferAmt, fromId]);
    await connection.query(`UPDATE accounts SET balance=balance+$1
                                WHERE id=$2`,
        [transferAmt, toId]);
    await connection.query('COMMIT');
    success = true;
} catch (error) {
    console.error(error.message);
    connection.query('ROLLBACK');
    success = false;
}

如果你并行运行这段代码,你会发现某些事务会以类似以下的错误失败:

yaml 复制代码
restart transaction: TransactionRetryWithProtoRefreshError:
 WriteTooOldError: write at timestamp 16412

事务重试错误

在默认使用较低事务隔离级别的数据库(例如 PostgreSQL)中,这个事务几乎总是会成功,可能仅在数据库宕机时才会失败。然而,在使用 SERIALIZABLE 事务隔离级别时(这是 CockroachDB 的默认设置,其他数据库也提供此选项),事务失败的可能性较大。如果一个并发事务在我们事务开始和尝试修改该行之间修改了同一表行,那么我们会遇到 TransactionRetryWithProtoRefreshError: WriteTooOldError(为了简便起见,我们称之为事务重试错误)。

事务重试错误类型

WriteTooOldError 类型的事务重试错误是一个错误系列的成员------包括 RETRY_SERIALIZABLE 等,这些错误表明可以并且可能应该进行重试。尽管这些错误有不同的(有时是相当复杂的)底层原因,它们都发出了相同的 40001 错误代码。

图 6-6 展示了两个并发事务可能导致事务重试错误的事件序列。

接收到事务重试错误的可能性取决于两个事务在同一行上的碰撞概率。在一次测试中,重试的百分比从涉及 10,000 个不同账户时不到 1% 到只有 10 个账户时超过 75% 不等。

然而,无论遇到事务重试错误的概率是多少,这种可能性是存在的,您的应用程序代码应能够应对这些预期的错误场景。

在使用 READ COMMITTED 隔离级别运行 CockroachDB 时,由于序列化错误导致的事务重试不需要由应用程序代码来处理。锁竞争仍然会影响争抢相同数据的事务,但与之前立即收到重试错误不同,事务将会等待。这种行为可以在图 6-7 中看到,其中两个事务试图更新相同的行。与之前遇到事务重试错误不同,Session 2 会在 Session 1 提交后解除阻塞。

实现事务重试

处理重试错误的相对明显的方法是按照错误代码的建议进行操作------重试事务。当遇到事务重试错误时,执行 ROLLBACK 命令以丢弃事务中已完成的工作,并重新尝试该事务。在以下示例中,我们向示例 6-1 中的 Node.js 方法添加了一些逻辑,以便在必要时重试事务:

javascript 复制代码
let retryCount = 0;
let transactionEnd = false;
while (!transactionEnd) {
    if (retryCount++ >= maxRetries) {
        throw Error('Maximum retry count exceeded');
    }
    try {
        await connection.query('BEGIN TRANSACTION');
        // 检查是否有足够的资金
        const results = await connection.query(
            'SELECT balance FROM accounts where id=$1',
            [fromId]
        );
        const fromBalance = results.rows[0].balance;
        if (fromBalance < transferAmt) {
            throw Error('Insufficient funds');
        }
        // 转账
        await connection.query(
            `UPDATE accounts SET balance=balance-$1
                    WHERE id=$2`,
            [transferAmt, fromId]
        );
        await connection.query(
            `UPDATE accounts SET balance=balance+$1
                    WHERE id=$2`,
            [transferAmt, toId]
        );
        await connection.query('COMMIT');
        success = true;
        console.log('success');
    } catch (error) {
        if (error.code == '40001') { // 事务重试错误
            console.log(error.code, retryCount);
            connection.query('ROLLBACK');
            // 指数退避
            const sleepTime = (2 ** retryCount) * 100
                + Math.ceil(Math.random() * 100);
            await sleep(sleepTime);
        } else {
            console.log('aborted ', error.message);
            transactionEnd = true;
        }
    }
}

如果这个方法遇到错误代码 40001(事务重试错误),它会执行 ROLLBACK,稍等一会儿,然后重试事务。

在这个实现中,睡眠时间会随着重试次数的增加而呈指数增长。这是为了避免事务在某个资源上"翻滚"过多。指数退避策略倾向于减少繁忙系统的负载,但它可能会导致一些"倒霉"的事务等待时间较长。此外,当我们重试事务时,不能保证更新会按原始提交的顺序成功。最先提交的事务可能会在稍后提交的事务提交后才会成功。

自动事务重试

前一节中展示的逻辑可以用任何语言实现。然而,一些驱动程序会透明地为您实现此逻辑:

  • 如果一个返回少于 16 KB 输出的单个语句(例如一个不包含 RETURNING 子句的单独 UPDATE)遇到 40001 错误,则 CockroachDB 会自动重试该语句,无需您进行干预。这个逻辑适用于隐式事务(没有 BEGIN 语句)和只有一个语句的显式事务。除非指定会话变量 statement_timeout,否则重试将会无限期地继续。
  • Go 的 DBTools 库为 Go 事务提供了一个事务重试处理程序。您将一组操作传递给事务处理程序,它会自动重试事务,且可以配置重试限制和延迟。cockroach-go 项目包含类似的帮助函数,由 CockroachDB 团队维护。
  • 许多对象关系映射(ORM)框架------例如 Python 的 SQLAlchemy------会自动为您透明地重试事务。有关更多细节,请参见 CockroachDB 文档。

为什么 CockroachDB 不能处理所有的事务重试?

编写事务重试代码可能显得很繁琐。既然 CockroachDB 在某些情况下会自动重试事务,为什么它不能自动处理所有的重试呢?

简短的回答是,在许多情况下,CockroachDB 无法确定事务中不同语句之间的逻辑关联。例如,在示例 6-1 中,CockroachDB 无法知道在 UPDATE 之前的 SELECT 语句如何影响 UPDATE 逻辑。只有当事务完全不含歧义(这通常只发生在事务中只有一个语句时),CockroachDB 才能安全地执行重试。

使用 FOR UPDATE 避免事务重试错误

执行事务重试有一些显著的缺点。首先,它们是浪费性的,因为在重试之前,事务中已完成的工作会被丢弃。其次,它们引入了不可预测甚至不必要的事务处理延迟。很难知道在事务重试之间应该睡眠多久,指数退避可能导致极端的等待时间。最后,事务重试会导致非确定性行为。事务不会必然按照应用程序提交的顺序应用到数据库;即使在相同的工作负载下,也会观察到不同的结果。

事务重试方法的替代方案是通过在事务开始时使用 FOR UPDATE 语句"锁定"所需的行。FOR UPDATE 是一个阻塞语句,一旦返回,您的事务便具有了对相关行的更新权限。

以下是我们使用 FOR UPDATE 逻辑的示例代码:

ini 复制代码
try {
    await connection.query('BEGIN TRANSACTION');
    // 检查是否有足够的资金(并锁定行)
    const results = await connection.query(
        `SELECT balance FROM accounts where id=$1
            FOR UPDATE`,
        [fromId]
    );
    const fromBalance = results.rows[0].balance;
    if (fromBalance < transferAmt) {
        throw Error('Insufficient funds');
    }
    // 锁定第二行
    await connection.query(
        `SELECT balance FROM accounts where id=$1
            FOR UPDATE`,
        [toId]
    );
    // 转账
    await connection.query(
        `UPDATE accounts SET balance=balance-$1
                WHERE id=$2`,
        [transferAmt, fromId]
    );
    await connection.query(
        `UPDATE accounts SET balance=balance+$1
                WHERE id=$2`,
        [transferAmt, toId]
    );
    await connection.query('COMMIT');
    success = true;
    console.log('success');
} catch (error) {
    console.error(error.message);
    connection.query('ROLLBACK');
    success = false;
}

通过在实际执行 UPDATE 语句之前使用 FOR UPDATE 锁定 ACCOUNTS 行,我们避免了任何事务重试的可能性。然而,在生产实现中,可能仍然建议在任何事务中(即使尝试通过 FOR UPDATE 避免重试时)包含一个事务重试错误处理程序,因为由于时钟同步或其他问题,重试错误仍然可能发生。例如,如果两个账户之间的相反方向的同时转账发生冲突,前述代码就会遇到死锁条件------我们将在接下来的几页中更详细地讨论死锁。

乐观与悲观事务设计

我们在这里看过的两种事务模式------重试处理与 FOR UPDATE 锁定------历史上被称为乐观和悲观事务模型。

在乐观事务模型中,我们认为发生冲突更新并导致事务中止的可能性较小。因此,我们不"预先锁定"数据,而是依赖事务重试来处理可能发生的冲突。

在悲观模型中,我们非常担心事务冲突,因此我们预先锁定可能发生冲突的行。

这两种模型没有优劣之分------它实际上取决于行级事务冲突的可能性。不要根据个人情绪选择其中之一。仔细考虑冲突的可能性------必要时进行基准测试------并相应地采取行动。

通过消除热点行减少竞争

事务重试的最重要原因是对少数"热点"行的竞争。热点行是那些被多个数据库会话频繁更改的行。热点行通常表示数据模型中的设计缺陷。例如,如果我们决定维护每日报告的账户转账总数,我们可能会在每次交易后更新单一行。

使用嵌套数组或 JSON 数据类型也可能引发这些问题。例如,保持一个 JSON 文档中的测量数组看起来可能很方便:

sql 复制代码
SELECT * FROM latest_measurements;
{
    "measurements": [{
        "locationid": "8a90ec6e-370a-4f90-bdc7-2f4bcdd381c2",
        "measurement": "32.6933968058154"
        }, {
        "locationid": "ccc240a0-3322-4a02-9538-23e0d98a39e5",
        "measurement": "1.1379426982748297"
    }, {
        "locationid": "15f41b26-f1a7-4d35-a88b-9f6bce022c7b",
        "measurement": "39.21261847039683"
    }, <snip>
    {
        "locationid": "f9b422d5-e9fd-44e7-8db3-35a243e45a95",
        "measurement": "25.66958037632363"
    }, {
        "locationid": "abdd31e7-b553-4798-896d-be492b11dbf1",
        "measurement": "41.09557231178944"
    }]
}

这种设计可能会导致快速的检索时间,但它现在创建了一个超级热点。如果将每个位置存储在自己的行中会更优。记住------反范式化通常应服务于提高性能的目标;要小心那些实际上降低吞吐量的反范式化。

减少事务耗时

事务运行时间越长,与另一个事务竞争的机会就越大。因此,您应该始终将任何耗时的应用逻辑------当然还有任何人工干预------移出事务之外。在 BEGINCOMMIT(或 ROLLBACK)语句之间的代码应仅包括事务本身至关重要的代码。例如,以下是对最初在示例 6-1 中介绍的事务的一种变体:

ini 复制代码
await connection.query('BEGIN TRANSACTION');
// 检查是否有足够的资金(并锁定行)
const results = await connection.query(
    `SELECT balance FROM accounts where id=$1
        FOR UPDATE`,
    [fromId]
);
const fromBalance = results.rows[0].balance;
if (fromBalance < transferAmt) {
    throw Error('Insufficient funds');
}
// 锁定第二行
await connection.query(
    `SELECT balance FROM accounts where id=$1
        FOR UPDATE`,
    [toId]
);

// 执行反洗钱检查
await performAMLCheckViaRESTCall(txnDetails);

// 转账
await connection.query(
    `UPDATE accounts SET balance=balance-$1
            WHERE id=$2`,
    [transferAmt, fromId]
);
await connection.query(
    `UPDATE accounts SET balance=balance+$1
            WHERE id=$2`,
    [transferAmt, toId]
);
await connection.query('COMMIT');

performAMLCheckViaRESTCall() 通过一个 REST 调用执行反洗钱(AML)检查------在最坏的情况下可能需要几秒钟。我们在执行 FOR UPDATE 语句后发出此调用。尽管从逻辑角度来看(在确保事务能够完成之前不检查 AML 当局)这可能是合理的,但 FOR UPDATE 锁的额外持续时间将显著降低吞吐量。从性能角度来看,最好是在开始事务之前执行 AML 检查。

在重试逻辑中也可能发生类似的效果。如果事务中存在不必要的耗时语句,那么重试的机会会增加,随之而来的是吞吐量的降低。

重新排序语句

事务中的 DML 语句的顺序可能对竞争产生很大影响。通常,最可能涉及竞争的语句应首先放在事务序列中。将竞争语句放在前面有几个好的影响:

  • CockroachDB 可以自动透明地重试事务中的第一个语句,而不需要显式处理。
  • 如果事务失败,它将在执行其他语句之前失败。执行和回滚这些其他语句会在服务器上产生额外开销。

虽然重新排序语句的影响因情况而异,但通常,将最具竞争性的语句提到事务序列的前面是值得的。

时间旅行查询

如果事务尝试读取具有比事务开始时间更高时间戳的数据,则会发生重试错误。对于读取操作,我们可以通过使用 AS OF SYSTEM TIME 来避免这些错误。

这在只读事务中特别重要,在这种事务中,您希望多个 SELECT 语句的结果是一致的,但不需要这些结果完全与系统时间保持同步。您可以在 SELECT 语句中或 BEGIN 语句中包括 AS OF SYSTEM TIME。如果在 BEGIN 语句中包括,则该事务是一个只读事务,并且根据提供的时间戳具有读取一致性。

例如,在这里我们一致地从 ridesuser_ride_counts 表中读取。如果没有 AS OF SYSTEM TIME 子句,写入 ridesuser_ride_counts 的并发操作可能会导致事务失败:

ini 复制代码
cursor.execute("BEGIN AS OF SYSTEM TIME '-10s'")
top10cities = cursor.execute('''SELECT city, count(*)
                                FROM movr.rides GROUP BY city
                                ORDER BY 2 DESC LIMIT 10''')
top10users = cursor.execute('''SELECT name, rides
                                FROM movr.user_ride_counts
                                ORDER BY rides DESC LIMIT 10''')
cursor.execute('COMMIT')

模糊事务错误

在分布式系统中,一些错误可能会导致模糊的结果。例如,如果在处理 COMMIT 语句时收到连接关闭错误,您无法判断事务是否已成功提交。这些错误在任何数据库中都有可能发生,但 CockroachDB 更可能产生这些错误,因为模糊的结果可能由集群节点之间的故障引起。这些错误会以 PostgreSQL 错误代码 40003(statement_completion_unknown)报告。

模糊错误可能由节点崩溃、网络故障或超时引起。请注意,模糊情况只可能发生在事务的最后一个语句(COMMITRELEASE SAVEPOINT)或事务外的语句中。如果在事务尚未尝试提交时连接断开,事务将一定会被中止。

通常,您应像处理连接关闭错误一样处理模糊错误。如果您的事务是幂等的------即能够多次执行并得到相同的结果------那么在遇到模糊错误时重试是安全的。UPSERT 操作通常是幂等的(前提是没有动态分配的列值),其他事务也可以通过在执行任何写入操作之前验证预期的状态来编写为幂等的。像 UPDATE my_table SET x=x+1 WHERE id=$1 这样的增量操作通常是不能轻易做到幂等的。有关幂等性键的详细讨论,请参见 @brandur 的博客文章。

如果您的事务不是幂等的,那么您应该根据是否对应用程序更有利决定是否重试该事务:是应用事务两次,还是返回错误给用户。

死锁

如果两个会话锁定了对方需要的资源,FOR UPDATE 也可能会导致事务重试错误。例如,在图 6-8 中,我们看到两个会话每个都锁定了对方需要的资源。会话 1 锁定了 id=1,并希望锁定 id=2。会话 2 锁定了 id=2,并希望锁定 id=1。这种情况永远无法解决,因此 CockroachDB 将终止其中一个会话,该会话将不得不重试事务。

如果事务始终按照特定顺序锁定资源,死锁的可能性会较小。然而,我们无法完全确保在复杂应用中永远不会发生死锁。解决方案是确保所有关键事务都具有重试逻辑。

如果死锁场景中的事务具有不同的优先级,CockroachDB 允许优先级更高的事务中止另一个事务,该事务必须重试。如果事务具有相同的优先级,则随机选择一个事务中止。

事务优先级

事务可以与优先级关联,并且在发生冲突时,优先级较高的事务会优先于优先级较低的事务。调整事务优先级可以确保关键事务不会被优先级较低的工作阻塞。然而,这些决策应该非常慎重地做出。在繁忙的系统中,低优先级的事务可能会被无限期推迟,这可能比高优先级工作负载的延迟更加不可取。

事务优先级可以通过 SET TRANSACTION 命令设置。默认情况下,所有事务的优先级为 NORMAL

事务方法总结

我们已经探讨了许多事务执行模式,你可能会觉得编程 CockroachDB 事务的"正确"方法并不明确。说得对------确实有不止一种方式来实现它。然而,以下准则通常适用:

  • 你的关键事务应包括某种形式的重试逻辑。即使你通过我们探讨的所有技术避免了重试错误,仍然有可能由于内部资源竞争而发生重试错误。
  • 事务应尽可能保持简短的范围和持续时间。事务内不需要的任何语句应移出作用域。
  • 最有可能导致冲突的 DML 语句应放在事务的最前面。
  • 如果队列中的顺序很重要,请使用 FOR UPDATE 语句在修改资源之前锁定它们。这种悲观锁定模式并不总是更快,但它通常可以确保事务按接收顺序处理。
  • 对于只读事务,可以考虑使用 AS OF SYSTEM TIME 执行"时间旅行"查询,以避免事务重试。
  • 在可能的情况下,将事务中的所有 SQL 语句批处理为单个请求可以提高性能并简化重试逻辑。在这些批处理程序中,要注意 SQL 注入的可能性。

使用 ORM 框架

对象关系映射(ORM)框架自动将程序对象映射到关系结构,减少或消除了在程序代码中使用 SQL 语言指令的需求。

ORM 之所以受欢迎,是因为它们减少了代码复杂性,减轻了开发人员手动确定面向对象程序工件如何映射到关系表的负担。另一方面,ORM 有时会降低关系数据库提供的灵活性,并可能导致性能不尽如人意。

由于 ORM 层位于 SQL 层之上,并且 CockroachDB 的 SQL 层与 PostgreSQL 兼容,大多数 PostgreSQL ORM 可以在不修改的情况下与 CockroachDB 一起使用。在某些情况下,CockroachDB 团队与 ORM 维护者合作,确保其与 CockroachDB 的兼容性。您可以在 CockroachDB 文档中查看支持的 ORM 列表,并查看安装任何必要 CockroachDB 先决条件的说明。

表 6-1 总结了 CockroachDB 的一些 ORM 框架选项。

表 6-1. CockroachDB 的对象关系映射系统

Go Java Python JavaScript Ruby
GORM Hibernate SQLAlchemy Knex.js ActiveRecord
go-pg jOOQ Django Prisma upper/db
MyBatis peewee Sequelize TypeORM

我们没有空间探讨使用各种 ORM 的所有选项,CockroachDB 文档中有很多示例。然而,让我们回顾一下使用最初的 ORM 之一:Java 的 Hibernate 的基本工作流。

这一节的代码来自 CockroachDB Hibernate 示例,您可以在 GitHub 上查看。

Hibernate 配置存储在一个 XML 文件中,该文件告诉 Hibernate 如何连接到后端数据库以及使用哪种 SQL 方言。以下配置文件告诉 Hibernate 使用 PostgreSQL 驱动程序,并提供连接 URL、用户名和密码:

xml 复制代码
<hibernate-configuration>
    <session-factory>
        <!-- 数据库连接设置 -->
        <property name="hibernate.connection.driver_class">
            org.postgresql.Driver
        </property>
        <property name="hibernate.dialect">
            org.hibernate.dialect.CockroachDB201Dialect
        </property>
        <property name="hibernate.connection.url">
            jdbc:postgresql://localhost:26257/bank?ssl=true&amp;sslmode=require
        </property>
        <property name="hibernate.connection.username">maxroach</property>
        <property name="hibernate.connection.password">password</property>
        <property name="hibernate.hbm2ddl.auto">create-drop</property>
    </session-factory>
</hibernate-configuration>

注意 hibernate.dialect 属性被设置为 org.hibernate.dialect.CockroachDB201Dialect;这应与连接的 CockroachDB 版本相对应。

在用户代码中,我们创建与数据库表映射的类,并在这些类中定义可以对这些表执行的操作方法。例如,在这里我们创建一个 Accounts 类。注解告诉 Hibernate 该类将映射到 accounts 表:

java 复制代码
@Entity
@Table(name = "accounts")
public static class Account {
    @Id
    @Column(name = "id")
    public long id;

    public long getId() {
        return id;
    }

    @Column(name = "balance")
    public BigDecimal balance;

    public BigDecimal getBalance() {
        return balance;
    }

    public void setBalance(BigDecimal newBalance) {
        this.balance = newBalance;
    }

    // 便捷构造函数
    public Account(int id, int balance) {
        this.id = id;
        this.balance = BigDecimal.valueOf(balance);
    }

    // Hibernate 需要一个默认构造函数来创建模型对象
    public Account() {
    }
}

现在,我们可以使用这些 Hibernate 方法来编写操作数据库的函数,而无需编写 SQL 代码:

ini 复制代码
private static Function<Session, BigDecimal> transferFunds
    (long fromId, long toId, BigDecimal amount) throws JDBCException {
    Function<Session, BigDecimal> f = s -> {
        BigDecimal rv = new BigDecimal(0);
        try {
            Account fromAccount = (Account) s.get(Account.class, fromId);
            Account toAccount = (Account) s.get(Account.class, toId);
            if (!(amount.compareTo(fromAccount.getBalance()) > 0)) {
                fromAccount.balance = fromAccount.balance.subtract(amount);
                toAccount.balance = toAccount.balance.add(amount);
                s.save(fromAccount);
                s.save(toAccount);
                rv = amount;
                System.out.printf(
                            "APP: transferFunds(%d, %d, %.2f) --> %.2f\n",
                            fromId, toId, amount, rv);
            }
        } catch (JDBCException e) {
            throw e;
        }
        return rv;
    };
    return f;
}

这段代码是相当标准的 Hibernate 代码,可以在几乎任何 SQL 数据库上工作。然而,正如我们所见,CockroachDB 有一些独特的事务行为,这些行为可能需要在复杂应用程序中进行处理。在 Java Hibernate 示例中,CockroachDB 团队定义了一个 runTransaction 方法。它接受一个包含可能触发重试事务错误的命令的函数作为参数。该方法使用图 6-6 中显示的指数退避策略重试事务:

ini 复制代码
private static BigDecimal runTransaction(
                    Session session,
                    Function<Session, BigDecimal> fn) {
    BigDecimal rv = new BigDecimal(0);
    int attemptCount = 0;
    while (attemptCount < MAX_ATTEMPT_COUNT) {
        attemptCount++;
        if (attemptCount > 1) {
            System.out.printf(
                "APP: Entering retry loop again, iteration %d\n",
                attemptCount);
        }
        Transaction txn = session.beginTransaction();
        System.out.printf("APP: BEGIN;\n");
        if (attemptCount == MAX_ATTEMPT_COUNT) {
            String err = String.format("hit max of %s attempts, aborting",
                MAX_ATTEMPT_COUNT);
            throw new RuntimeException(err);
        }
        try {
            rv = fn.apply(session);
            if (!rv.equals(-1)) {
                txn.commit();
                System.out.printf("APP: COMMIT;\n");
                break;
            }
        } catch (JDBCException e) {
            if (RETRY_SQL_STATE.equals(e.getSQLState())) {
                // 指数退避
                System.out.printf("APP: retryable exception occurred:\n sql
state = [%s]\n    message = [%s]\n    retry counter = %s\n", e.getSQLState(),
e.getMessage(), attemptCount);
                System.out.printf("APP: ROLLBACK;\n");
                txn.rollback();
                int sleepMillis = (int) (Math.pow(2, attemptCount) * 100) +
                    RAND.nextInt(100);
                System.out.printf("APP: Hit 40001 transaction retry error,
sleeping %s milliseconds\n", sleepMillis);
                try {
                    Thread.sleep(sleepMillis);
                } catch (InterruptedException ignored) {
                    // no-op
                }
                rv = BigDecimal.valueOf(-1);
            } else {
                throw e;
            }
        }
    }
    return rv;
}

每个 ORM 框架都有自己配置和编码的方法,但一般做法是类似的。表 6-2 总结了您可以选择的重试事务的 ORM 选项。

表 6-2. ORM 中重试事务的选项

语言/ORM 重试事务过程
SQLAlchemy 使用 sqlalchemy_cockroachdb.run_transaction() 方法
Django 在装饰器函数中定义事务重试循环
GORM 使用 crdbgorm.ExecuteTx() 包装函数调用
pgx 使用 crdbpgx.ExecuteTx() 包装函数调用
Java 使用 runTransaction() 方法

行级 TTL

在许多行业中,公司必须保留数据一段时间才能删除它。除非有充分的理由将每一行数据永久保存,否则在某个时刻,你可能会希望或需要归档或删除旧数据。

CockroachDB 内置的行级 TTL 允许你在满足某些条件时声明性地删除表中的数据。在本节中,我们将利用行级 TTL 来自动过期数据,避免手动删除或编写专门的代码。

首先,我们创建一个集群:

css 复制代码
cockroach demo --insecure --no-example-database

假设我们是一个电商企业,客户在结账前将商品添加到购物篮中。接下来的语句创建一个购物篮表,应用行级 TTL 表达式,根据以下逻辑删除行:

  • 如果待处理的购物篮最后更新时间超过一小时,则删除该购物篮;这样客户在我们清理购物篮之前有机会重新查看他们的订单。
  • 立即删除已购买的购物篮。
sql 复制代码
CREATE TYPE basket_status AS ENUM ('pending', 'purchased');

CREATE TABLE basket (
  "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  "customer_id" UUID NOT NULL,
  "products" STRING[] NOT NULL,
  "status" basket_status NOT NULL DEFAULT 'pending',
  "last_update" TIMESTAMPTZ NOT NULL DEFAULT now()
) WITH (
  ttl_expiration_expression = '
    CASE
      WHEN status = ''purchased'' THEN last_update

      WHEN status = ''pending'' THEN
        ((last_update AT TIME ZONE ''UTC'') + INTERVAL ''1 hour'')
        AT TIME ZONE ''UTC''

      ELSE NULL
    END',
  ttl_job_cron = '* * * * *' -- 每分钟运行一次。
);

接下来,我们插入一些购物篮数据,模拟它们可能处于的不同状态:

sql 复制代码
-- 一个活跃的待处理购物篮。
INSERT INTO basket ("customer_id", "products", "status", "last_update")
VALUES (
  'a7d16771-09d7-460d-8264-cdbd6781b005',
  ARRAY['a', 'b', 'c'],
  'pending',
  now()
);

-- 一个不活跃的待处理购物篮。
INSERT INTO basket ("customer_id", "products", "status", "last_update")
VALUES (
  'bcf370b8-c917-4629-b7cd-614b1cd37380',
  ARRAY['d', 'e', 'f'],
  'pending',
  now() - INTERVAL '1 hour'
);

-- 一个已购买的购物篮
INSERT INTO basket ("customer_id", "products", "status", "last_update")
VALUES (
  'c6638bf0-cea9-4f10-8afa-7c1c0401a2b9',
  ARRAY['g', 'h', 'i'],
  'purchased',
  now()
);

在短暂的时间后(由我们的 ttl_job_cron 参数定义),不活跃的待处理购物篮和已购买的购物篮将被删除,只留下活跃的待处理购物篮(该购物篮将在一小时无活动后被删除):

lua 复制代码
SELECT customer_id, status
FROM basket;
              customer_id              | status
---------------------------------------+----------
  a7d16771-09d7-460d-8264-cdbd6781b005 | pending

总结

在本章中,我们讨论了与 CockroachDB 应用开发相关的话题。

由于 CockroachDB 与 PostgreSQL 高度兼容,并且与许多其他 SQL 数据库也具有一定的兼容性,因此与 CockroachDB 进行软件开发的基本概念并不独特。软件开发的最佳实践包括使用连接池、批量处理、最小化网络流量以及避免不必要的数据库请求。

如果您正在从默认隔离级别为 READ COMMITTED 的数据库迁移到 CockroachDB,您可能会在迁移后的应用程序中遇到重试错误。在短期内,您可以切换到 CockroachDB 的 READ COMMITTED 隔离级别,从而实现更符合您习惯的应用行为。然而,从长期来看,使用可串行化的隔离级别将有助于保证数据的完整性。

CockroachDB 的默认可串行化一致性模型比许多其他 SQL 数据库更严格。再加上分布式数据库中冲突的可能性较高,这确实会导致事务冲突的可能性。有两种主要模式来处理这些冲突------重试事务(乐观事务)或在修改之前使用 FOR UPDATE 锁定数据(悲观事务)。这两种模式都是有效的;无论使用哪种模式,建议都要使用事务重试逻辑。

在下一章中,我们将探索可以用来将数据导入和导出 CockroachDB 的工具和技术。我们还将探讨 CockroachDB 的特性,使其能够与您的更广泛架构无缝集成。

相关推荐
kfepiza26 分钟前
Debian编译安装mysql8.0.41源码包 笔记250401
数据库·笔记·mysql·debian·database
tjfsuxyy28 分钟前
SqlServer整库迁移至Oracle
数据库·oracle·sqlserver
老王笔记1 小时前
MySQL统计信息
数据库·mysql
程序员 小柴1 小时前
RabbitMQ的工作模式
分布式·rabbitmq·ruby
无名之逆1 小时前
[特殊字符] Hyperlane 框架:高性能、灵活、易用的 Rust 微服务解决方案
运维·服务器·开发语言·数据库·后端·微服务·rust
蒋星熠1 小时前
在VMware下Hadoop分布式集群环境的配置--基于Yarn模式的一个Master节点、两个Slaver(Worker)节点的配置
大数据·linux·hadoop·分布式·ubuntu·docker
爱的叹息2 小时前
MongoDB 的详细解析,涵盖其核心概念、架构、功能、操作及应用场景
数据库·mongodb·架构
小样vvv2 小时前
【分布式】微服务系统中基于 Hystrix 的熔断实现方案
分布式·hystrix·微服务
AWS官方合作商2 小时前
实战解析:基于AWS Serverless架构的高并发微服务设计与优化
架构·serverless·aws