该博客文章介绍如何在Aerospike应用中实现事务语义。由于Aerospike事务的单一记录范围和某些集群转换,某些情况下的处理方式必须不同于您可能熟悉的传统数据库。具体来说,该帖子讨论:

  • 如何处理常见的读写事务
  • 如何解决由于网络或节点故障而导致的分区更改“不确定(in-doubt)”事务。

同一个数据库中不同的数据

作为开发人员,您需要在正确性和可用性范围内存储和处理不同类型的数据。对于一类数据,一致性至关重要。例如,金融应用程序必须准确地保存记录,账户登录必须使用最新的密码,以及在解除好友连接后必须立即停止社交网络上的共享(类似白名单)。数据库事务为此类数据提供ACID保证:

  • Atomicity(原子性):一个事务要么成功要么失败。一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被恢复(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
  • Consistency(一致性):一个事务始终使数据库处于一致状态。在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。
  • Isolation(隔离性):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。
  • Durability(持久性):在电源故障或者节点故障时能保留数据。事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

在分布式和多副本数据库中,事务更新还应保证所有副本中值的一致性。

对于另一类数据,您可以决定优先考虑可用性而不是一致性。例如,电子商务应用程序中的产品推荐、广告服务平台中的广告选择以及支付应用程序中的欺诈风险评分,可能会以合理的准确性重视可用性,而不是基础数据的绝对新近度和正确性。

企业花费大量精力来设计、开发和运营针对此类多样化数据需求的多个数据库。幸运的是,Aerospike 以可预测的毫秒级性能、PB 级规模和高弹性扩展处理了可用性和一致性范围内的各种数据访问。Aerospike 还消除了对前端缓存的需求,这些缓存通常需要满足现代应用程序的延迟和吞吐量要求,这使设计和部署更加复杂。

强一致性 (SC) 和可用行 (AP) 模式

Aerospike 是一个分布式键值数据库(支持面向文档的数据模型),它自动在具有所需复制因子的节点集群中对数据进行分区(分片)。数据作为Records(Rows)存储在 Aerospike 中,使用唯一键访问。一条记录可以包含一个或多个不同数据类型的bins(Columns)

根据著名的CAP 定理,如果数据库集群能够容忍网络分区(继续运行),则必须在一致性和可用性之间做出选择。Aerospike 通过允许在 SC(强一致性,CAP 中的 CP 的同义词)或 AP(分区期间可用性)模式下配置namespaces(databases),从而提供一致性和可用性模式。需要注意的是,CAP 定理中的 C 是指保持副本之间的一致性,而 ACID 中的 C 是指强制完整性约束。

Aerospike 中的所有单条记录请求都是原子的。AP 模式具有典型 NoSQL 数据库提供的“最终一致性”保证。但是,在 AP 模式下:

  • 所有副本的记录可能始终不一致
  • 读取有时可能会访问陈旧的数据
  • 偶尔写入可能会丢失。

虽然这听起来不可取,但需要在 CAP 上进行权衡。由于数据丢失和过时读取的可能性非常罕见,因此许多应用程序可能能够证明在一致性方面进行更高可用性的权衡是合理的,并选择 AP 模式。

然而,许多应用程序要求绝对正确性,在这种情况下,为了确保读取只获得最新的已提交值(没有陈旧或脏/未提交的读取)并且没有丢失已提交的数据(持久提交),Aerospike 提供了 SC 模式,以保证强一致性。在这种模式下,“线性化”读取在副本之间始终是一致的。Aerospike 的强一致性支持已通过Jepsen 测试得到独立证实。

Aerospike 的事务

在 Aerospike 中,所有单条记录操作都有事务保证。每一个记录请求,包括对一个或多个bin(column)包含多个操作的请求,都在具有隔离性和持久性的记录锁下原子执行,并确保所有副本都是一致的。

Aerospike 事务不跨越多个记录边界。应用程序可以利用 Aerospike 的丰富数据模型消除或最小化对多记录事务的需求。通过适当的数据建模,可以避免需要连接和约束的多记录事务。例如,关系数据库中多个表中的数据可以建模为单个非规范化记录。因此,像 Begin、Commit、Abort 这样的多操作事务控制操作不是 Aerospike API 的一部分。

实现读写事务

Aerospike 允许在单个事务中对同一个键进行多次读/写操作。然而,Aerospike 中的写入是一个简单的(例如,设置、添加、追加)操作,并且不能是记录中一个或多个 bin 的任意函数。换句话说,复杂的更新逻辑不能被发送到服务器以在事务中执行。这种 Aerospike 设计使常见的读取操作保持简单和可预测的快速,并将不太频繁的读写事务的任何增加的复杂性推迟到应用程序。

Aerospike 有两个用于一般读写事务的选项:

  1. 在应用程序的客户端使用 Record Read-Modify-Write(或 Check-and-Set)模式。
  2. 在服务端的用户定义函数 (UDF) 中实现更新逻辑。UDF 类似于数据库中的存储过程,必须由管理员安装,然后才能通过 API 调用。

UDF 在 Lua 中实现并驻留在服务器上,执行、开发、测试和更改的速度较慢。因此,它们不是最适合应用程序中的大多数读写事务。虽然它们是许多情况下的首选解决方案,但本文将重点介绍第一个替代方案,即 RMW 方法

RMW 方法利用记录的“generation”元数据,该元数据本质上是一个计数器,在记录上的每次写入操作时都会递增。这个好理解,generation本来意思是代,传宗接代的“代”,也就是数据版本的概念。RMW读写事务的模式如下:

  1. 先去读取记录。
  2. 在应用程序中修改记录。
  3. 将写入策略设置为 GEN_EQUAL,仅当读取记录的代数匹配时,写入才会成功。
  4. 写入修改后的数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# R-M-W pattern pseudo-code for a read-write transaction


Result RMWTransaction(key, read_bins, update_func):
# key: key tuple (namespace, set, id)
# read_bins: list of bins to read
# update_func: update function that takes a map read_bins->
# values and returns a map write_bins->values
# Result: return enum that includes Success and Retry


# 1. Read the record
read_policy = [{'read_mode_sc': SC_LINEARIZE}]
record = Aerospike.get(read_policy, key, read_bins)
read_generation = record.generation


# 2. Modify the record
read_bin_vals = {}
for read_bin in read_bins:
read_bin_vals[read_bin] = record[read_bin]
write_bin_vals = update_func(read_bin_vals)


# 3. Prepare write with generation comparison and no retries.
write_policy = { 'generation_policy': GEN_EQUAL,
'generation': read_generation,
'max_retries': 0 }


# 4. Write the modified data
ops = []
for write_bin, write_val in write_bin_vals:
ops.append({'op': WRITE, 'bin': write_bin, 'val': write_val})
try:
Aerospike.operate(write_policy, key, ops)
catch GENERATION_ERROR:
# retry on generation error
return Retry


return Success

在 RMW 方法中,我们通过仅在生成等于读取时的生成时才执行写入来确保记录不变。如果有临时更改,GEN_EQUAL 条件将失败,写入也会失败,并且可能会重试整个 RMW 模式。

代替以记录的生成元数据为条件进行写入,可以使用Predicate Filter以读取值为条件进行写入。在这种情况下,只有在先前读取的值没有改变的情况下,写入才会成功。

解决有疑问的交易

应用程序必须知道事务成功或失败,以便它可以进行任何必要的调整。如果事务失败,应用程序可以采取适当的步骤来重试事务(或具有更高的逻辑,包括手动干预以采取适当的行动)。

在集群转换期间(集群分裂时)的极少数情况下,无法立即知道事务是成功还是失败。在这种情况下,写入事务超时,异常对象中的“InDoubt”标志设置为真,反映了仍然未知的结果。作为自动集群恢复过程的一部分,当受影响的数据分区再次活跃时,可以解决有问题的事务。

当网络处于分裂中时,事务的结果可能是未知的,当少数子集群中接收到写入时,并且在知道所有副本都已成功更新之前另一个子集群变得不可访问。写入的确切结果无法立即获得成功或失败。受影响的分区需要在恢复后变为活动且可访问,以解决写入的结果。

有疑问的请求将超时。没有内置的方法来解决不确定的事务,因此客户端必须设计自己的方案来找出写入的结果。同样,这符合 Aerospike 的设计理念,即为了使常见操作保持简单和可预测的快速,它将推迟应用程序的任何增加的复杂性以处理罕见的情况。

解决不确定写入的典型方法是在记录中原子地记录事务 ID(客户端生成的全局唯一 ID)以及通过多操作请求进行的更新。在一个“txns”bin 的记录中维护成功事务 ID 列表,因为每次写入都会通过原子多操作请求将事务 ID 添加到列表中。应用程序可以通过检查事务 ID 是否在列表中来确定不确定写入是否成功。txns 列表通过将其修剪到特定大小来防止无限增长。

以下伪代码描述了一种解决不确定事务的方法。它要求记录有一个包含最后 MAX_TXNS 事务 ID 的“txns”列表bin。

  1. 读取记录。
  2. 使用operateGEN_EQUAL 策略写入记录,以将读记录的generation与这些操作相匹配。
  3. 处理结果:如果交易成功则返回Success;如果它因生成错误而失败,则返回 Retry;如果超时且不确定,则解决不确定事务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# R-M-W With In-Doubt Handling pseudo-code


Result RMWTransactionHandleInDoubt(key, read_bins, update_func, transaction_id):
# transaction_id: a globally unique ID generated by the caller


# 1. Read the record
read_policy = [{'read_mode_sc': SC_LINEARIZE}]
record = Aerospike.get(readPolicy, key, read_bins)
read_generation = record.generation
# get update values
read_bin_vals = {}
for read_bin in read_bins:
read_bin_vals[read_bin] = record[read_bin]
write_bin_vals = update_func(read_bin_vals)


#2. Write, prepend transaction-id to list, and trim the list.
# Prepare write with policy: generation comparison and no retries
write_policy = {'generation_policy': GEN_EQUAL,
'generation': read_generation,
'max_retries': 0 }
ops = []
for write_bin, write_val in write_bin_vals:
ops.append({'op': WRITE, 'bin': write_bin, 'val': write_val})
txns = 'txns'
ops.append([{'op': PREPEND, 'bin': 'txns', 'val': transaction_id},
{'op': TRIM, 'bin': 'txns', 'index': 0, value: MAX_TXNS} ]
try:
Aerospike.operate(write_policy, key, ops)


# 3. Handle generation-error, in-doubt, and timeout errors.
catch GENERATION_ERROR:
return Retry
catch TIMEOUT as e:
# check for in-doubt
if e.args[4] == 'InDoubt':
return ResolveInDoubt(key, read_generation, transaction_id)
# timed out but not in-doubt; retry
return Retry


return Success

可以通过等待分区恢复并检查 txns 列表中是否存在事务 ID 来解决有问题的事务。下面是轮询读取的逻辑和伪代码。请注意,生成必须经过原始读取生成,以确保原始写入不会仍在服务器上“进行中”。在合适的经过时间之后,如果代没有移动超过原始读取代,则可以发出触摸操作(它仅增加代而不修改记录)并进行 gen-equal 检查以强制代超过原始值。成功的触摸操作将确认写入失败或确保它会失败。否则,写入仍然存在疑问,必须在更高级别解决(包括通过手动干预)。

  1. 轮询记录直到超时或成功。
    1. 读取 txns,同时记录生成仍然与读取生成相同,并且总等待时间小于 RESOLVE_TIMEOUT。
    2. 如果交易ID在txns列表中,则交易肯定成功;返回成功。否则,事务肯定失败,可能会被重试。
  2. 如果记录生成仍然等于读取生成,请使用 gen-equal check 触摸记录以尝试强制失败并重试。
    1. 如果 touch 因生成错误而失败,请阅读 txns 并按照 1b 中的方法解决。
    2. 如果触摸因任何其他错误而失败,则返回 InDoubt 以对不确定事务进行带外解析。
    3. 如果 touch 成功,则原始事务已经失败或将失败(因为它是使用相同的 gen-equal 检查发出的),并且可以安全地重试。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# ResolveInDoubt pseudo-code


Result ResolveInDoubt(key, read_generation, transaction_id):
# 1. Poll the record until RESOLVE_TIMEOUT.
timer = 0
# 1a
while timer <= RESOLVE_TIMEOUT:
start_time = time()
read_policy = [{'read_mode_sc': SC_LINEARIZE}]
bins = ['txns']
record = Aerospike.get(read_policy, key, bins)
generation = record.generation
if generation > read_generation:
# 1b
if transaction_id in record['txns']:
# transaction succeeded
return Success
else:
# transaction failed
return Retry
wait(POLL_INTERVAL)
timer += time() - start_time


# 2. Touch the record to force failure and possible retry.
touch_policy = {'generation_policy': GEN_EQUAL,
'generation': read_generation,
'max_retries': 0 }
try:
ops = [{'op': TOUCH}]
Aerospike.operate(touch_policy, key, ops)
catch GENERATION_ERROR:
# 2a. Read txns and resolve
record = Aerospike.get(read_policy, key, bins)
if transaction_id in record['txns']:
return Success
else:
return Retry
catch TIMEOUT::
# 2b. touch failed, in-doubt must be resolved at a higher level
return InDoubt
# 2c. touch succeeded, retry
return Retry

以下是在 Aerospike 中实现事务时需要记住的一些额外事项:

  • 重试策略必须在写入请求上设置为“不重试”,这样它就不会在超时时重复,因为超时(不确定)事务可能会成功。为此(如上所示),请使用将 max_retries 设置为零的写入策略。
  • 所有更新都同步写入所有副本,每个副本异步将其写入缓冲区刷新到单个块中的持久存储以提高效率。为了提高持久性,开发人员可以指示 Aerospike 使用 commit-to-device 配置在每个事务的基础上将每次写入提交到磁盘。

例子

以下简单示例显示了使用 RMW 函数进行读写事务的调用方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Example to illustrate a read-write transaction


# update function
WriteBinVals UpdateCoinToss(read_bin_vals):
# Takes old values of last coin toss (head or tail) and streak (consecutive runs of it) and returns
# new values.
# read_bin_vals: a map containing current values of 'last_toss' and 'streak'
# WriteBinVals: returns a map containing new values of 'last_toss' and 'streak'
new_toss = random('head', 'tail')
new_streak = 1
if new_toss == read_bin_vals['last_toss']:
new_streak = read_bin_vals['streak'] + 1
return {'last_toss': new_toss, 'streak': new_streak}


# RMW caller
Result ReadWriteTransactionExample():
# get a globally unique id
transaction_id = GetUniqueID()
key = ('RMW', 'test', 'coin_toss')
read_bins = ['last_toss', 'streak']
retries = 0
while retries <= MAX_RETRIES:
try:
result = RMWTransactionHandleInDoubt(key, read_bins, UpdateCoinToss, transaction_id)
catch e:
log('unexpected exception: {e} in transaction {transaction_id}, key: {key}')
throw e
retries += 1
if result == Success:
break
if result == Retry:
continue
if result == InDoubt:
log('in-doubt transaction {transaction_id}, key: {key}')
break
If result == Retry:
result = Fail
return result

选择读取一致性

Aerospike 在读取策略中具有读取一致性级别的客户端控制。客户端级别的默认值适用于所有读取,除非指定了每个事务的设置,在这种情况下它优先。这些包括:

  • 可线性化或全局一致,
  • 会话一致,即与客户端一致,并且
  • 额外的宽松一致性模式

请注意,虽然 API 提供了与持久性相关的客户端写入策略,但它们仅适用于 AP 模式,并且在 SC 模式中被忽略。

会话一致读取比线性化读取更快,因为它们避免了主节点与副本检查的“制度”(即集群形成版本)的额外往返。提高速度的代价是在网络分区转换期间偶尔会出现读取旧数据。请注意,应用程序不需要做任何事情来避免会话一致模式下的陈旧读取,因为驱动程序库会自动检测并避免陈旧的读取,方法是跟踪分区的最高已知机制,如果它来自较低的regime则重新读取。

SC 模式下的读取可能由于各种原因而失败,应用程序应该适当地处理失败,通常通过重试。在某些情况下,读取可能会失败,因为它无法返回一致的(最新提交的)值,因为集群已拆分,并且连接的子集群没有可用的有效分区副本。

结论

作为一名应用程序开发人员,您将需要处理需要在分布式集群中的所有副本之间保持一致性的数据。Aersopike的强一致性(SC)模式为单记录操作提供了如此强大的一致性和其他事务性保证,同时又不放弃速度、吞吐量和恢复能力。Aerospike还允许应用程序通过AP模式选择其他数据的可用性而非一致性。SC与AP模式的选择是在服务器端的名称空间(数据库)级别进行的,它控制名称空间中所有数据的事务语义。大多数读写事务都需要读-修改-写模式。在集群转换期间,事务的结果可能是不确定的,并且不能立即知道。在这种情况下,应用程序需要实现一个记录和检查事务id的方案,以确定事务要么成功要么失败。