Aerospike:入门与实战——数据模型

在前几章中,我们讨论了Aerospike的架构以及数据库所提供的功能。在本章中,我们将探讨如何在Aerospike中进行数据建模以解决常见问题。与大多数数据库一样,解决相同问题的方法有多种,因此我们会讨论各种方法的优缺点。

请注意,在Aerospike中进行数据建模的方法有很多种,适合的技术取决于要解决的问题。本章将介绍一些较常见的建模技术,但并不是详尽无遗的。

Aerospike数据建模

Aerospike支持具有类似关系数据库结构的记录,并支持类似关系数据库的二级索引,因此,直观上会认为数据建模技术会与关系数据库中的方法类似。然而,事实并非总是如此。为了说明这一点,我们来看一下经典的数据聚合方式。

假设您想要建模一个客户记录。客户与零个或多个地址关联,并且这些地址在没有与客户关联时没有业务用途。这是一个经典的聚合案例,您可以将其表示如图6-1所示。

在关系模型中,您通常会创建两个不同的表:customeraddress,并在 address 表中使用一个指向 customer 表的外键。为了保持引用完整性,您会在 customer 表中删除记录时级联删除 address 表中的相关记录。用 PostgreSQL 语法定义如下:

sql 复制代码
CREATE TABLE customer(
    cust_id UUID PRIMARY KEY, 
    first_name TEXT, 
    ... 
    last_login TIMESTAMP
);

CREATE TABLE address (
    address_id UUID PRIMARY KEY, 
    line_1 TEXT NOT NULL, 
    line_2 TEXT, 
    ... 
    cust_id UUID REFERENCES customer(cust_id) ON DELETE CASCADE
);

为了在加载一个客户的所有地址时获得良好的性能,您应在 address 表的外键上建立索引:

arduino 复制代码
CREATE INDEX CUST_ID_IDX ON ADDRESS(CUST_ID);

在 Aerospike 中处理同样的问题时,您希望能够高效地加载某个客户的地址,并在该客户记录删除时同时删除关联的地址。对此有几种建模方式,下面我们将探讨各自的优缺点。

注意 回顾一下 Aerospike 和大多数关系数据库之间的一些差异和对应关系:

关系型数据库 Aerospike
Database Namespace
Table Set
Record Record
Field Bin

二级索引

在 Aerospike 中,可以使用非常类似于传统方法的方式来建模此问题。可以创建两个集合:Customer 集合和 Address 集合。在 Address 集合中,设置一个包含 Customer ID 的 bin,并在该 bin 上定义一个二级索引,如图 6-2 所示。

这种方法存在一些问题:

  1. Aerospike 不支持自动级联删除 :当您删除 Customer 对象时,关联的 Address 对象也应被删除。使用这种数据模型无法自动实现这一点。可以通过二级索引和操作结合实现此功能,但这会带来多个操作的开销,首先显式删除 Customer 对象,然后使用二级索引扫描 Address 对象。示例代码如下:

    ini 复制代码
    String custToDelete = "123bca";
    Statement stmt = new Statement();
    stmt.setNamespace("test");
    stmt.setSetName("address");
    stmt.setFilter(Filter.equal("cust_id", custToDelete));
    ExecuteTask task = client.execute(null, stmt, Operation.delete());
    client.delete(null, new Key("test", "customer", custToDelete));
    task.waitTillComplete();
  2. 这种方式的二级索引使用效率不高:如第 4 章所述,二级索引查询采用"分散收集"方法,所有节点会并行查询匹配记录,然后将结果流式传回客户端。这会导致 Aerospike 客户端驱动程序和集群的工作量增大。如果只有少量地址对象而集群中节点数量庞大,则很多节点会执行查找匹配地址的工作,但可能找不到任何匹配项。

让我们来看看更高效的方法:将地址集合聚合到 customer 中。

将子对象聚合到一个记录中

在这个用例中,通常只有少数地址与一个客户关联,且这些地址通常较小。为何不直接将地址存储在客户记录中呢?

使用这种方法,可以在客户记录上定义一个 address bin。该 bin 包含一个列表,列表中的元素是映射。每个映射代表一个地址,其中键为地址的字段(如城市、州、国家等),值为对应数据。例如:

json 复制代码
{
    "cust_id": "123bca",
    "first_name": "John",
    "last_name": "Doe",
    "dob": 1680714616850,
    "email": "jdoe@here.com",
    "verified": true,
    "last_login": 1685764613895,
    "addresses": [
        {
            "line_1": "1 Main St",
            "city": "Denver",
            "state": "CO",
            "country": "USA",
            "Zip_code": 80001
        }
    ]
}

这种方法有几个优点:

  • 读取客户记录时会自动检索该客户的所有地址,无需执行额外查询。同时,删除 Customer 记录会自动删除所有关联的地址。
  • 客户及其地址可以使用键值操作进行访问,这在 Aerospike 中效率极高。
  • 地址可以直接被操作或在客户记录中增删,利用列表和映射操作即可实现。
  • 地址对象得到了简化。不再需要包含客户 ID,因为访问地址时已知客户 ID。同样也不需要为每个地址分配一个 ID,列表中的位置可以作为"伪 ID"。

如果您认为这会增加客户记录的大小,那是正确的。但通常地址数量相对较少,这种方式带来的开销通常较低。事实上,增加对象大小的代价可能比想象的小。Aerospike 通常使用 SSD 存储信息,现代 SSD 为"块级"设备,意味着数据只能以块为单位读写,而非逐字节。这些块的大小通常为 4 KiB,因此如果应用请求 1 KiB,SSD 实际上会读取 4 KiB 并丢弃多余的 3 KiB。如果增加地址让客户记录的大小从 2 KiB 增加到 3 KiB,读取记录的成本实际上不会增加!

将子对象聚合到多个记录中

前述方法在子对象数量已知且较少时效果良好。但在许多用例中情况并非如此。考虑信用卡及其关联的交易记录。对于活跃的信用卡用户来说,每年可能会有数千笔交易,若是公司卡,交易数量可能更高。

一种方案是使用二级索引(参见"二级索引"),每笔交易记录保存账户编号。要查询给定账户的交易记录,可以执行二级索引查找。然而,信用卡供应商通常只需要最近一段时间(如过去180天)的交易记录。可以将账户编号的二级索引与表达式结合(如第4章所示)来解决此问题。但若是公司信用卡,可能有数百万笔交易,此时数据库集群需加载所有交易记录,通过表达式筛选出所需的记录并进行排序,这些操作可能会超过欺诈检测的响应时间窗口。

为了加快处理速度,可以考虑将 Transaction 记录合并到 CreditCard 记录中。信用卡的记录可能如下所示,使用主账号(PAN,一部分信用卡号)作为主键:

json 复制代码
{
    "cardNo": "1234123412341234",
    "cardSeqNo": 1,
    "custNo": "cust-1234",
    "expiry": 1680714616850,
    "opened": 1678912262162,
    "txns": [
        {
            "txnId": "txn1",
            "amount": 10000,
            "desc": "New car tire",
            "txnDate": 1680113831954
        },
        {
            "txnId": "txn2",
            "amount": 500,
            "desc": "Ice cream",
            "txnDate": 1699422631954
        }
    ]
}

在这种情况下,不可能将所有交易存储在单个记录中。自Aerospike 6.4版本起,单个记录的最大存储限制为8 MiB。即使能将所有内容存储在8 MiB中,你真的会这样做吗?如第5章所述,Aerospike总是执行写入时复制(copy-on-write),这意味着每次记录更改时,整个记录都会被重写。因此,对8 MiB的 CreditCard 记录做出微小更改(如添加一笔交易)意味着存储设备需先读取8 MiB,再写回8 MiB的更新数据。这对存储设备的负担很重,若此类操作频繁,数据库节点可能会耗尽I/O容量,导致数据库变慢。这种情况如图6-3所示。

对此问题的更佳解决方案是将大量子对象分成有限的"桶"并将每个桶存储在单独的记录中。以信用卡为例,可以在 Transaction 集中存储交易记录,并将每张信用卡的交易记录按天分成多个记录,每天的交易记录存储在一个单独的记录中。因此,与图6-3的存储方式不同,新集合可以如图6-4所示进行存储。

在这个新的模型中,信用卡详情自成一个记录。通常这些详情不会经常改变,因此每次添加交易记录时重新写入这些详情会显得低效,而在图6-3所展示的数据模型中,正是这种情况。这里的交易记录被拆分为每日一个记录,并且这些记录使用复合键(由两个或多个独立信息片段组成的键),其中包含信用卡的主账号(PAN)和某个偏移量的日期。

注意 请注意,只要偏移日期保持一致,其实际起始时间并不重要。例如,您可以将"Day 0"设为2010年1月1日。日期偏移量可以通过以下公式轻松计算得出:

scss 复制代码
(milliseconds(NOW) - milliseconds(OFFSET_DATE)) / TimeUnit.DAYS.toMillis(1);

在特定日期的记录中,有一个 txns bin 用于存储该日期的交易记录。可以使用不同的格式来存储这些交易记录,例如之前提到的映射列表(list of maps)。在这种情况下,您可以稍微调整一下,使用以交易时间戳为键的映射(map of maps),其中键是时间戳,值是包含交易信息的映射。例如:

css 复制代码
txns: {
    1680113831954: {"amount": 10000, "desc": "New car tire", "txnId": "txn1"},
    1699422631954: {"amount": 500, "desc": "Ice cream", "txnId": "txn2"},
    1704423453345: {"amount": 2750, "desc": "Pizza", "txnId": "txn3"}
}

进行这种更改的原因是信用卡交易的用例通常需要基于时间范围的操作,例如"检索该日期下午3点到5点之间的所有交易"。将时间戳作为交易键可以简化这些操作。同样地,可以将映射定义为 KEY_ORDERED,以使基于时间范围的操作更高效。稍后我们将在"此模型的附加操作"部分中更详细地讨论这一点。

因此,您可以编写一个方法来加载某信用卡的过去30天交易记录,以运行欺诈检测检查:

vbnet 复制代码
private final static long EPOCH_TIME = new GregorianCalendar(2010, 0, 1).getTime().getTime();

private long calculateDaysSinceEpoch(Date date) {
    return (date.getTime() - EPOCH_TIME) / TimeUnit.DAYS.toMillis(1);
}

public List<Transaction> readCreditCardTransactions(IAerospikeClient client, long cardId) {
    long dayOffset = calculateDaysSinceEpoch(new Date());
    Key[] keys = new Key[DAYS_TO_FETCH];
    for (int i = 0; i < DAYS_TO_FETCH; i++) {
        keys[i] = new Key(creditCardNamespace, SET_NAME, "Pan-" + cardId + ":" + (dayOffset - i));
    }
    Record[] records = client.get(null, keys);
    List<Transaction> txnList = new ArrayList<>();
    for (int i = 0; i < DAYS_TO_FETCH; i++) {
        if (records[i] == null) {
            continue;
        }
        TreeMap<Long, Map<String, Object>> map = (TreeMap<Long, Map<String, Object>>) records[i].getMap(MAP_BIN);
        for (long txnDate : map.descendingKeySet()) {
            Map<String, Object> data = map.get(txnDate);
            Transaction txn = new Transaction();
            txn.setTxnId((String) data.get("txnId"));
            txn.setTxnDate(new Date(txnDate));
            txn.setAmount((long) data.get("amount"));
            txn.setDescription((String) data.get("desc"));
            txnList.add(txn);
        }
    }
    return txnList;
}

让我们来看看这个方法在做什么。 首先,EPOCH_TIME 被定义为自2010年1月1日起的毫秒数。然后,calculateDaysSinceEpoch 方法计算自此时间以来的天数。这使用了64位整数运算,因此非常高效。

readCreditCardTransactions 方法中,第一行调用 calculateDaysSinceEpoch 方法以计算当前日期的偏移量。这个偏移量会是一个数值,例如5128,具体取决于当前日期。然后,形成一个键数组,其中每一天都有一个键:

ini 复制代码
Key[] keys = new Key[DAYS_TO_FETCH];
for (int i = 0; i < DAYS_TO_FETCH; i++) {
    keys[i] = new Key(creditCardNamespace, SET_NAME, "Pan-" + cardId + ":" + (dayOffset - i));
}

需要注意的是,由于使用恒定哈希分布记录,Aerospike并不像一些其他数据库那样内置对复合键的支持。然而,正如此示例所示,您可以构建自己的复合键。在此例中,您使用了一个固定字符串、cardIddayOffset 的组合。该代码生成的键示例可能类似于 Pan1234123412341234:5125

一旦形成键数组,您可以调用:

ini 复制代码
Record[] records = client.get(null, keys);

该调用从数据库中加载 keys 数组中的所有记录,并返回一个记录数组,每个键对应一个记录,如第3章所讨论的。这种调用通常非常高效,利用集群并行化快速获取所有结果。

接下来,程序遍历返回的记录:

ini 复制代码
List<Transaction> txnList = new ArrayList<>();
for (int i = 0; i < DAYS_TO_FETCH; i++) {
    if (records[i] == null) {
        continue;
    }

可能会有一些返回的记录为 null,这是因为某些天没有任何交易发生。在这种情况下,无需进行处理,因此可以直接跳过该天。

接着,方法从记录中提取交易数据:

vbnet 复制代码
TreeMap<Long, Map<String, Object>> map = (TreeMap<Long, Map<String, Object>>) records[i].getMap(MAP_BIN);
for (long txnDate : map.descendingKeySet()) {
    Map<String, Object> data = map.get(txnDate);
    Transaction txn = new Transaction();
    txn.setTxnId((String) data.get("txnId"));

在此数据模型中,交易记录存储在服务器上的一个 Map 中。然而,服务器上的 Map 被定义为 KEY_ORDERED,因此它们按照交易时间戳排序。Java中的映射 (Map) 本质上是无序的,因此,为了保留这个顺序,Aerospike Java驱动程序返回了一个 TreeMap------一个有序的 Map。这使得交易记录可以按时间顺序进行处理。例如,如果用例只需要返回过去30天的最近1,000条交易记录,这种方式会非常方便。

剩下的工作就是将每个交易解包成应用程序可识别的 Transaction 对象。可以通过从映射中提取每个字段并将其设置到 Transaction 对象来完成。这对于熟悉在Java中使用JDBC驱动程序解包关系数据库对象的用户来说应该很熟悉。

此模型的附加操作

映射结构允许根据键高效查询记录,而这里的键代表日期。Aerospike 的 MapOperation 类让我们可以对映射内的元素执行操作。回顾一下映射中的数据结构如下所示:

css 复制代码
txns: {
    1680113831954: {"amount": 10000, "desc": "New car tire", "txnId": "txn1"},
    1699422631954: {"amount": 500, "desc": "Ice cream", "txnId": "txn2"},
    1704423453345: {"amount": 2750, "desc": "Pizza", "txnId": "txn3"}
    ...
}

在前一部分的代码中,获取了映射的所有元素并将它们转换为 Transaction 对象。但如果我们不需要整个映射呢?例如,您可能只想要当天3点到5点之间的交易记录。换句话说,您可能希望只从映射中返回一个键的范围,而不是整个映射。可以通过以下代码实现:

csharp 复制代码
Record record = client.operate(null, key, 
    MapOperation.getByKeyRange("txns", Value.get(startTime), 
                               Value.get(endTime), MapReturnType.KEY_VALUE));

在此示例中,startTime 是当天3点(转换为长整数),endTime 是当天5点。这些长整数需要转换为 Value 类的实例,以便传递给 getByKeyRange 方法,因此使用了 Value.get(...) 方法。包含映射的 bin 名称为 txns,并希望返回键和值以便重新组合整个交易信息。

正如第4章所讨论的,operate 命令总是返回一个 Record,但返回的 bin 值类型取决于操作。例如,如果只想统计3点到5点之间的交易数量,可以传递 MapReturnType.COUNT,而不是 MapReturnType.KEY_VALUE。然后可以通过以下方式获取计数:

ini 复制代码
int count = record.getInt("txns");

在这种情况下,我们获取了键和值,因此可以重新组装交易信息。这里,Aerospike 将返回一个 List<AbstractMap.SimpleEntry>

可能会觉得奇怪,为什么从数据库中的 Map 中选择的结果,Aerospike 客户端会返回一个 List?这是因为 Aerospike 中的 Map 可以排序(如本例所示),并且您正在从排序后的映射中选择一个键的范围。Java 的 Map 没有排序的概念,因此 Aerospike 客户端按您请求的顺序返回项目作为有序列表。

要使用此结果,代码可以像这样:

javascript 复制代码
List<SimpleEntry<Long, Map<String,Object>>> entries =
    (List<SimpleEntry<Long, Map<String, Object>>>)record.getList(MAP_BIN);
for (SimpleEntry<Long, Map<String,Object>> simpleEntry : entries) {
    Long txnTime = simpleEntry.getKey();
    Date txnDate = new Date(txnTime);
    Map<String, Object> txnDetails = simpleEntry.getValue();
    System.out.printf("%tH:%tM:%tS:%s\n", 
            txnDate, txnDate, txnDate, txnDetails);
}

输出将类似于:

ini 复制代码
13:49:14:{amount=74, desc=desc-1, txnid=txn-74}
16:18:14:{amount=26, desc=desc-1, txnid=txn-26}
16:35:33:{amount=40, desc=desc-1, txnid=txn-40}

显然,如果使用真实数据而不是生成的数据,交易值会更加有意义。

进一步优化

前面的数据模型已经不错,但我们可以进一步优化。来看一个存储在映射中的交易示例:

css 复制代码
1680113831954: {"amount": 10000, "desc": "New car tire", "txnId": "txn1"}

由于交易日期是映射的键,可以使用如 getByKeyRangegetByKeyListMapOperations 操作来查询数据。但这里存在大量冗余,每笔交易包含相同的数据描述:amountdesctxnId,这些描述的长度甚至可能比实际存储的数据还长!

一种优化方法是将实际数据存储在列表中而非映射中,如下所示:

css 复制代码
txns: {
	1680113831954: [10000, "New car tire", "txn1"],
	1699422631954: [500, "Ice cream", "txn2"],
	1704423453345: [2750, "Pizza", "txn3"],
	...
}

插入数据时的唯一区别在于值的构建方式:

less 复制代码
List<Object> transactionAsList = Arrays.asList(
    transaction.getAmount(),
    transaction.getDescription(),
    transaction.getTxnId());

client.operate(null, key,
    MapOperation.put(mapPolicy, MAP_BIN,
    Value.get(transaction.getTxnDate().getTime()),
    Value.get(transactionAsList)));

这种格式节省了大量存储空间,从而提升性能,因为需要读取和写入的数据量减少,网络传输的数据量减少,同一记录内可以容纳更多交易数据。然而,该格式不如之前直观。在这个简单示例中并不复杂,但如果列表包含20个整数,代表不同含义的数据如日期、金额、客户ID等,理解起来就不如之前的格式方便。如果这些整数表示相同类型的元素,如向量的组成部分,那么列表更适合作为数据结构。

在这种情况下使用列表还有一个优势:列表具有内在的顺序性,在某些用例中可能很有用。

列表排序

在 Aerospike 中,比较两个列表是否相等或是否有大小关系时,遵循以下规则:

  1. 从第一个元素开始依次比较列表中的每个元素,如果当前元素大于另一个列表中相应位置的元素,则当前列表更大。如果元素相等,继续比较下一个元素。
  2. 如果某个列表没有更多元素了,检查另一个列表的大小,如果它更大,则另一个列表更大,否则两个列表相等。

示例:

  • 列表 [1,2] 小于 [1,3],因为第一个元素相等,但 2 < 3
  • 同样,[1,2,9] 小于 [1,3,1],因为第一个列表的第二个元素小于第二个列表的第二个元素。
  • 列表 [1,2,3] 大于 [1,2],因为前两个元素相同,但第一个列表更长。

需要注意,类型在 Aerospike 中也有预定义顺序,且不同类型的比较结果总是固定的。例如,整数始终小于字符串,字符串始终小于列表,因此 27 小于 "25",而 "25" 又小于 [1],这仅仅是类型顺序的结果。

根据值排序

到目前为止,我们讨论了基于键的操作。这些操作在定义为 KEY_ORDEREDKEY_VALUE_ORDERED 的映射上非常高效。然而,Aerospike 还支持基于映射值的操作。

考虑一个用例:我们希望能够根据交易金额进行操作,例如"获取金额大于或等于 50,000 的交易"。我们可以这样实现:

less 复制代码
Record record = client.operate(null, key,
    MapOperation.getByValueRange(
        "txns",
        Value.get(Arrays.asList(50000)),
        Value.get(Arrays.asList(Long.MAX_VALUE)),
        MapReturnType.KEY_VALUE));

与之前的 getByKeyRange 调用类似,但这次是基于值进行操作。这将返回金额大于指定值的记录键和值。

注意,代码没有直接传递 Value.get(50000) 作为起始值,而是使用 Value.get(Arrays.asList(50000))。这是因为值是一个列表。如果仅传递 Value.get(50000),Aerospike 会将其视为整数,而整数总是小于列表,这将导致查询不到任何结果。

通过将 50000 包裹在 Java 列表中,Aerospike 会比较两个列表。根据"列表排序"中的规则,这将按预期返回符合金额条件的交易。

关联对象

我们已经了解了将聚合对象嵌入父对象的强大功能,这在实际应用中非常常见。而另一种常见的用例是拥有两个彼此关联的顶级对象。例如,一个客户(Customer)可能拥有多个账户(Account),而一个账户也可能关联多个客户。这两者都是独立的顶级实体,各自具有业务价值。

处理这种情况的最常见方法是让客户对象包含账户ID的列表,同时账户对象也包含客户ID的列表。图6-5展示了客户和账户之间的关系。每个账户的 custId 列表中包含了与该账户相关的客户ID,而每个客户的 accountIds 列表中则存放了该客户相关的账户ID。

这种模式便于从账户导航到客户,或从客户导航到账户。如果某个用例不需要这种双向导航,例如始终从客户查询账户,那么只需维护一个列表(在这种情况下即客户中的账户ID列表)。

更新关系

以下示例展示了如何在客户和账户之间创建关系。假设两个对象都已存储到 Aerospike 中,此代码仅负责在数据库中链接它们:

less 复制代码
ListPolicy listPolicy = new ListPolicy(
        ListOrder.UNORDERED, 
        ListWriteFlags.ADD_UNIQUE | ListWriteFlags.NO_FAIL);

// 将账户ID放入客户记录的accountIds列表中,忽略重复项
client.operate(null, new Key(NAMESPACE, CUST_SET, customer.getId()),
    ListOperation.append(listPolicy, "accountIds",
        Value.get(account.getId())));

// 将客户ID放入账户记录的custIds列表中,忽略重复项
client.operate(null, new Key(NAMESPACE, ACCOUNT_SET, account.getId()),
    ListOperation.append(listPolicy, "custIds",
        Value.get(customer.getId())));

代码首先为操作设置 ListPolicy,以确保ID列表中没有重复项,即使同一账户多次链接到同一客户。ListWriteFlags.ADD_UNIQUE 标志确保只有当列表中不存在该项时才将其放入列表。如果该项已经存在,则会引发异常。此时,我们希望不引发异常而是静默失败,因此包含了 ListWriteFlags.NO_FAIL 标志。这样列表就相当于一个唯一值集合,最多包含每个ID的一份拷贝。

ListPolicy 设置完成后,账户ID会被放入客户记录的 accountIds 列表中。由于 append 操作的行为已通过创建 ListPolicy 设定,因此只需使用适当的参数调用 append 即可。

最后,将客户ID放入账户对象中的 custIds 列表中,操作方法与将账户ID加入 accountIds 列表的方式类似:

less 复制代码
// 将客户ID放入账户记录的custIds列表中,忽略重复项
client.operate(null, new Key(NAMESPACE, ACCOUNT_SET, account.getId()),
    ListOperation.append(listPolicy, "custIds",
        Value.get(customer.getId())));

数据库中的示例数据可能如下所示:

sql 复制代码
aql> select * from test.customers
+-----------+------------+---------------------------------------+
| firstName | lastName   | accountIds                            |
+-----------+------------+---------------------------------------+
| "Fred"    | "Black"    | LIST('["ACCT-2"]')                    |
| "Bob"     | "Smith"    | LIST('["ACCT-2", "ACCT-5", "ACCT-6"]')|
...

aql> select * from test.accts
+---------+-------------------+
| balance | custIds           |
+---------+-------------------+
| 600     | LIST('[5]')       |
| 200     | LIST('[2, 3, 5]') |
...
+---------+-------------------+

请注意,这里有两个独立的操作:一个针对客户,一个针对账户。这些记录是独立的,因此两个操作之间没有事务性,这在发生错误时可能会导致数据库状态不一致。在撰写本文时,当前版本的 Aerospike 仅支持单记录事务,但在某些情况下,通过适当的操作顺序可以规避这个问题。不过在此示例中不适用。预计 Aerospike 8 版本将支持多记录事务,从而解决此问题。

读取关联对象

现在数据库中已经建立了对象之间的关系,我们需要能够将数据读取出来。请注意,Aerospike 并不支持像关系型数据库那样的 JOIN 操作,因此需要通过一些操作来检索数据。

下面的示例假设你拥有一个客户 ID,并希望获取与该客户关联的所有账户。

示例 6-1. 检索指定客户的所有账户

ini 复制代码
public List<Account> getAccountsForCustomer(long customerId) {
    List<Account> results = new ArrayList<>();
    Record custRecord = client.get(null, 
        new Key(NAMESPACE, CUST_SET, customer.getId(), "accountIds");
    if (custRecord == null) {
        return results;
    }
    List<String> accountIds = (List<String>)
        custRecord.getList("accountIds");
    Key[] keys = new Key[accountIds.size()];
    for (int i = 0; i < accountIds.size(); i++) {
        keys[i] = new Key(NAMESPACE, ACCOUNT_SET, accountIds.get(i));
    }
    Record[] accounts = client.get(null, keys);
    for (int i = 0; i < accountIds.size(); i++) {
        Record account = accounts[i];
        results.add(new Account(accountIds.get(i),
            account.getLong("balance"),
           (List<Long>)account.getList("custIds")));
    }
    return results;
}

示例 6-1 代码解析

该代码可以分为以下几个步骤。首先,通过对客户记录执行简单的 get 操作,从客户记录中获取账户 ID 列表。不过,你只需要 accountIds 列表,而不是完整的客户记录,因此在 get 方法中指定只检索这个 bin:

vbnet 复制代码
Record custRecord = client.get(null, 
    new Key(NAMESPACE, CUST_SET, customer.getId(), "accountIds");

Aerospike 仍会读取完整的客户记录,但只会将所需的 accountIds bin 返回给应用程序。如果你已经加载了完整的客户对象,可以跳过此步骤,因为 accountIds 列表已在该对象中。

接下来,你需要遍历 accountIds 列表,为每个 ID 生成对应的账户记录键:

ini 复制代码
Key[] keys = new Key[accountIds.size()];
for (int i = 0; i < accountIds.size(); i++) {
    keys[i] = new Key(NAMESPACE, ACCOUNT_SET, accountIds.get(i));
}

这样就得到了一个键数组。接下来,只需对这些键执行批量获取 (batch get),并遍历结果,将每个 Aerospike Record 转换为相应的 Account 对象:

ini 复制代码
Record[] accounts = client.get(null, keys);
for (int i = 0; i < accountIds.size(); i++) {
    Record account = accounts[i];
    results.add(new Account(accountIds.get(i),
        account.getLong("balance"),
       (List<Long>)account.getList("custIds")));
}

这种方法可以非常高效地解决一对多关系的读取需求,而无需执行 JOIN 操作。

存储 ID

在前一节中,当你将 Aerospike 记录列表转换为 Account 对象时,你可能注意到账户余额和 custIds 列表是从 Aerospike 记录中提取的,而账户 ID 则来自于示例 6-1 中获取的 accountIds 列表。那么,为什么不直接从记录中提取 ID,就像其他 bins 一样呢?这是因为 Aerospike 默认不存储记录的主键(PK)!

如果你习惯使用关系型数据库,这可能会让你大吃一惊。毕竟数据库中的每个记录应该都需要知道自己的 PK,否则如何选择正确的记录呢?

实际上,正如第 5 章提到的那样,Aerospike 通过记录的摘要(digest)来存储和检索记录,而非实际的 PK。digest 是集合名称和 PK 的 20 字节哈希值,且在每个记录中是唯一的,因此可以作为一种 PK。

在实际应用中,你会发现大部分情况下,你是通过 PK 检索记录,而不需要数据库告诉你记录的 PK。比如在示例 6-1 中,操作从一个已知的客户 ID 开始。如果没有存储客户 ID 又该如何找到它呢?大多数应用允许用户通过姓名、出生日期等信息搜索客户,用户从客户列表中选择一个客户,而这一步实际上是基于 digest 完成的。如果应用已经提供了 customerId,则可以直接用它来检索记录。

绝大多数场景下不需要存储记录的 ID,但如果确实需要,有两种方法:

  1. 手动将 ID 显式地存储在一个单独的 bin 中。
  2. 告诉 Aerospike 自动存储 ID。

第一种方式最简单,但需要额外存储和读取一个 bin。

第二种方式需要在写入记录时设置策略 sendKey = true。例如:

less 复制代码
WritePolicy writePolicy = new WritePolicy(client.getWritePolicyDefault());
writePolicy.sendKey = true;
client.put(writePolicy, customer.getKey(), 
        new Bin("firstName", customer.getFirstName()), 
        new Bin("lastName", customer.getLastName()), 
        new Bin("accountIds", customer.getAccountIds()));

这会将 PK 和 digest 一同传递给 Aerospike 服务器。Aerospike 会存储与记录关联的 key,同时执行额外的校验以确保存储的 key 与传递的 key 一致。例如,假设键 "123" 和 "456" 在某集合中具有相同的 hash 值。若 sendKey = false,写入会成功,覆盖现有记录;但若 sendKey = true,Aerospike 会比较 "123" 和 "456",因不同而抛出异常,从而避免潜在的冲突。

此方法可以防止 digest 的哈希碰撞。尽管实际发生两不同值 hash 到同一 digest 的概率极低,但此方法增加了安全性。需要注意的是,sendKey = true 可能会引入额外的延迟,因为 Aerospike 需要在写入时从存储中检索实际 PK。

使用 AQL 查看 key_send 的行为:

sql 复制代码
aql> set key_send false
KEY_SEND = false
aql> insert into test.newSet(pk, value) values (1, 1)
OK, 1 record affected.
aql> select * from test.newSet
+-------+
| value |
+-------+
|  1    |
+-------+
1 row in set (0.173 secs)
OK
aql> set key_send true
KEY_SEND = true
aql> insert into test.newSet(pk, value) values (1, 1)
OK, 1 record affected.
aql> select * from test.newSet
+----+-------+
| PK | value |
+----+-------+
| 1  | 1     |
+----+-------+
1 row in set (0.154 secs)
OK

注意

AQL 使用 key_send 来发送 key,而 Java 使用 sendKey。AQL 默认将 key_send 设为 true,但 Aerospike 客户端默认设为 false。这通常不会成为问题,因为生产系统中的数据通常通过应用程序插入,而非 AQL。

从以上例子可以看到,当 key_send 设为 false 时,Aerospike 仅返回 value bin;但当 key_send 设为 true 后重新插入记录时,Aerospike 能返回记录的 key。

其他常见的数据建模问题

让我们来看一些常见的数据建模问题及其解决方案。与许多数据建模问题一样,这些问题可以有不止一个正确的解决方案,每种用例都会给问题带来自己特有的细微差别。

外部 ID 解析

如今,公司经常与合作伙伴共享信息,因此用内部数据创建的记录可能会使用来自外部的数据源进行补充,这种数据丰富化在广告技术领域尤为普遍。这些外部数据源也可能会请求返回相关信息,其中还包含内部来源的信息。

例如,A 公司储存了一份他们认为某人可能感兴趣的话题清单,称为"受众分段"。因此,他们可能知道 Bob 对"运动"感兴趣,并且有一条记录,key 为 1234,记录了这些信息。之后,他们从 B 公司收到一些受众分段数据,得出该数据也包含了 Bob 的受众分段信息。然而,B 公司为 Bob 使用的 key 是 6789。A 公司将 B 公司的受众分段合并到其 key 为 1234 的记录中,但他们需要知道,当 B 公司请求 key 为 6789 的数据时,他们需要返回内部记录 key 为 1234 的信息。

因此,内部 ID 1234 有时需要通过外部 ID 6789 来进行解析。显而易见的解决方法是使用一个结构类似于以下的二级索引:

PK external_id data
1234 6789 {...}

可以在 external_id 上定义二级索引,这样当提供外部 ID 时,可以执行二级索引查询,查找与传递的 ID 匹配的记录。

然而,这并不是二级索引的最佳用法。假设外部 ID 是唯一的,因此查询与外部 ID 匹配的数据将返回零条或一条记录。请记住,二级索引查询是"分散-聚合"式的,因此请求会发送到集群中的每个节点,查询每个节点上的匹配记录。客户端需要协调对多个服务器节点的请求,而每个服务器节点都会查找该外部 ID,而只有一个节点可能找到匹配记录。

更糟糕的是,随着集群规模从 10 个节点扩展到 20 个节点,集群的工作量会增加(在此例中翻倍),而返回的结果却保持不变。这种查询方式并没有扩展性,显然不是理想选择。

更好的解决方案是:设置一个集合,用于将外部 ID 映射到内部 ID,并设置另一个集合,将内部 ID 映射到数据,如图 6-6 所示。

当外部 ID 进入系统时,会执行两次键值读取操作:一次读取外部 ID 并检索内部 ID,另一次读取与该内部 ID 相关联的数据。因此,代码可能类似于:

csharp 复制代码
Record record = client.get(null, new Key("test", "extId", 6789));
if (record != null) {
    record = client.get(null, new Key("test", "data", record.getLong("internal_id")));
}

由于第 5 章介绍的 HMA(混合内存架构),Aerospike 中的键值读取操作非常高效,因此这两次读取通常会在 1 到 2 毫秒内完成,具体取决于集群运行的硬件。

如果你习惯了关系型数据库,通常会避免执行两次操作,而是倾向于使用一次更复杂的操作。然而,在 Aerospike 中,简单操作的速度和效率非常高,因此这种方法非常常见。

小对象问题

使用上述两次键值查找的技巧非常高效,能够很好地解决内外部索引问题。然而,它确实存在一个缺点:在 Aerospike 中,每条记录的每份副本需要 64 字节的主索引空间,这通常是存储在内存 (RAM) 中的。对于较大的记录来说,这通常不是问题,但这里的对象仅包含两个 ID,这意味着数据甚至可能比主索引所需的内存还小。

解决这个问题的一种方法是使用映射将多个记录合并为一个记录。这要求对键的分布有一些了解。为了举例说明,假设外部 ID 是 10 位数字,随机分布在整个范围内,并且有十亿个这样的外部 ID。

如果将每个外部 ID 到内部 ID 的映射作为一条独立的记录存储,并且 Aerospike 被配置为存储每条记录的两个副本,则集群的 RAM 需求将为:

64 字节 × 2 副本 × 1,000,000,000 条记录 ≈ 128 GB

128 GB 的内存消耗对于现代机器来说可能不算多,但要注意这只是该用例的一个小组件。这将减少系统剩余的资源,影响其他数据库记录的存储。

将这些小记录合并为一个大记录非常简单。其图示效果如图 6-7 所示。

因此,10位的外部ID不再作为记录的主键(PK),而是作为映射的键,将内部ID作为映射的值。由于十亿个条目无法装入单个映射,因此会有多个记录包含这些映射,那么问题是:给定一个外部ID,应该使用哪个PK来存储或读取该外部ID对应的映射?

解决这个问题的一个好方法是决定存储这些映射的记录大小。通常,几千字节是一个合适的大小,这样在SSD上读写不会负担过重,并且主索引RAM(64字节)和实际存储的比例相对合理。在此示例中,外部ID和内部ID的组合很小,可能大约为50字节。

因此,如果理想的记录大小约为4千字节,而每条记录为50字节,则每个映射记录中应有(4,000/50)个条目,大约80个左右。不过,如果多一点或少一点也无所谓,这是一个大致的数量级。

如果你将外部ID的后三位删除,那么123,456变为123,这会形成一个良好的唯一PK吗?这样做的确会导致多个外部ID映射到相同的PK,因为所有介于123,000和123,999之间的数字都会映射到同一个PK:123。

最坏的情况是某个映射中有一千个条目,但外部ID是随机的,因此这种情况不太可能发生。10位数的数字总共有100亿个可能值,其中使用了10亿个,因此平均每个映射大约有100个条目------仅为你除以一千所得的最大数量的一成。例如,介于123,000到123,999之间的一千个键,统计上只有大约100个会被使用。

这提供了一个非常简单的算法来保存和检索这些ID:

csharp 复制代码
private Key getKey(long externalId) {
    return new Key("test", "mapping", externalId / 1000);
}

public void saveMapping(long externalId, long internalId) {
    client.operate(null, getKey(externalId),
        MapOperation.put(MapPolicy.Default, "map", 
            Value.get(externalId), Value.get(internalId)));
}

public long getInternalId(long externalId) {
    Record record = client.operate(null, getKey(externalId),
        MapOperation.getByKey("map", 
            Value.get(externalId), MapReturnType.VALUE));
    if (record != null) {
        return record.getLong("map");
    }
    return 0;
}

该键通过将外部ID除以一千来确定。要将外部ID映射到内部ID,使用 MapOperation.put(...) 操作;要从外部ID检索内部ID,使用 MapOperation.get(...) 操作。

这个算法简单且易于使用和理解。有一个可以改进的地方:每个映射的键除了后三位以外都相同,因为记录的键是外部ID(映射键)除以一千后的值。因此,代替每次在映射中存储完整的外部ID,可以仅存储后三位。这可以优化数据库中的空间,唯一需要更改的地方是将前述代码中的 Value.get(externalId) 改为 Value.get(externalId % 1000)

如果你的键不是数字,或者随机性不够理想,那么可以使用良好的哈希算法(如RIPEMD160或类似算法),从哈希值中截取一组位,这将提供一个比预计键数量更大的数字。

Map 条目过期处理

我们要探讨的最后一个问题在多个领域中都很常见:信息存储在映射(map)中,并且信息有一个过期日期,之后这些信息将变得无用并可以删除。Aerospike 支持基于 TTL 的记录自动删除,但不支持 map 条目的自动过期。

为了实现 map 条目的过期,需要一个包含信息的 map,其中每个条目还包括该信息的过期时间。map 中的值可以是一个列表,第一个元素是过期时间,后续是其他相关信息。例如,广告技术中的受众细分就符合这一用例。对于一个特定设备(手机、平板等),我们可以存储一个包含兴趣类别的细分列表,这些类别显示出用户近期浏览过的话题,从而帮助推送相关广告以增加效果。

如果某些类别在过去 30 天内未被访问过,就可以认为它们已经失去了当前的相关性,可以从 map 中删除。一个示例记录可能如下所示:

css 复制代码
segments: {
    "CRICKET":  [1680243671492, "icc-cricket.com"],
    "DATABASE": [1680243859728, "aerospike.com"],
    "KUNG_FU":  [1680243656968, "shaolin.com"]
}

在这个例子中,该设备有三个类别,分别是 CRICKET、DATABASE 和 KUNG_FU。每个类别都有一个名称(map 键)、一个过期时间(值列表的第一个元素)以及一个来源网站(报告该兴趣类别的站点)。这是一个简化的示例,仅作示范用途。

我们需要对这些数据执行三个操作:

  1. 检索未过期的条目(即过期日期在将来的条目)
  2. 添加带有过期日期的新条目
  3. 删除已过期的条目

检索有效条目

我们可以使用 getByValueRange 操作来获取过期时间在未来的条目,类似于之前章节中的信用卡交易列表的查询方式:

vbnet 复制代码
Date now = new Date().getTime();
Record record = client.operate(writePolicy, key,
     MapOperation.getByValueRange("segments", 
        Value.get(Arrays.asList(now)), Value.INFINITY, 
        MapReturnType.KEY));

getByValueRange 调用的参数包括 bin 名称("segments")、开始时间(当前时间 now 作为列表)、结束时间(INFINITY)和返回的信息(此处为键,即类别名称)。如果类别有效,其过期时间将在未来,这些参数会选择出这些条目。对于当前数据集,结果会是包含 [CRICKET, DATABASE, KUNG_FU] 的列表。

添加新条目并删除过期条目

添加新条目只需要构造包含过期时间的列表并将条目插入 map。以下是代码:

vbnet 复制代码
long now = new Date().getTime();
long expiryMs = now + MS_IN_30_DAYS; // 增加30天
List<Object> data = Arrays.asList(expiryMs, "pi.com");
client.operate(writePolicy, key,
    MapOperation.removeByValueRange("segments", 
        Value.get(Arrays.asList(0)),
        Value.get(Arrays.asList(now)), 
        MapReturnType.NONE),
    MapOperation.put(MapPolicy.Default, "segments",
        Value.get("ELECTRONICS"), Value.get(data)));

注意这里的 operate 调用包含两个操作:一个是通过 MapOperation.put 添加新的条目(一个对象列表)到 map,另一个是通过 MapOperation.removeByValueRange 删除任何过期的条目。removeByValueRange 首先执行,丢弃任何时间戳在过去范围内的元素。由于存储的是过期时间戳,时间在过去的条目即被视为已过期。

删除操作先于添加操作执行,这样可以在同一时间删除旧条目而不额外消耗存储访问量。要注意的是,removeByValueRange 操作会对 map 进行排序,如果该 map 是 KEY_ORDERED 类型,这可能会产生一定的 CPU 开销。

如果调用频率适中,这些额外的 CPU 开销通常不会带来显著影响,因为 Aerospike 一般对 CPU 的负载较低,数据库节点通常有空余的 CPU 资源。然而,如果调用频率很高,这可能成为一个问题。以下是两个简单的优化解决方案:

  1. 在 Aerospike 版本 7 中,有可能让 KEY_VALUE_ORDERED 类型的 map 将值排序索引随记录一起保存到存储中,这样可以消除完全排序的 CPU 开销,但会增加存储空间需求。
  2. 不在每次调用时都执行 removeByValueRange,而是将它延迟到后台查询操作中,以受控的每秒请求速率进行,降低对集群的影响。如果 map 中的条目额外存在一段过期时间,这不会影响其他操作的功能。

总结

数据建模在高效使用任何数据库中都至关重要,Aerospike 也不例外。要确保设计出良好的数据模型,以解决业务问题。不仅要使数据模型具备良好的性能,还应尽可能减少硬件资源的需求。

与大多数数据建模问题一样,同一个问题往往有多种解决方法。本章介绍了一些解决业务问题的基础技术,但并不全面。若要深入探讨在 Aerospike 中的数据建模,可能需要另一本专著。不过,希望这些基础内容能帮助您开始为自己的用例构建数据模型。

在下一章中,您将开始学习如何配置和管理 Aerospike。

相关推荐
Zilliz Planet24 分钟前
GenAI 生态系统现状:不止大语言模型和向量数据库
数据库·人工智能·语言模型·自然语言处理
爱奇艺技术产品团队33 分钟前
爱奇艺大数据多 AZ 统一调度架构
大数据·架构
瓜牛_gn1 小时前
redis详细教程(4.GEO,bitfield,Stream)
数据库·redis·缓存
练习两年半的工程师1 小时前
建立一个简单的todo应用程序(前端React;后端FastAPI;数据库MongoDB)
前端·数据库·react.js·fastapi
新知图书2 小时前
MySQL 9从入门到性能优化-创建触发器
数据库·mysql·性能优化
HEX9CF2 小时前
【SQLite】改善默认输出格式不直观难以阅读问题:通过修改输出设置提升数据可读性
数据库·sqlite
丶21362 小时前
【云原生】云原生后端详解:架构与实践
后端·云原生·架构
HEX9CF3 小时前
【Linux】SQLite 数据库安装教程(Ubuntu 22.04)
linux·数据库·sqlite
恬淡虚无真气从之3 小时前
django中entity.save(using=)的使用
数据库·python·django
零希3 小时前
正则表达式
java·数据库·mysql