到目前为止,你已经了解了 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 客户端执行一次调用,对数据库进行一次事务操作。此事务包含三个独立的操作:
- 将字符串附加到现有字符串上,此处为商品描述。如果
itemsbin 不存在,Aerospike 会自动创建该 bin。 - 将
totalItemsbin 增加 1。如果 bin 不存在,它将以默认值零创建,然后再加上传递值 1。 - 将商品的成本添加到累计金额中。
可以调用此方法将两个新商品添加到购物车中:
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 将为你创建记录,并自动创建 items、totalItems 和 cost 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 显示了该列表的属性。
| 值 | 1 | 4 | 7 | 3 | 9 | 26 | 11 |
|---|---|---|---|---|---|---|---|
| 索引 | 0 或 -7 | 1 或 -6 | 2 或 -5 | 3 或 -4 | 4 或 -3 | 5 或 -2 | 6 或 -1 |
| 排名 | 0 或 -7 | 2 或 -5 | 3 或 -4 | 1 或 -6 | 4 或 -3 | 6 或 -1 | 5 或 -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]
注意:
列表可以定义为 ORDERED 或 UNORDERED。UNORDERED 列表按插入顺序保留元素顺序,这是默认设置。而 ORDERED 列表则由 Aerospike 按排序顺序存储元素,与插入顺序无关。在 ORDERED 列表中,由于列表是排序的,因此排名与索引相同。
映射
映射是包含键值对的集合。大多数编程语言都有类似的映射或字典。映射的键可以是整数、字符串或二进制数据,元素的值可以是 Aerospike 支持的任意数据类型。
注意:
需要确保客户端编程语言支持映射的格式。例如,Node.js 不支持整数作为映射键,因此将此类映射读入 Node.js 客户端时会出错,但在 Java 等其他语言中工作正常。
可以通过键、值、索引或排名来访问映射。以下示例更容易理解。考虑以下映射:
{a:1, b:2, c:30, y:30, z:26}
表 4-3 显示了该映射的属性:
| 键 | a | b | c | y | z |
|---|---|---|---|---|---|
| 值 | 1 | 2 | 30 | 30 | 26 |
| 索引 | 0 或 -5 | 1 或 -4 | 2 或 -3 | 3 或 -2 | 4 或 -1 |
| 排名 | 0 或 -5 | 1 或 -4 | 3 或 -2 | 4 或 -1 | 2 或 -3 |
例如,一个存储游戏分数的映射,键是玩家 ID,值是玩家分数。在此情况下:
- 按键获取值将返回传入的玩家 ID 的分数。
- 获取排名为 0 的项目可得到最高分的玩家,排名为 -1 的项目则返回最低分的玩家。
映射可以定义为 UNORDERED、KEY_ORDERED 或 KEY_VALUE_ORDERED。API 对所有映射类型相同,Aerospike 会根据需要排序。
操作
现在你了解了 Aerospike 列表和映射的基本功能,来看如何使用它们。列表和映射非常强大,如你将在第 6 章中看到,它们构成了许多数据建模技术的基础。
注意:
如果你对 operate 和 operations 感到困惑,不必担心!
Operations指定数据库应执行的操作。多个类包含静态方法以创建操作实例,如Operation、MapOperation和ListOperation。operate()是 API 调用,用于将这些操作传递给数据库以便执行。
类似于 Operation 类,还有 ListOperation 和 MapOperation 类,分别包含列表和映射操作。
例如,在之前的购物车示例中,商品只是一个拼接的字符串,不便于操作。可以将每个商品转化为映射,并将这些映射存入购物车的商品列表中。然后在购物车示例中添加一些操作。
首先,定义一个方法将商品信息转换为映射:
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”项的价格从 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 语句)。此外,还有 ListExp 和 MapExp 类,分别用于操作列表和映射。
三值逻辑(Trilean Logic)
程序员一般熟悉布尔逻辑,布尔逻辑只有 true 和 false 两个值,而在 Aerospike 表达式中,使用了三值逻辑,包括 true、false 和 unknown。这是因为 Aerospike 的架构中记录的元数据通常在内存中,而数据通常保存在闪存中。因此,可以基于元数据快速判断表达式是否为 true 或 false,无需加载存储中的记录数据。
例如,假设需要判断记录的 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() 方法接收四个参数:BatchPolicy、BatchWritePolicy、键数组以及要执行的操作。运行后,结果可以通过 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() 方法,传入 BatchPolicy 和 BatchRecords 列表。
二级索引
与许多其他数据库类似,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 的架构,以便全面理解其高效性能的原因。