Aerospike:入门与实战——高级操作

490 阅读28分钟

到目前为止,你已经了解了 Aerospike 的基本操作,主要是读取和写入数据。我们讨论了数据如何存储在 bin 中,并且可以在一次调用中读取或写入多个 bin。接下来,我们将探讨一些超出这些基本操作的内容。

首先,你将了解 operate() 命令的部分强大功能,特别是如何对列表和映射等复杂数据类型中的数据进行添加、追加和更新。接着,你将学习使用表达式创建条件写入和派生读取等操作的基础知识。你还将接触到更高级的批量操作、二级索引、多条件查询以及其他与使用 Aerospike 相关的功能。

operate() 命令

operate() 是单条记录上最强大的命令之一。事实上,所有其他单记录命令都只是包装了 operate() 的语法糖而已。如果你一直在跟随本书中的示例,你实际上已经使用了这个命令,只不过是在 get()put() 方法的封装下。

operate() 允许你在单条记录上执行任意复杂的操作列表,接收包含相应操作的 Operation 类列表。例如,在一次调用中,你可以插入一个 bin,对另一个 bin 的内容加 10,计算第三个 bin 中映射的大小,等等。该命令的基本语法为:

public Record operate(WritePolicy policy, Key key, Operation ... operations);

如果你不熟悉 Java,这里的“...” 是可变参数(varargs)运算符,允许传递任意数量的参数。因此,可以传递一个参数,也可以传递一百个参数,具体取决于你的需求。

举个例子,假设你正在构建一个购物车应用。用户的记录包含购物车中的商品、商品数量和总金额。在这个示例中,假设购物车中的商品仅存储为字符串。

你可以创建如下的记录:

Key key = new Key("test", "cart", 1);
client.put(null, key, 
      new Bin("items", "shoes,"), 
      new Bin("totalItems", 1), 
      new Bin("cost", 59.25));

如前所述,这会在“test”命名空间的“cart”集合(表)中创建引用项目 1 的键。记录将写入该键,其中包含三个具有指定值的 bin。这将生成如下的记录:

aql> select * from test.cart;
+----------+------------+-------+
| items    | totalItems | cost  |
+----------+------------+-------+
| "shoes," | 1          | 59.25 |     
+----------+------------+-------+
1 row in set (0.279 secs)

如何更新此记录呢?假设你想添加另一件商品,比如牛仔裤。你可以将其编码为类似以下的方法:

public static void addItem(Key key, String itemDescr, double cost) { 
    client.operate(null, key, 
         Operation.append(new Bin("items", itemDescr + ",")),
         Operation.add(new Bin("totalItems", 1)), 
         Operation.add(new Bin("cost", cost)) 
    );
}

此方法对 Aerospike 客户端执行一次调用,对数据库进行一次事务操作。此事务包含三个独立的操作:

  1. 将字符串附加到现有字符串上,此处为商品描述。如果 items bin 不存在,Aerospike 会自动创建该 bin。
  2. totalItems bin 增加 1。如果 bin 不存在,它将以默认值零创建,然后再加上传递值 1。
  3. 将商品的成本添加到累计金额中。

可以调用此方法将两个新商品添加到购物车中:

addItem(key, "jeans", 29.95);
addItem(key, "shirt", 19.95);

这会生成如下的数据库条目:

aql> select * from test.cart;
+----------------------+------------+--------+
| items                | totalItems | cost   |
+----------------------+------------+--------+
| "shoes,jeans,shirt," | 3          | 109.15 |
+----------------------+------------+--------+
1 row in set (0.187 secs)

简化程序

你的程序通过硬编码 bin 创建了初始记录。然而,由于 Aerospike 会自动创建缺失的 bin,这一步实际上不是必须的。可以通过直接使用 addItem 方法简化程序。如果记录中已有商品,新的商品将被附加。如果记录中没有商品或记录不存在,Aerospike 将为你创建记录,并自动创建 itemstotalItemscost bin。

因此,你的程序可以变为:

client = new AerospikeClient("172.17.0.2", 3000);
Key key = new Key("test", "cart", 1);
client.delete(null, key);
addItem(key, "shoes", 59.25);
addItem(key, "jeans", 29.95);
addItem(key, "shirt", 19.95);

注意以下新增行:

client.delete(null, key);

此行只是确保在运行程序前记录不存在。如果不执行这行代码并多次运行程序,你将不断地将相同的商品添加到购物车中。

更改后,让我们通过 AQL 再次检查:

aql> select * from test.cart;
+----------------------+------------+--------+
| items                | totalItems | cost   |
+----------------------+------------+--------+
| "shoes,jeans,shirt," | 3          | 109.15 |
+----------------------+------------+--------+
1 row in set (0.187 secs)

Operation 类

在前面的示例中,你看到用于描述 Aerospike 在记录上执行的操作的参数位于 Operation 类中。以下是该类的一些常用方法,如表 4-1 所示。

方法用途
add(Bin)将 bin 增加传入的 Bin 值。如果 bin 不存在,将用传入的值创建该 bin。如果传入的值或数据库中现有的值非数值类型,将抛出 AerospikeException,并返回 ResultCode.BIN_TYPE_ERROR 代码。
append(Bin)将传入的字符串值添加到 bin 字符串的末尾。如果 bin 不存在,将用传入的值创建 bin。如果传入的值或数据库中现有的值非字符串类型,将抛出 AerospikeException,并返回 ResultCode.BIN_TYPE_ERROR
get()返回记录中所有 bin 的内容。
get(String)返回指定 bin 的内容。如果该 bin 不存在,则不返回任何内容。
put(Bin)将 bin 的内容设置为传入的值。

operate() 的返回值

请注意,某些方法(例如 get 方法)会返回一个值。那么如何访问这些值呢?请回忆一下 operate() 的实际签名:

public Record operate(WritePolicy policy, Key key, Operation ... operations);

返回的 Record 包含指定要读取的值。例如,如果你希望将 addItem 方法更改为返回购物车中商品的当前总价,可以在执行的操作列表中添加一个 get("cost") 操作:

public static double addItem(Key key, String itemDescr, double cost) {
    Record record = client.operate(null, key, 
        Operation.append(new Bin("items", itemDescr + ",")),
        Operation.add(new Bin("totalItems", 1)),
        Operation.add(new Bin("cost", cost)),
        Operation.get("cost")
    );
    return record.getDouble("cost");
}

在此示例中,方法的返回类型更改为 double,在列表末尾添加了额外的 Operation,并通过在返回的 Record 上调用 getDouble("cost") 返回 "cost" bin 的值。

类似地,在 Python 中可以这样做:

my_operations = [ {"op": aerospike.OPERATOR_APPEND, "bin": "items", "val": f"{item_desc},"}, {"op": aerospike.OPERATOR_INCR, "bin": "totalItems", "val": 1}, {"op": aerospike.OPERATOR_INCR, "bin": "cost", "val": cost}, {"op": aerospike.OPERATOR_READ, "bin": "cost"},]
(record, metadata, bins) = client.operate(key, my_operations)

操作顺序

在单条记录上的操作将按照在 operate() 调用中指定的顺序应用。Aerospike 保证这些操作是原子性的,即所有操作要么全部成功,要么全部失败,且在第一个操作和最后一个操作之间不会有其他线程影响该记录。

操作顺序有时很重要。例如,假设希望 Aerospike 在插入新商品前返回购物车中商品的总价,并在插入商品后再次返回总价。可以修改程序如下:

Record record = client.operate(null, key, 
        Operation.get("cost"),
        Operation.append(new Bin("items", itemDescr + ",")),
        Operation.add(new Bin("totalItems", 1)),
        Operation.add(new Bin("cost", cost)),
        Operation.get("cost")
    );

在此示例中,首先获取 cost bin 的值,更新后再次获取。这样做在 Aerospike 中是有效的,但返回的结果会是什么呢?幸运的是,Record 对象有一个有用的 toString() 方法,因此可以在返回 Record 后立即添加以下代码来查看返回值:

System.out.println(record);

执行后,可以查看后续调用的返回值。此处相同的 operate 命令被调用了三次:

(gen:1),(exp:0),(bins:(cost:59.25))
(gen:2),(exp:0),(bins:(cost:[59.25, 89.2]))
(gen:3),(exp:0),(bins:(cost:[89.2, 109.15]))

第一个返回的值是代数(generation),即该记录被修改的次数。然后是记录的到期时间(若设置了 TTL)。最后返回 bin 的值,可以看到在第一次调用中 cost bin 以单个数字返回,在后续调用中则以包含两个值的列表形式返回。

如果考虑其背后的逻辑,这是合理的。在第一次调用时,cost bin 尚未存在,因此没有返回内容。操作列表末尾的 get("cost") 操作返回了一个值,因此 cost bin 以单个值返回。然而,在后续调用中,cost bin 对于操作列表开头和末尾的 get("cost") 调用都存在有效值,因此 Aerospike 必须返回这两个值,于是将它们封装在列表中。

ListOperation 和 MapOperation

到目前为止,你看到的操作都相对简单。然而,操作的强大功能在于它们能够作用于 Aerospike 的集合数据类型(CDTs)——列表和映射。这些内容在第 2 章中有所提及,现在我们来详细看看。

列表

列表是一组有序的元素集合。与 Aerospike 中所有的集合数据类型一样,列表中的元素可以是任何支持的类型,包括其他列表和映射,并且不要求元素类型一致。例如,一个列表可能包含:

["sofa", 255.99, 2, ["black", "red", "white"]]

在这个例子中,列表包含一个字符串("sofa")、一个浮点数(255.99)、一个长整型数(2)和一个列表。

可以按整个列表、索引、值或排名来访问列表。为了理解这些概念,来看一个示例。考虑以下列表:

[1, 4, 7, 3, 9, 26, 11]

索引是列表中元素的位置,从 0 开始。值是特定索引位置的元素值,而排名是在假设列表排序后的元素位置。表 4-2 显示了该列表的属性。

147392611
索引0 或 -71 或 -62 或 -53 或 -44 或 -35 或 -26 或 -1
排名0 或 -72 或 -53 或 -41 或 -64 或 -36 或 -15 或 -2

请注意,索引和排名可以使用负数来表示从列表末尾开始倒数。两者都以零为第一个元素。

值和索引比较直观,但让我们看看排名为 3(或 -4)的“7”是如何得出的。记住排名是列表排序后的元素顺序。因此,将列表排序后得到:

[1, 3, 4, 7, 9, 11, 26]

在这个排序后的列表中,“7”的索引为 3,或者从列表末尾开始为 -4。如果你对“7”为何位于位置 3 感到困惑,请记住列表中的第一个元素("1")是索引 0,"3" 是索引 1,"4" 是索引 2,"7" 是索引 3。

来看一个简单的程序来展示如何查询这些信息:

public class ListMapExamples {
    private static final String LIST_BIN = "list";
    private static IAerospikeClient client = new AerospikeClient("localhost", 3000);
    private static Key key = new Key("test", "sample", 1);
    
    private static void show(Operation operation, String description) {
        Record record = client.operate(null, key, operation);
        System.out.println(description + ": " + record.getValue(LIST_BIN));
    }

    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 4, 7, 3, 9, 26, 11);
        client.put(null, key, new Bin(LIST_BIN, list));
        
        show(ListOperation.getByIndex(LIST_BIN, 1, ListReturnType.VALUE), "Index of 1");
        show(ListOperation.getByRank(LIST_BIN, 1, ListReturnType.VALUE), "Rank of 1");
        show(ListOperation.getByValue(LIST_BIN, Value.get(1), ListReturnType.VALUE), "Value of 1");
        client.close();
    }
}

此程序的输出反映了之前提到的索引、排名和值:

Index of 1: 4
Rank of 1: 3
Value of 1: [1]

注意:

列表可以定义为 ORDEREDUNORDEREDUNORDERED 列表按插入顺序保留元素顺序,这是默认设置。而 ORDERED 列表则由 Aerospike 按排序顺序存储元素,与插入顺序无关。在 ORDERED 列表中,由于列表是排序的,因此排名与索引相同。

映射

映射是包含键值对的集合。大多数编程语言都有类似的映射或字典。映射的键可以是整数、字符串或二进制数据,元素的值可以是 Aerospike 支持的任意数据类型。

注意:

需要确保客户端编程语言支持映射的格式。例如,Node.js 不支持整数作为映射键,因此将此类映射读入 Node.js 客户端时会出错,但在 Java 等其他语言中工作正常。

可以通过键、值、索引或排名来访问映射。以下示例更容易理解。考虑以下映射:

{a:1, b:2, c:30, y:30, z:26}

表 4-3 显示了该映射的属性:

abcyz
12303026
索引0 或 -51 或 -42 或 -33 或 -24 或 -1
排名0 或 -51 或 -43 或 -24 或 -12 或 -3

例如,一个存储游戏分数的映射,键是玩家 ID,值是玩家分数。在此情况下:

  • 按键获取值将返回传入的玩家 ID 的分数。
  • 获取排名为 0 的项目可得到最高分的玩家,排名为 -1 的项目则返回最低分的玩家。

映射可以定义为 UNORDEREDKEY_ORDEREDKEY_VALUE_ORDERED。API 对所有映射类型相同,Aerospike 会根据需要排序。

操作

现在你了解了 Aerospike 列表和映射的基本功能,来看如何使用它们。列表和映射非常强大,如你将在第 6 章中看到,它们构成了许多数据建模技术的基础。

注意:

如果你对 operateoperations 感到困惑,不必担心!

  • Operations 指定数据库应执行的操作。多个类包含静态方法以创建操作实例,如 OperationMapOperationListOperation
  • operate() 是 API 调用,用于将这些操作传递给数据库以便执行。

类似于 Operation 类,还有 ListOperationMapOperation 类,分别包含列表和映射操作。

例如,在之前的购物车示例中,商品只是一个拼接的字符串,不便于操作。可以将每个商品转化为映射,并将这些映射存入购物车的商品列表中。然后在购物车示例中添加一些操作。

首先,定义一个方法将商品信息转换为映射:

public Map<String, Object> createItem(String itemDescr, double cost, String originalItem) {
    Map<String, Object> result = new HashMap<>();
    result.put("cost", cost);
    result.put("descr", itemDescr);
    result.put("orig", originalItem);
    return result;
}

然后,使用列表操作插入商品:

public static void addItem(IAerospikeClient client, Key key, Map<String, Object> item) {
    client.operate(null, key, ListOperation.append("items", Value.get(item)));
}

此方法中,你再次调用 client.operate,传入 ListOperation 将商品附加到列表末尾。注意这里将 item 包装在 Value.get(...) 调用中。

然后可以像这样插入几个商品到购物车列表中:

addItem(client, key, createItem("shoes", 59.25, "/items/item1234"));
addItem(client, key, createItem("jeans", 29.95, "/items/item2378"));
addItem(client, key, createItem("shirt", 19.95, "/items/item88293"));

在 AQL 中查看输出可以看到新的嵌套结构:

aql> select * from test.cart;
+-------------------------------------------------------------------------
| items
+-------------------------------------------------------------------------
| LIST('[{"cost":59.25, "descr":"shoes", "orig":"/items/item1234"}, {"cost"+-------------------------------------------------------------------------1 row in set (0.510 secs)

随着记录变得越来越复杂,检查它们也会变得更加困难。幸运的是,AQL 支持多种显示模式,如表 4-4 所示。

AQL 模式输出
TABLE表格显示输出,适合小型结果,但较大结果会被截断。默认模式。
JSON以 JSON 格式显示输出。注意 JSON 不支持非字符串键。
RAW逐行显示每个 bin 的完整输出,适合查看包含大量数据的记录。

这里最适合的是 JSON 模式:

aql> set output json
OUTPUT = JSON
aql> select * from test.cart;
[
   [
      {
         "items": [
            {
               "cost": 59.25,
               "descr": "shoes",
               "orig": "/items/item1234"
            },
            {
               "cost": 29.95,
               "descr": "jeans",
               "orig": "/items/item2378"
            },
            {
               "cost": 19.95,
               "descr": "shirt",
               "orig": "/items/item88293"
            }
         ]
      }
   ],
   [
      {
         "Status": 0
      }
   ]
]

购物车现在由一个包含三个元素的列表组成,每个元素是一个映射。

当然,也可以在单个 API 调用中组合多个操作。例如,假设要删除列表中的第二项,并返回列表中剩余项的数量,可以这样操作:

Record record = client.operate(null, key,
          ListOperation.removeByIndex("items", index, ListReturnType.NONE),
          ListOperation.size("items"));
int remaining = record.getInt("items");

在我们的例子中,这将导致 remaining 的值为 2。

反向标志(Inverted Flag)

ListReturnType 标志和对应的 MapReturnType 标志的所有返回值都是离散的(只能指定一个),唯独 ListReturnType.INVERTED 例外。该标志可以与其他任何返回类型一起指定,有效地反转列表命令的含义。

如果将此标志添加到前面的操作代码中,代码将变为:

Record record = client.operate(null, key,
    ListOperation.removeByIndex("items", index,
           ListReturnType.NONE | ListReturnType.INVERTED));
int removed = record.getInt("items");

INVERTED 标志的存在告诉 Aerospike 移除列表中的所有项,除了指定索引位置的那一项。因此,不论列表初始有 5 个元素还是 50 个元素,最后的列表都只会保留一个元素。

上下文(Contexts)

列表和映射操作可以嵌套至任意深度。在我们的购物车示例中,列表中包含映射,因此嵌套深度为 2。使用列表和映射操作时如何作用于非顶层项便成为了一个问题。

解决方案是所有这些 API 都接受一个上下文参数。该上下文描述了从顶层到特定列表或映射的路径。在 Java 中,这是列表或映射操作的可变参数,因此位于每个调用的末尾。例如,假设要将“shoes”项的价格从 59.25改为59.25 改为 49。你知道这是列表中的第一个项,因此可以使用以下 MapOperation

client.operate(null, key, 
       MapOperation.put(MapPolicy.Default, "items", Value.get("cost"),
                        Value.get(49), CTX.listIndex(0)));

让我们看看这行代码的含义。MapOperation 的第一个参数是 MapPolicy。在这种情况下,我们使用 Default,告诉 Aerospike 使用无序 (UNORDERED) 映射。如果想使用按键排序的映射 (KEY_ORDERED),可以在此处指定。接下来的参数是 bin 名称 ("items"),在生产系统中通常会将其提取为常量。然后是要设置的映射键 ("cost") 和设置的值 (49),两者都封装在 Value.get(...) 调用中。最后是上下文 (CTX.listIndex(0)),它告诉 Aerospike 要设置该值的映射在列表的第一个元素中。

注意,想要操作的项是映射,因此需要使用 MapOperation,即使该映射被包含在一个列表中。从顶层(bin 级别)到该映射的路径通过上下文来表示。上下文可能作为第一个参数会更直观,但遗憾的是在 Java 中,变长参数必须放在最后。

你可以在 Aerospike 网站的客户端驱动程序文档中找到更多的列表和映射操作,以及列表和映射扩展功能的性能复杂度估计。Aerospike 网站上的函数是基于 C 实现的,因此特定的驱动程序调用可能有所不同。

表达式

Aerospike 的一个强大功能是支持使用表达式。表达式可以用于过滤是否对记录执行特定操作、实时计算值、向记录写入派生数据等。让我们来看一些示例。

过滤表达式

过滤表达式仅在表达式返回 true 时才允许操作在服务器上执行。例如,如果你希望将订单状态更新为 COMPLETED,但前提是当前状态为 PROCESSING,可以使用过滤表达式来实现。你可以读取记录,检查当前状态是否为 PROCESSING,然后更新状态,但在读取和写入之间,可能有其他线程更改了记录状态。使用过滤表达式则可以避免这个问题,确保操作是原子性的,不会被其他线程中断。

假设购物车记录中包含一个状态,可以这样设置:

client.put(null, key, new Bin("state", "PROCESSING"));

然后更新状态为 COMPLETED

client.put(null, key, new Bin("state", "COMPLETED"));

这会直接覆盖现有状态而不进行检查,因此需要添加过滤表达式。可以在操作的 Policy 上指定过滤表达式。在这种情况下,你执行的是写操作,因此需要创建 WritePolicy 并传入操作中。例如:

WritePolicy wp = new WritePolicy(client.getWritePolicyDefault());
wp.filterExp = 
      Exp.build(Exp.eq(Exp.stringBin("state"), Exp.val("PROCESSING")));
client.put(wp, key, new Bin("state", "COMPLETED"));

上面创建了基于默认写策略的 WritePolicy,然后设置了过滤表达式。表达式采用前缀表示法,首先是操作符,然后是参数。在本例中,核心表达式是:

Exp.eq(Exp.stringBin("state"), Exp.val("PROCESSING"))

Exp.eq 比较两个表达式是否相等,并返回布尔值。Exp.stringBin("state") 读取名为 state 的 bin,Exp.val("PROCESSING") 创建一个持有字符串值 "PROCESSING" 的常量表达式。最终表达式为 true 表示 bin state 中包含 "PROCESSING"。在中缀表示法中,它相当于:

stringBin("state") == "PROCESSING"

Aerospike 提供了许多不同的表达式来操作记录的数据。例如,有比较运算符 Exp.eq,算术运算符 Exp.add,逻辑运算符 Exp.and,以及 Exp.cond 等控制操作(类似于 if-then-else 语句)。此外,还有 ListExpMapExp 类,分别用于操作列表和映射。

三值逻辑(Trilean Logic)

程序员一般熟悉布尔逻辑,布尔逻辑只有 truefalse 两个值,而在 Aerospike 表达式中,使用了三值逻辑,包括 truefalseunknown。这是因为 Aerospike 的架构中记录的元数据通常在内存中,而数据通常保存在闪存中。因此,可以基于元数据快速判断表达式是否为 truefalse,无需加载存储中的记录数据。

例如,假设需要判断记录的 state 是否为 PROCESSING,并检查最后一次更新是否在一天之内。Aerospike 的 sinceUpdate() 表达式可以获取记录更新的时间,代码如下:

Exp.and(
    Exp.eq(Exp.stringBin("state"), Exp.val("PROCESSING")),
    Exp.le(Exp.sinceUpdate(), Exp.val(TimeUnit.DAYS.toMillis(1)))
)

如果元数据表明条件不满足,则无需加载存储中的数据;如果结果为 unknown,则会从存储加载数据并重新评估表达式。

读取表达式

有时希望返回基于记录内容的派生数据,而不是记录中实际存在的数据。此类表达式不会返回布尔值,而是将数据返回给客户端。比如,如果希望返回购物车中物品的平均价格,可以利用 total bin 进行计算:

public static double getAverageCost(IAerospikeClient client, Key key) {
    Record record = client.operate(null, key,
        ExpOperation.read("avg", Exp.build(
            Exp.div(
                Exp.floatBin("total"), 
                Exp.toFloat(ListExp.size(Exp.listBin("items")))
            )
        ), ExpReadFlags.DEFAULT));
    return record.getDouble("avg");
}

此方法使用 ExpOperation.read 来获取平均价格,并创建一个名为 avg 的伪 bin,返回 total bin 除以 items 列表大小的结果。

注意,如果列表为空,将导致除以零的异常。可以通过在 ExpOperation.read 方法中加入 ExpReadFlags.EVAL_NO_FAIL 标志,忽略异常:

Record record = client.operate(null, key,
    ExpOperation.read("avg", Exp.build(
        Exp.div(
           Exp.floatBin("total"), 
            Exp.toFloat(ListExp.size(Exp.listBin("items")))
        )
    ), ExpReadFlags.DEFAULT | ExpReadFlags.EVAL_NO_FAIL));

这样一来,Aerospike 会静默处理异常,avg bin 将返回 null,读取为整数时为 0。

批量操作

第 3 章介绍了批量读取操作。简单回顾一下,批量读取会接收一个包含多个键的数组,并返回对应于这些键的记录数组。如果某个键对应的记录不存在,则会为该键返回 null

使用批量操作

批量读取可以在一次数据库调用中读取多个记录,从而减少网络通信、简化代码并降低延迟。这种方法看似对每次调用都适用,但在使用批量读取时需注意以下常见问题:

  • 启动批量操作时,客户端和服务器端都会产生一些开销。对于较小的批量,这会导致显著的性能开销。最坏的情况是批量操作仅处理一条记录,这样会比直接的键值读取慢得多。
  • 服务器端的批量架构使用一组缓存的、可复用的 128 KiB 缓冲区将记录返回给客户端。如果批量中的单个记录大于此大小,则会为该记录临时分配一个缓冲区,并在批量调用结束时丢弃。这会导致性能下降,因为需要不断进行内存的分配和释放。可以通过监控 batch_index_huge_buffers 指标来跟踪这些分配情况。更多监控详情请参考第 8 章。
  • 批量操作可能请求大量数据,导致 Aerospike 的批量响应变慢,并且需要使用 EWOULDBLOCK 进行等待。可以通过服务器的 batch_index_delay 指标监控这一情况。

尽管批量读取可能是最常用的,但 Aerospike 的批量操作功能远不止于此。Aerospike 的批量功能非常高效——客户端会计算每个键对应的服务器,并将所有键通过一个网络包发送到相应的服务器。根据 BatchPolicy 中的设置,服务器可能会并行处理需要处理的键,并将结果并行流式传输回客户端。

让我们来看其他几种批量操作的使用方法。

批量写入

有时,需要对多条记录执行相同的操作集。这些操作可以是写入、追加、列表或映射操作等。批量写入可以满足这种需求,通过接收记录键的数组和要应用到这些记录的操作集,来高效地执行操作。以下是一个稍显特殊的例子,假设有一个集合包含人的记录,其中包括每个人的州和该州的税率:

aql> select * from test.people
+----+----------+-----+-------+---------+
| PK | name     | age | state | taxRate |
+----+----------+-----+-------+---------+
| 5  | "Alex"   | 72  | "FL"  | 1.15    |
| 6  | "Lou"    | 25  | "CA"  | 2.05    |
| 0  | "Tim"    | 312 | "CO"  | 1.35    |
| 7  | "Jill"   | 44  | "IA"  | 0.44    |
| 2  | "Sue"    | 43  | "FL"  | 1.15    |
| 8  | "Manish" | 49  | "CO"  | 1.35    |
| 4  | "Mary"   | 33  | "NV"  | 0.95    |
| 1  | "Albert" | 29  | "CO"  | 1.35    |
| 9  | "Sunil"  | 54  | "CA"  | 2.05    |
| 3  | "Joe"    | 19  | "GA"  | 1.25    |
+----+----------+-----+-------+---------+
10 rows in set (0.148 secs)

假设需要将这10人的税率提高0.02%,可以通过以下代码生成所需的键数组:

Key[] keys = new Key[10];
for (int i = 0; i < 10; i++) {
    keys[i] = new Key("test", "people", i);
}

然后只需告诉 Aerospike 更新这些人的税率,操作代码如下:

client.operate(null, null, keys, Operation.add(new Bin("taxRate", 0.02)));

在这里,client.operate() 方法接收四个参数:BatchPolicyBatchWritePolicy、键数组以及要执行的操作。运行后,结果可以通过 AQL 查看:

aql> select * from test.people
+----+----------+-----+-------+---------+
| PK | name     | age | state | taxRate |
+----+----------+-----+-------+---------+
| 2  | "Sue"    | 43  | "FL"  | 1.17    |
| 0  | "Tim"    | 312 | "CO"  | 1.37    |
| 4  | "Mary"   | 33  | "NV"  | 0.97    |
| 1  | "Albert" | 29  | "CO"  | 1.37    |
| 9  | "Sunil"  | 54  | "CA"  | 2.07    |
| 7  | "Jill"   | 44  | "IA"  | 0.46    |
| 8  | "Manish" | 49  | "CO"  | 1.37    |
| 5  | "Alex"   | 72  | "FL"  | 1.17    |
| 6  | "Lou"    | 25  | "CA"  | 2.07    |
| 3  | "Joe"    | 19  | "GA"  | 1.27    |
+----+----------+-----+-------+---------+
10 rows in set (0.166 secs)

税率已成功增加。

批量操作的任意操作

有时,某些记录需要不同的操作集。例如,假设想为 Joe 增加 1 岁,为 Sue 修改州为 Ohio(OH),减少 Mary 的税率 0.1%,读取 Jill 的年龄和税率,删除 Alex 的记录,并将 Tim 的名字改为 Timothy。可以使用以下代码实现这些操作:

Key joesKey = new Key("test", "people", 3);
Key suesKey = new Key("test", "people", 2);
Key marysKey = new Key("test", "people", 4);
Key jillsKey = new Key("test", "people", 7);
Key alexsKey = new Key("test", "people", 5);
Key timsKey = new Key("test", "people", 0);

Operation[] joesOps = new Operation[] {Operation.add(new Bin("age", 1))};
Operation[] suesOps = new Operation[] {Operation.put(new Bin("state", "OH"))};
Operation[] marysOps = new Operation[] {Operation.add(new Bin("taxRate", -0.1))};
Operation[] timsOps = new Operation[] {Operation.put(new Bin("name", "Timothy"))};
BatchRead jillsRead = new BatchRead(jillsKey, new String[] {"age", "taxRate"}); 

client.operate(null, Arrays.asList(
        new BatchWrite(joesKey, joesOps),
        new BatchWrite(suesKey, suesOps),
        new BatchWrite(marysKey, marysOps),
        jillsRead,
        new BatchDelete(alexsKey),
        new BatchWrite(timsKey, timsOps)
));

System.out.println(jillsRead.record);

在这里,操作被分为三部分:定义每个人的键,定义 BatchWrite 的操作数组,并调用 operate() 方法,传入 BatchPolicyBatchRecords 列表。

二级索引

与许多其他数据库类似,Aerospike 也支持二级索引,以便高效地查询符合特定条件的记录。例如,可以查询“所有居住在科罗拉多州的人”或“所有年龄在 25 到 39 岁之间的人”。二级索引定义在特定集合中的某个 bin 上,并且必须指定类型,例如数字或字符串。表 4-5 显示了支持的不同二级索引类型以及可用的操作。

表 4-5:二级索引类型

类型允许的操作备注
NUMERIC等值、范围比较用于索引和查询整数类型的 bin,包括 long、short 等,不支持浮点数(float、double)。
STRING仅支持等值查询为了节省空间,Aerospike 会存储字符串的哈希值,因此仅支持精确匹配,且字符串匹配区分大小写。
GEO JSON点在区域内、区域包含点GeoJSON 查询是高级主题,不在本书的范围内,但支持地理空间数据的高效比较。

注意,如果某条记录的 bin 含有已定义二级索引的内容,但 bin 中的类型不匹配该二级索引类型,则该记录将不会出现在二级索引查询的结果中。例如,在一个 bin 为“age”的数字索引中,如果某条记录包含字符串 "40",则此记录将不会与该二级索引匹配。

要创建二级索引,可以使用 Aerospike 管理工具 asadm。系统管理员经常使用该工具来监控和管理 Aerospike 集群。在本节我们将简要介绍该工具,但在第 7 章会有更详细的讨论。

启动 asadm 的命令为:

 % asadm [-h <ip_address>]

与 Aerospike 的其他工具一样,默认情况下 IP 地址为本地地址 (127.0.0.1),所以对许多本地安装而言,命令名之后不需要任何参数。

这将使你进入一个交互式命令行:

Admin>

输入的命令会实时显示结果。首先,可以输入 help 命令来查看可用的命令。asadm 支持帮助命令(参见第 7 章),还支持命令补全功能,因此可以输入部分命令内容后双击 Tab 键查看可用选项。

要查看已有的索引:

Admin> show sindex

通常在 Aerospike 文档和工具中,sindex 是 “secondary index”(二级索引)的简写。目前环境中没有二级索引,因此需要创建一个。

asadm 有两种模式:用户模式和特权模式。用户模式允许查看信息,但不允许集群管理(如创建二级索引)。需要切换到特权模式,只需输入 enable。在生产环境中,只有具有足够权限的用户才能进入特权模式,但你还未在集群上启用安全设置,因此无需担心权限问题。

Admin> enable
Admin+>

进入特权模式后可以创建索引:

Admin+> manage sindex create numeric age_idx ns test set people bin age;

show sindex 确认索引 age_idx 已成功创建。

Admin+> show sindex
 ~~~Secondary Indexes (2023-08-28 13:00:08 UTC)~~~~
 Index  |Namespace|   Set|Bin|    Bin|  Index|State
 Name   |         |      |       Type|   Type|
 age_idx|test     |people|age|numeric|default|RW
 Number of rows: 1

使用 manage sindex 命令创建二级索引。命令的结构较为复杂,但大致意思是:管理二级索引(manage sindex)并创建一个名为 age_idx 的数字索引(create numeric age_idx),位于 test 命名空间(ns test)中的 people 集合(set people)的 age bin(bin age)上。

使用二级索引

创建好二级索引后,可以使用它查询集合中符合特定年龄范围的人员:

Statement stmt = new Statement();   
stmt.setFilter(Filter.range("age", 25, 39));
stmt.setNamespace("test");
stmt.setSetName("people");
RecordSet recordSet = client.query(null, stmt);
while (recordSet.next()) {
    System.out.println(recordSet.getRecord());
}
recordSet.close();

首先,创建一个 Statement 对象,用来指定查询的参数,包括命名空间和集合。设置过滤条件,在 age bin 上使用范围过滤器,设置年龄范围为 25 到 39 岁。范围的两端是包含的,因此会包括 25 和 39 岁的人以及年龄在此范围内的人。

接着执行查询并返回 RecordSet。RecordSet 可以方便地遍历符合条件的记录,如上所示,在 next() 方法返回 true 后,可获取每条记录及其键。

虽然该查询看起来简单,但 Aerospike 在后台做了大量工作。假设 Aerospike 集群中的 people 集合包含 10 亿条记录,其中有 100 万条符合查询条件。默认情况下,Aerospike 会并行向每个节点发送请求,每个节点会查询一棵内存树,该树包含集合中每条记录的 age bin 值。当找到符合条件的记录时,结果将被流式返回给客户端。这种算法被称为“散播聚集”(scatter gather)算法,因为它向所有节点发送请求并在客户端聚集结果。

客户端会并行接收所有节点的响应。对于较大的结果集,Aerospike 依靠按需从相应节点获取更多结果,而非一次性将所有结果存入内存。客户端还会跟踪从每个节点处理的记录,因此如果查询过程中某个节点发生故障,客户端可以继续从集群中获取记录,而无需重启查询。

查询完成后请关闭结果集,以释放客户端和服务器端的资源。

多条件查询

Aerospike 仅支持每次查询使用一个二级索引。因此,即使在 "age" bin 上定义了一个索引,在 "state" bin 上定义了另一个索引,如果你希望查询所有年龄在 25 到 39 岁之间且居住在科罗拉多州的人,仍然只能使用其中的一个索引,而不能同时使用两个索引。通常情况下,建议使用选择性最高的索引,即更可能返回较小结果集的索引。

不过,处理这种多条件查询有一个简单的解决方案。如果你猜测可以“使用表达式”,那你猜对了!可以在带有二级索引的查询中添加一个表达式。这样,Aerospike 将先使用二级索引加载匹配的记录,然后对每条记录应用表达式,仅返回符合表达式的记录。因此,要查询年龄在 25 到 39 岁之间且州为科罗拉多州(CO)的人,可以将代码更改如下:

Statement stmt = new Statement();   
stmt.setFilter(Filter.range("age", 25, 39));
stmt.setNamespace("test");
stmt.setSetName("people");

QueryPolicy qp = new QueryPolicy(client.getQueryPolicyDefault()); 
qp.filterExp = Exp.build(Exp.eq(Exp.stringBin("state"), Exp.val("CO"))); 

RecordSet recordSet = client.query(qp, stmt); 
while (recordSet.next()) {
    System.out.println(recordSet.getRecord());
}
recordSet.close();
  • 创建一个新的 QueryPolicy,并基于默认设置。
  • 设置 filterExp 值为所需的过滤表达式。
  • client.query 调用中包含这个 QueryPolicy 实例。

总结

在本章中,你了解了 Aerospike 的一些强大功能。列表和映射的使用大大增强了 Aerospike 的能力,并将在第 6 章的数据建模讨论中占据重要地位。表达式支持高级过滤操作,而批处理和二级索引则实现了从 Aerospike 批量查询记录的功能。下一章将重点介绍 Aerospike 的架构,以便全面理解其高效性能的原因。