简介
在这篇文章中,我们将看到为什么YugabyteDB提供的默认强一致性保证让你设计的应用程序比使用传统的关系型数据库系统时更有弹性。
导致破产的竞赛条件
正如我在这篇文章中所解释的,Flexycoin破产是因为一个数据异常被黑客利用了。
现在,复制这样的竞赛条件实际上是非常容易的。我们只需要创建一个TransferService 接口和实现以及一个AccountRepository ,如下图所示。

TransferServiceImpl 像这样实现了transfer 方法。
@Service
public class TransferServiceImpl implements TransferService {
@Autowired
private AccountRepository accountRepository;
@Transactional
public boolean transfer(String fromIban, String toIban, long cents) {
boolean status = true;
long fromBalance = accountRepository.getBalance(fromIban);
if(fromBalance >= cents) {
status &= accountRepository.addBalance(
fromIban,
(-1) * cents
) > 0;
status &= accountRepository.addBalance(
toIban,
cents
) > 0;
}
return status;
}
}
而AccountRepository 使用最简单的可能的SQL语句定义了getBalance 和addBalance 方法。
@Repository
@Transactional(readOnly = true)
public interface AccountRepository
extends JpaRepository<Account, String> {
@Query(value = """
SELECT balance
FROM account
WHERE iban = :iban
""",
nativeQuery = true)
long getBalance(@Param("iban") String iban);
@Query(value = """
UPDATE account
SET balance = balance + :cents
WHERE iban = :iban
""",
nativeQuery = true)
@Modifying
@Transactional
int addBalance(@Param("iban") String iban, @Param("cents") long cents);
}
而且,当在PostgreSQL上使用多个并发线程在两个账户之间运行一个transfer 。
assertEquals(10L, accountRepository.getBalance("Alice-123"));
assertEquals(0L, accountRepository.getBalance("Bob-456"));
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
startLatch.await();
transferService.transfer("Alice-123", "Bob-456", 5L);
} catch (Exception e) {
LOGGER.error("Transfer failed", e);
} finally {
endLatch.countDown();
}
}).start();
}
LOGGER.info("Starting threads");
startLatch.countDown();
endLatch.await();
LOGGER.info("Alice's balance: {}", accountRepository.getBalance("Alice-123"));
LOGGER.info("Bob's balance: {}", accountRepository.getBalance("Bob-456"));
我们将在最后得到以下账户余额。
Alice's balance: -35
Bob's balance: 45
而且,无论我们使用的是PostgreSQL、MySQL、Oracle还是SQL Server,都没有关系。我们总是会得到这种异常现象,因为这些关系数据库中每一个的默认隔离级别都不能防止丢失更新现象。
YugabyteDB的默认强一致性
然而,如果我们在YugabyteDB上运行同样的测试案例,我们会得到以下结果。
Alice's balance: 0
Bob's balance: 10
为什么在YugabyteDB上运行得很好,而在PostgreSQL、MySQL、Oracle或SQL Server上的并发传输却失败了呢?
而答案是由Yugabyte使用的默认隔离级别给出的,它是快照隔离。
在使用MVCC(多版本并发控制)时,快照隔离是一个非常流行的隔离级别,因为它允许事务在每个事务开始时查看数据库,因此可以防止不可重复读取、幻影读取或读取偏差。
这就是为什么许多关系型数据库系统使用快照隔离的原因。例如,PostgreSQL中的可重复读取隔离级别基本上就是快照隔离。
然而,快照隔离也可以防止丢失更新,这就是为什么我们在使用默认的Yugabyte隔离级别时不会出现这种异常情况。
事实上,我们可以使用YugabyteDBPhenomenaTest 集成测试轻松地验证Yugabyte在改变事务隔离级别时的表现。
而且,我们得到以下结果。
| Isolation level | Dirty Read | Non-Repeatable Read | Phantom Read | Read Skew | Write Skew | Lost Update |
|------------------|------------|---------------------|--------------|-----------|------------|-------------|
| Read Uncommitted | No | No | No | No | Yes | No |
| Read Committed | No | No | No | No | Yes | No |
| Repeatable Read | No | No | No | No | Yes | No |
| Serializable | No | No | No | No | No | No |
你可以这样来读这个表。
Yes表示该异常情况在该特定的隔离级别中是允许的No表示该异常情况被该特定隔离级别所阻止
默认情况下,SQL标准的Repeatable Read、Read Committed和Read Uncommitted隔离级别被映射到Snapshot Isolation,除了Write Skew异常,几乎可以防止所有的异常,而Serializable隔离级别可以防止所有的异常。
YugabyteDB默认使用更强的隔离级别的原因是,当执行分布在多个分片上的SQL语句时,它无法承受不一致的情况。
通过使用RAFT共识协议来同步集群节点之间的变化,快照隔离成为正确的选择。而且,由于快照隔离是一种乐观的并发控制机制,这使得YugabyteDB能够在性能和一致性之间找到正确的平衡。
事实上,正如文档中所解释的,读承诺隔离级别要求你将yb_enable_read_committed_isolation 设置为true ,以便使用它。
如果你想启用yb_enable_read_committed_isolation 的设置,你可以在启动Docker容器时这样做。
docker run
-d --name yugabyte \
-p7000:7000 -p9000:9000 -p5433:5433 -p9042:9042 \
yugabytedb/yugabyte:2.15.1.0-b175 bin/yugabyted start \
--daemon=false --ui=false \
--tserver_flags="yb_enable_read_committed_isolation=true"
而且,当重新运行YugabyteDBPhenomenaTest ,我们得到以下输出。
| Isolation level | Dirty Read | Non-Repeatable Read | Phantom Read | Read Skew | Write Skew | Lost Update |
|------------------|------------|---------------------|--------------|-----------|------------|-------------|
| Read Uncommitted | No | Yes | Yes | Yes | Yes | Yes |
| Read Uncommitted | No | Yes | Yes | Yes | Yes | Yes |
| Repeatable Read | No | No | No | No | Yes | No |
| Serializable | No | No | No | No | No | No |
虽然最初不支持,但YugabyteDB决定支持读承诺,以提高与PostgreSQL的兼容性,后者默认使用读承诺。由于快照隔离(可重复读取)增加了冲突的可能性,应用程序的行为可能会改变,因为在数据异常的情况下,更多的事务将不得不回滚。
拥有一个 "已读承诺 "的隔离级别允许YugabyteDB在默认的 "已读承诺 "模式下运行时表现得像PostgreSQL、Oracle或SQL Server,因此使迁移到YugabyteDB的过程非常简单,不需要改变应用程序的数据访问逻辑。
任何高于 "已提交 "的隔离级别都需要应用程序用额外的重试逻辑来处理序列化错误(例如,
SQLSTATE 40001)。由于这个原因,在不改变代码的情况下,需要读承诺来迁移应用程序。
YugabyteDB确实是一个真正的MVCC数据库
现在,在阅读YugabyteDB文档时,引起我注意的是,Read Committed隔离级别是使用悲观锁实现的,这让我很好奇,因为大多数关系型数据库默认使用MVCC。
在深入研究之后,我意识到YugabyteDB在防止Dirty Writes方面的方法与我测试过的所有关系型数据库系统完全不同。如果允许两个并发的事务同时修改同一记录,就会发生Dirty Write异常,这是一个非常危险的异常,因为它可能会危及Atomicity(ACID中的A),因为回滚将不允许数据库返回到之前的一致性状态,而数据库在事务开始时处于这种状态。
即使Oracle、PostgreSQL或MySQL使用MVCC,而不是SQL Server默认使用的2PL(两阶段锁定)机制,也只允许一个事务改变一个给定的记录,这意味着每当一个事务对一个给定的表行发出UPDATE或DELETE,就会获得一个写入或独占锁。
这就是YugabyteDB所说的,它使用悲观锁实现了读承诺隔离级别的意思。当使用Read Committed时,YugabyteDB也会锁定修改的记录,直到事务提交或回滚,就像PostgreSQL、Oracle、MySQL或SQL Server一样。
事实上,我实际上可以用我的AbstractPhenomenaTest 来验证这种行为,因为我必须改变Dirty Write异常检测来支持MVCC。由于其分布式的性质,YugabyteDB被要求尽可能减少锁的使用,这就是为什么其默认的快照隔离级别能够使用乐观的锁来检测所有的异常,甚至对于Dirty Writes。
至少可以说是令人印象深刻的!我甚至不知道这是有可能的。我一直认为Oracle、SQL Server、PostgreSQL或MySQL的行为是理所当然的,认为除非你锁定了你所修改的记录,否则你不可能有原子性。那么,你实际上可以使用乐观锁来实现,从而提高可扩展性。
结论
YugabyteDB使用的一致性模型比大多数关系型数据库系统要强得多。当Oracle、PostgreSQL和SQL Server默认使用读提交时,YugabyteDB更倾向于快照隔离模式,这是一个强隔离级别。
默认情况下使用更强的隔离级别,使得数据库应用的实现更加容易,因为你应该明确预防的数据完整性问题更少。
并发控制问题是很难推理的。甚至谷歌的开发人员在开发所有需要数据完整性的谷歌服务时,也最好使用更强的一致性模型。