面向 Cassandra 用户的 Spanner 简介

本文档比较了 Apache Cassandra 和 Spanner 的概念和做法。本教程假定您熟悉 Cassandra,并希望在将 Spanner 用作数据库的情况下迁移现有应用或设计新应用。

Cassandra 和 Spanner 都是为需要高可伸缩性和低延迟时间的应用而构建的大规模分布式数据库。虽然这两种数据库都可以支持要求苛刻的 NoSQL 工作负载,但 Spanner 提供了用于数据建模、查询和事务操作的高级功能。如需详细了解 Spanner 如何满足 NoSQL 数据库条件,请参阅 适用于非关系型工作负载的 Spanner

从 Cassandra 迁移到 Spanner

如需从 Cassandra 迁移到 Spanner,您可以使用 Cassandra 到 Spanner 代理适配器。借助这款开源工具,您可以将工作负载从 Cassandra 或 DataStax Enterprise (DSE) 迁移到 Spanner,而无需对应用逻辑进行任何更改。

核心概念

本部分比较了 Cassandra 和 Spanner 的关键概念。

术语

Cassandra Spanner
集群 实例

一个 Cassandra 集群相当于一个 Spanner 实例,即一组服务器和存储资源。由于 Spanner 是一项代管式服务,因此您无需配置底层硬件或软件。您只需指定要为实例预留的节点数量,或选择自动扩缩以自动扩缩实例。实例类似于数据库的容器,数据复制拓扑(单区域、双区域或多区域)是在实例级别选择的。
键空间 数据库

一个 Cassandra 键空间相当于一个 Spanner 数据库,它是表和其他架构元素(例如索引和角色)的集合。与键空间不同,您无需配置复制因子。Spanner 会自动将您的数据复制到实例中指定的区域。
表格

在 Cassandra 和 Spanner 中,表是一系列行,由表架构中指定的主键标识。
分区 拆分

Cassandra 和 Spanner 都通过对数据进行分片来扩容。在 Cassandra 中,每个分片称为分区,而在 Spanner 中,每个分片称为分块。Cassandra 使用哈希分区,这意味着每个行都会根据主键的哈希值独立分配给一个存储节点。 Spanner 采用范围分片,这意味着主键空间中相邻的行在存储空间中也是相邻的(分块边界除外)。Spanner 会根据负载和存储空间进行拆分和合并,而对应用而言,这一切都是透明的。这意味着,与 Cassandra 不同,在 Spanner 中,对主键的前缀进行范围扫描是一种高效的操作。
Row

在 Cassandra 和 Spanner 中,行是指由主键唯一标识的列集合。与 Cassandra 一样,Spanner 支持复合主键。与 Cassandra 不同,Spanner 不会区分分区键和排序键,因为数据是按范围分片的。您可以将 Spanner 视为只有排序键,分区由后台管理。


在 Cassandra 和 Spanner 中,列是一组具有相同类型的数据值。表中的每一行对应一个值。 如需详细了解如何将 Cassandra 列类型与 Spanner 进行比较,请参阅数据类型

架构

Cassandra 集群由一组服务器和与这些服务器共存储的存储空间组成。哈希函数会将分区键空间中的行映射到虚拟节点 (vnode)。然后,系统会为每个服务器随机分配一组 vnode 来处理一部分集群键空间。虚拟节点的存储空间会本地挂接到传送节点。客户端驱动程序会直接连接到提供服务的节点,并处理负载均衡和查询路由。

Spanner 实例由复制拓扑中的一组服务器组成。Spanner 会根据 CPU 和磁盘使用情况,将每个表动态分片为行范围。系统会将分片分配给计算节点以进行投放。数据实际存储在 Google 的分布式文件系统 Colossus 上,与计算节点分开。客户端驱动程序会连接到 Spanner 的前端服务器,这些服务器会执行请求路由和负载均衡。如需了解详情,请参阅 Spanner 读写生命周期白皮书。

概括来讲,随着资源添加到底层集群,这两种架构都会扩缩。由于 Spanner 的计算和存储分离,因此可以更快地在计算节点之间重新平衡负载,以响应工作负载变化。与 Cassandra 不同,分片迁移不会涉及数据迁移,因为数据会保留在 Colossus 上。此外,对于希望按分区键对数据进行排序的应用,Spanner 基于范围的分区可能更为自然。基于范围的分区的一个缺点是,如果工作负载写入键空间的一端(例如,按当前时间戳键值的表),则可能会遇到热点问题,而无需考虑额外的架构设计。如需详细了解如何克服热点问题,请参阅架构设计最佳实践

一致性

使用 Cassandra 时,您必须为每项操作指定一致性级别。如果您使用“共识一致性级别”,则大多数副本节点必须响应协调者节点,才能将操作视为成功。如果您使用一致性级别 1,Cassandra 需要单个副本节点响应,才能将操作视为成功。

Spanner 提供强一致性。Spanner API 不会向客户端公开副本。Spanner 的客户端与 Spanner 的交互方式就像与单机数据库交互一样。写入操作始终会先写入大多数副本,然后才向用户确认。任何后续读取都会反映新写入的数据。应用可以选择读取过去某个时间的数据库快照,这可能比强一致性读取更具性能优势。如需详细了解 Spanner 的一致性属性,请参阅事务概览

Spanner 旨在支持大型应用所需的一致性和可用性。Spanner 可大规模提供强一致性和高性能。对于需要时,Spanner 支持快照读取,以放宽新鲜度要求。

数据建模

本部分比较了 Cassandra 和 Spanner 数据模型。

表声明

Cassandra 和 Spanner 的表声明语法非常相似。您可以指定表名称、列名称和类型,以及用于唯一标识行的主键。主要区别在于,Cassandra 采用哈希分区,并区分分区键和排序键,而 Spanner 采用范围分区。可以将 Spanner 视为仅具有排序键,并在后台自动维护分区。与 Cassandra 一样,Spanner 支持复合主键。

单个主键部分

Cassandra 和 Spanner 之间的区别在于类型名称和主键子句的位置。

Cassandra Spanner
CREATE TABLE users (
  user_id    bigint,
  first_name text,
  last_name  text,
  PRIMARY KEY (user_id)
)
    
CREATE TABLE users (
  user_id    int64,
  first_name string(max),
  last_name  string(max),
) PRIMARY KEY (user_id)
    

多个主键部分

对于 Cassandra,第一个主键部分是“分区键”,后续的主键部分是“排序键”。对于 Spanner,没有单独的分区键。数据按整个复合主键进行排序存储。

Cassandra Spanner
CREATE TABLE user_items (
  user_id    bigint,
  item_id    bigint,
  first_name text,
  last_name  text,
  PRIMARY KEY (user_id, item_id)
)
    
CREATE TABLE user_items (
  user_id    int64,
  item_id    int64,
  first_name string(max),
  last_name  string(max),
) PRIMARY KEY (user_id, item_id)
    

复合分区键

对于 Cassandra,分区键可以是复合键。Spanner 中没有单独的分区键。数据按整个复合主键进行排序存储。

Cassandra Spanner
CREATE TABLE user_category_items (
  user_id     bigint,
  category_id bigint,
  item_id     bigint,
  first_name  text,
  last_name   text,
  PRIMARY KEY ((user_id, category_id), item_id)
)
    
CREATE TABLE user_category_items (
  user_id     int64,
  category_id int64,
  item_id     int64,
  first_name  string(max),
  last_name   string(max),
) PRIMARY KEY (user_id, category_id, item_id)
    

数据类型

本部分比较了 Cassandra 和 Spanner 数据类型。如需详细了解 Spanner 类型,请参阅 GoogleSQL 中的数据类型

Cassandra Spanner
数字类型 标准整数:

bigint(64 位有符号整数)
int(32 位有符号整数)
smallint(16 位有符号整数)
tinyint(8 位有符号整数)
int64(64 位有符号整数)

Spanner 支持对有符号整数使用单个 64 位宽的数据类型。
标准浮点:

double(64 位 IEEE-754 浮点)
float(32 位 IEEE-754 浮点)
float64(64 位 IEEE-754 浮点)
float32(32 位 IEEE-754 浮点)
可变精度数字:

varint(可变精度整数)
decimal(可变精度小数)
对于固定精度的十进制数字,请使用 numeric(精度 38 标度 9)。 否则,请将 string 与应用层可变精度整数库结合使用。
字符串类型 text
varchar
string(max)

textvarchar 都存储和验证 UTF-8 字符串。在 Spanner 中,string 列需要指定其长度上限(对存储没有影响;这是为了验证目的)。
blob bytes(max)

如需存储二进制数据,请使用 bytes 数据类型。
日期和时间类型 date date
duration int64

Spanner 不支持专用时长数据类型。使用 int64 存储纳秒时长。
time int64

Spanner 不支持专用的时间数据类型。使用 int64 存储一天内的纳秒偏移。
timestamp timestamp
容器类型 用户定义的类型 jsonproto
list array

使用 array 存储类型化对象的列表。
map jsonproto

Spanner 不支持专用地图类型。使用 jsonproto 列来表示映射。如需了解详情,请参阅将大型映射存储为交错表
set array

Spanner 不支持专用集类型。使用 array 列表示 set,由应用管理集合的唯一性。如需了解详情,请参阅将大型映射存储为交错表,该方法也可用于存储大型集。

基本使用模式

以下代码示例展示了 Go 中 Cassandra 客户端代码与 Spanner 客户端代码之间的区别。如需了解详情,请参阅 Spanner 客户端库

客户端初始化

在 Cassandra 客户端中,您可以创建一个表示底层 Cassandra 集群的集群对象,实例化一个会话对象(用于对集群进行抽象化连接),并对会话发出查询。在 Spanner 中,您可以创建绑定到特定数据库的客户端对象,并针对客户端对象发出数据库请求。

Cassandra 示例

Go

import "github.com/gocql/gocql"

...

cluster := gocql.NewCluster("<address>")
cluster.Keyspace = "<keyspace>"
session, err := cluster.CreateSession()
if err != nil {
  return err
}
defer session.Close()

// session.Query(...)

Spanner 示例

Go

import "cloud.google.com/go/spanner"

...

client, err := spanner.NewClient(ctx,
    fmt.Sprintf("projects/%s/instances/%s/databases/%s", project, instance, database))
defer client.Close()

// client.Apply(...)

读取数据

您可以通过键值对格式的 API查询 API 在 Spanner 中执行读取操作。作为 Cassandra 用户,您可能会发现查询 API 更为熟悉。查询 API 的一个关键区别是,Spanner 需要命名参数(与 Cassandra 中的位置参数 ? 不同)。Spanner 查询中的实参名称必须以 @ 为前缀。

Cassandra 示例

Go

stmt := `SELECT
           user_id, first_name, last_name
         FROM
           users
         WHERE
           user_id = ?`

var (
  userID    int
  firstName string
  lastName  string
)

err := session.Query(stmt, 1).Scan(&userID, &firstName, &lastName)

Spanner 示例

Go

stmt := spanner.Statement{
  SQL: `SELECT
          user_id, first_name, last_name
        FROM
          users
        WHERE
          user_id = @user_id`,
  Params: map[string]any{"user_id": 1},
}

var (
  userID    int64
  firstName string
  lastName  string
)

err := client.Single().Query(ctx, stmt).Do(func(row *spanner.Row) error {
  return row.Columns(&userID, &firstName, &lastName)
})

插入数据

Cassandra INSERT 等同于 Spanner INSERT OR UPDATE。您必须为插入操作指定完整的主键。Spanner 同时支持 DML 和键值对式更改 API。由于延迟时间较短,因此建议将键值对格式的更改 API 用于琐碎写入。Spanner DML API 支持完整的 SQL 接口(包括在 DML 语句中使用表达式),因此提供更多功能。

Cassandra 示例

Go

stmt := `INSERT INTO
           users (user_id, first_name, last_name)
         VALUES
           (?, ?, ?)`
err := session.Query(stmt, 1, "John", "Doe").Exec()

Spanner 示例

Go

_, err := client.Apply(ctx, []*spanner.Mutation{
  spanner.InsertOrUpdateMap(
    "users", map[string]any{
      "user_id":    1,
      "first_name": "John",
      "last_name":  "Doe",
    }
  )})

批量插入数据

在 Cassandra 中,您可以使用批量语句插入多行。在 Spanner 中,提交操作可以包含多个更改。Spanner 会以原子方式将这些更改插入数据库。

Cassandra 示例

Go

stmt := `INSERT INTO
           users (user_id, first_name, last_name)
         VALUES
           (?, ?, ?)`
b := session.NewBatch(gocql.UnloggedBatch)
b.Entries = []gocql.BatchEntry{
  {Stmt: stmt, Args: []any{1, "John", "Doe"}},
  {Stmt: stmt, Args: []any{2, "Mary", "Poppins"}},
}
err = session.ExecuteBatch(b)

Spanner 示例

Go

_, err := client.Apply(ctx, []*spanner.Mutation{
  spanner.InsertOrUpdateMap(
    "users", map[string]any{
       "user_id":    1,
       "first_name": "John",
       "last_name":  "Doe"
    },
  ),
  spanner.InsertOrUpdateMap(
    "users", map[string]any{
       "user_id":    2,
       "first_name": "Mary",
       "last_name":  "Poppins",
    },
  ),
})

删除数据

在 Cassandra 中执行删除操作时,需要指定要删除的行的主键。这类似于 Spanner 中的 DELETE 更新。

Cassandra 示例

Go

stmt := `DELETE FROM
           users
         WHERE
           user_id = ?`
err := session.Query(stmt, 1).Exec()

Spanner 示例

Go

_, err := client.Apply(ctx, []*spanner.Mutation{
  spanner.Delete("users", spanner.Key{1}),
})

高级主题

本部分介绍了如何在 Spanner 中使用更高级的 Cassandra 功能。

写入时间戳

Cassandra 允许变更使用 USING TIMESTAMP 子句明确指定特定单元格的写入时间戳。通常,此功能用于操纵 Cassandra 的“最后写入者胜出”语义。

Spanner 不允许客户端指定每次写入的时间戳。每个单元格在提交单元格值时都会在内部标记为 TrueTime 时间戳。由于 Spanner 提供强一致性且严格可序列化的接口,因此大多数应用都不需要 USING TIMESTAMP 的功能。

如果您依赖 Cassandra 的 USING TIMESTAMP 来实现应用专用逻辑,则可以向 Spanner 架构中添加额外的 TIMESTAMP 列,该列可在应用级别跟踪修改时间。然后,您可以将对某行的更新封装在读写事务中。例如:

Cassandra 示例

Go

stmt := `INSERT INTO
           users (user_id, first_name, last_name)
         VALUES
           (?, ?, ?)
         USING TIMESTAMP
           ?`
err := session.Query(stmt, 1, "John", "Doe", ts).Exec()

Spanner 示例

  1. 创建包含显式更新时间戳列的架构。

    GoogleSQL

    CREATE TABLE users (
      user_id    INT64,
      first_name STRING(MAX),
      last_name  STRING(MAX),
      update_ts  TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
    ) PRIMARY KEY (user_id)
  2. 自定义逻辑以更新行并添加时间戳。

    Go

    func ShouldUpdateRow(ctx context.Context, txn *spanner.ReadWriteTransaction, updateTs time.Time) (bool, error) {
      // Read the existing commit timestamp.
      row, err := txn.ReadRow(ctx, "users", spanner.Key{1}, []string{"update_ts"})
    
      // Treat non-existent row as NULL timestamp - the row should be updated.
      if spanner.ErrCode(err) == codes.NotFound {
        return true, nil
      }
    
      // Propagate unexpected errors.
      if err != nil {
        return false, err
      }
    
      // Check if the committed timestamp is newer than the update timestamp.
      var committedTs *time.Time
      err = row.Columns(&committedTs)
      if err != nil {
        return false, err
      }
      if committedTs != nil && committedTs.Before(updateTs) {
        return false, nil
      }
    
      // Committed timestamp is older than update timestamp - the row should be updated.
      return true, nil
    }
  3. 请先检查自定义条件,然后再更新行。

    Go

    _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
      // Check if the row should be updated.
      ok, err := ShouldUpdateRow(ctx, txn, time.Now())
      if err != nil {
        return err
      }
      if !ok {
        return nil
      }
    
      // Update the row.
      txn.BufferWrite([]*spanner.Mutation{
        spanner.InsertOrUpdateMap("users", map[string]any{
          "user_id":    1,
          "first_name": "John",
          "last_name":  "Doe",
          "update_ts":  spanner.CommitTimestamp,
        })})
    
      return nil
    })

有条件的更改

Cassandra 中的 INSERT ... IF EXISTS 语句等同于 Spanner 中的 INSERT 语句。在这两种情况下,如果该行已存在,则插入操作会失败。

在 Cassandra 中,您还可以创建指定条件的 DML 语句,如果条件的计算结果为 false,则语句会失败。在 Spanner 中,您可以在读写事务中使用条件 UPDATE 更新。例如,如需仅在存在特定条件时更新行,请使用以下代码:

Cassandra 示例

Go

stmt := `UPDATE
           users
         SET
           last_name = ?
         WHERE
           user_id = ?
         IF
           first_name = ?`
err := session.Query(stmt, 1, "Smith", "John").Exec()

Spanner 示例

  1. 自定义逻辑以更新行并添加条件。

    Go

    func ShouldUpdateRow(ctx context.Context, txn *spanner.ReadWriteTransaction) (bool, error) {
      row, err := txn.ReadRow(ctx, "users", spanner.Key{1}, []string{"first_name"})
      if err != nil {
        return false, err
      }
    
      var firstName *string
      err = row.Columns(&firstName)
      if err != nil {
        return false, err
      }
      if firstName != nil && firstName == "John" {
        return false, nil
      }
      return true, nil
    }
  2. 请先检查自定义条件,然后再更新行。

    Go

    _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
      ok, err := ShouldUpdateRow(ctx, txn, time.Now())
      if err != nil {
        return err
      }
      if !ok {
        return nil
      }
    
      txn.BufferWrite([]*spanner.Mutation{
        spanner.InsertOrUpdateMap("users", map[string]any{
          "user_id":    1,
          "last_name":  "Smith",
          "update_ts":  spanner.CommitTimestamp,
        })})
    
      return nil
    })

TTL

Cassandra 支持在行或列级设置存留时间 (TTL) 值。在 Spanner 中,TTL 是在行级配置的,您可以将命名列指定为行的到期时间。如需了解详情,请参阅存留时间 (TTL) 概览

Cassandra 示例

Go

stmt := `INSERT INTO
           users (user_id, first_name, last_name)
         VALUES
           (?, ?, ?)
         USING TTL 86400
           ?`
err := session.Query(stmt, 1, "John", "Doe", ts).Exec()

Spanner 示例

  1. 创建包含显式更新时间戳列的架构

    GoogleSQL

    CREATE TABLE users (
      user_id    INT64,
      first_name STRING(MAX),
      last_name  STRING(MAX),
      update_ts  TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
    ) PRIMARY KEY (user_id),
      ROW DELETION POLICY (OLDER_THAN(update_ts, INTERVAL 1 DAY));
  2. 插入包含提交时间戳的行。

    Go

    _, err := client.Apply(ctx, []*spanner.Mutation{
      spanner.InsertOrUpdateMap("users", map[string]any{
                  "user_id":    1,
                  "first_name": "John",
                  "last_name":  "Doe",
                  "update_ts":  spanner.CommitTimestamp}),
    })

将大型映射存储为交错表。

Cassandra 支持使用 map 类型存储有序键值对。如需在 Spanner 中存储包含少量数据的 map 类型,您可以使用 JSONPROTO 类型,分别用于存储半结构化数据和结构化数据。对此类列进行更新需要重新写入整个列值。如果您的用例中大量数据存储在 Cassandra map 中,并且只需要更新 map 的一小部分,则使用 INTERLEAVED 表可能非常适合。例如,若要将大量键值对数据与特定用户相关联,请执行以下操作:

Cassandra 示例

CREATE TABLE users (
  user_id     bigint,
  attachments map<string, string>,
  PRIMARY KEY (user_id)
)

Spanner 示例

CREATE TABLE users (
  user_id  INT64,
) PRIMARY KEY (user_id);

CREATE TABLE user_attachments (
  user_id        INT64,
  attachment_key STRING(MAX),
  attachment_val STRING(MAX),
) PRIMARY KEY (user_id, attachment_key);

在这种情况下,用户附件行会与相应的用户行存储在一起,并且可以与用户行一起高效检索和更新。您可以使用 Spanner 中的读写 API 与交错表进行交互。如需详细了解交错,请参阅创建父级表和子表

开发者体验

本部分比较了 Spanner 和 Cassandra 开发者工具。

本地开发

您可以在本地运行 Cassandra,以进行开发和单元测试。Spanner 通过 Spanner 模拟器为本地开发提供了类似的环境。模拟器为交互式开发和单元测试提供了高保真度环境。如需了解详情,请参阅在本地模拟 Spanner

命令行

与 Cassandra 的 nodetool 等效的 Spanner 是 Google Cloud CLI。您可以使用 gcloud spanner 执行控制平面和数据平面操作。如需了解详情,请参阅 Google Cloud CLI Spanner 参考指南

如果您需要一个类似于 cqlsh 的 REPL 接口来向 Spanner 发出查询,可以使用 spanner-cli 工具。如需在 Go 中安装并运行 spanner-cli,请执行以下操作:

go install github.com/cloudspannerecosystem/spanner-cli@latest

$(go env GOPATH)/bin/spanner-cli

如需了解详情,请参阅 spanner-cli GitHub 代码库