Using Software Transactional Memory in Quarkus

软件事务内存 (STM) 自 1990 年代后期以来就在研究环境中出现,最近才开始出现在产品和各种编程语言中。我们不会深入探讨 STM 背后的所有细节,但有兴趣的读者可以查看 this paper。然而,足以说的是,STM 提供了一种在高度并发的环境中开发事务应用程序的方法,具有某些与 ACID 事务相同特性,而你可能已经通过 JTA 使用 ACID 事务。但重要的是,持久性属性在 STM 实现中被放宽(删除),或者至少使其成为可选项。这与 JTA 的情况不同,其中状态更改对关系数据库持久化,并支持 the X/Open XA standard。 请注意,Quarkus 提供的 STM 实现基于 Narayana STM实现。该文档并非打算替代该项目的文档,所以你可能需要查看它以了解更多详情。不过,我们将更专注于在开发 Kubernetes 原生应用程序和微服务时,如何将某些关键功能组合到 Quarkus 中。

Why use STM with Quarkus?

现在你仍然可能会问自己“为什么是 STM,而不是 JTA?”或“STM 有哪些好处,而 JTA 没有?”让我们尝试回答这些问题或类似问题,特别关注我们认为它们非常适合 Quarkus、微服务和 Kubernetes 原生应用程序的原因。所以没有特定的顺序……

  • STM 的目标是简化来自多个线程的对象读取和写入/保护状态,免受并发更新的影响。Quarkus STM 实现将使用已选择用于保护特定状态实例(Quarkus 中的对象)的任何隔离模型,安全地管理这些线程之间的任何冲突。在 Quarkus STM 中,有两个隔离实现,悲观的(默认),会导致发生冲突的线程被阻塞,直到原始线程完成更新(提交或中止事务);然后是乐观方法,允许所有线程进行处理,并在提交时检查冲突,其中一个或多个线程可能在发生冲突更新时被迫中止。

  • STM 对象具有状态,但它不必是持久性(耐久性)的。事实上,默认行为是将事务内存中管理的对象设置为可变的,例如,如果它们正在使用的服务或微服务崩溃或在其他地方生成,例如,由计划程序生成,则内存中的所有状态都将丢失,并且对象将从头开始。但是你肯定可以从 JTA(和一个合适的交易数据存储)中获取这些内容和更多内容,而不用担心重新启动你的应用程序吗?不完全是。这里有一个权衡:我们正在消除持久状态和在每次事务中从数据存储中读取然后写入(和同步)的开销。这使得对(可变)状态的更新非常快,但你仍然可以获得跨多个 STM 对象(例如,你的团队编写的对象然后调用你从另一个团队继承的对象,并要求它们进行全有或全无的更新)的原子更新的好处,以及在并发线程/用户(分布式微服务架构中常见)的情况下具有一致性和隔离性。此外,并非所有有状态应用程序都需要持久性——即使使用 JTA 事务,它也往往是例外,而不是规则。正如你稍后将看到的,由于应用程序可以有选择地启动和控制事务,因此可以构建可以撤消状态更改并尝试替代路径的微服务。

  • STM 的另一个好处是可组合性和模块化。你可以编写并发的 Quarkus 对象/服务,可以轻松地与使用 STM 构建的任何其他服务组合,而无需公开对象/服务的实现详细信息。正如我们之前讨论的,这种将你编写的对象与其他团队可能在几周、几个月或几年前编写的对象组成的能力,并且具有 A、C 和 I 属性可能非常有益。此外,包括 Quarkus 使用的实现,某些 STM 实现支持嵌套事务,它们允许在嵌套(子)事务的上下文中所做的更改稍后由父事务回滚。

  • 尽管 STM 对象状态的默认值为可变,但可以配置 STM 实现,使得对象的持久性状态。虽然可以配置 Narayana 以便可以使用包括关系数据库在内的不同后端数据存储,但是默认值是本地操作系统文件系统,这意味着你不需要使用 Quarkus 配置任何其他内容,例如数据库。

  • 许多 STM 实现允许将“纯粹的老式语言对象”变成 STM 感知对象,而应用程序代码几乎或完全不需要更改。你可以构建、测试和部署应用程序,不需要它们具有 STM 感知能力,然后稍后再添加那些功能,如果它们变得必要,并且几乎没有任何开发开销。

Building STM applications

快速启动中还有一个完整的工作示例,你可以通过克隆 Git 存储库:git clone $${quickstarts-base-url}.git,或下载一个 $${quickstarts-base-url}/archive/main.zip[存档]来访问它。寻找 `software-transactional-memory-quickstart`示例。这将有助于你了解如何使用 Quarkus 构建 STM 感知应用程序。然而,在我们这样做之前,有一些基本概念我们需要涵盖。

请注意,如你所见,Quarkus 中的 STM 依赖于很多注释来定义行为。缺少这些注释会导致默认情况下假设明智默认值,但开发人员必须了解这些默认值是什么非常重要。请参阅 Narayana STM manualSTM annotations guide,以更详细地了解 Narayana STM 提供的所有注释。

该技术被认为是 {extension-status}。 有关可能状态的完整列表,请查看我们的 FAQ entry.

Setting it up

要使用扩展,请将其作为依赖项包含在你的构建文件中:

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-narayana-stm</artifactId>
</dependency>
build.gradle
implementation("io.quarkus:quarkus-narayana-stm")

Defining STM-aware classes

为了让 STM 子系统了解哪些类将在事务内存的上下文中被管理,必须提供最低程度的检测。这是通过接口边界对 STM 感知类和 STM unaware 类进行分类来实现的;具体地说,所有 STM 感知对象必须是继承自已被注释为 STM 感知类的接口的类的实例。不遵循此规则的任何其他对象(及其类)将不会被 STM 子系统管理,因此,它们的任何状态更改都不会被回滚。

STM 及其感知应用程序接口必须使用的特定注释是 org.jboss.stm.annotations.Transactional。例如:

@Transactional
public interface FlightService {
    int getNumberOfBookings();
    void makeBooking(String details);
}

实现此接口的类可以使用 Narayana 中的其他注释来告知 STM 子系统,例如方法是否将修改对象的 state,或者类中的哪些 state 变量应通过事务进行管理(例如某些实例变量可能不必在事务中止时回滚)。如前所述,如果这些注释不存在,则会选择默认值以确保安全性,例如假设所有方法都将修改 state。

public class FlightServiceImpl implements FlightService {
    @ReadLock
    public int getNumberOfBookings() { ... }
    public void makeBooking(String details) {...}

    @NotState
    private int timesCalled;
}

例如,通过对 getNumberOfBookings 方法使用 @ReadLock 注释,我们能够告诉 STM 子系统,在事务性存储器中使用此对象时,将不会发生任何 state 修改。另外,@NotState 注释告诉系统在事务提交或中止时忽略 timesCalled,因此此值仅因应用程序代码而改变。

请参阅 Narayana 指南,了解如何对标记有 @Transactional 注释的接口实现对象的交易行为进行更精细的控制。

Creating STM objects

需要告知 STM 子系统它应该管理哪些对象。Quarkus(又称 Narayana)STM 实现通过提供这些对象实例驻留的事务性存储器容器来实现此目的。在对象被放置在其中一个 STM 容器中之前,无法在事务中管理它,并且任何 state 更改都不会具备 A、C、I(甚至 D)属性。

请注意,“容器”一词是在 Linux 容器出现之前的几年由 STM 实现中定义的。这可能会造成混淆,尤其是在像 Quarkus 这样的 Kubernetes 原生环境中,但希望读者能够进行思维映射。

默认 STM 容器 (org.jboss.stm.Container) 为只能在同一微服务/JVM 实例中的线程之间共享的易失性对象提供支持。当一个 STM 感知对象被放置在容器中时,它会返回一个句柄,以后该对象应通过该句柄使用。使用此句柄非常重要,因为继续通过原始引用访问对象将不允许 STM 子系统跟踪访问和管理 state 以及并发控制。

    import org.jboss.stm.Container;

    ...

    Container<FlightService> container = new Container<>(); 1
    FlightServiceImpl instance = new FlightServiceImpl(); 2
    FlightService flightServiceProxy = container.create(instance); 3
1 您需要告知每个容器它将负责的对象类型。在此示例中,它将是实现 FlightService 接口的实例。
2 然后,您创建一个实现 FlightService 的实例。您现在不应直接使用它,因为对它的访问未由 STM 子系统管理。
3 要获取受管实例,请将原始对象传递给 STM container,然后它会返回一个您可以通过其执行事务操作的引用。该引用可以从多个线程中安全使用。

Defining transaction boundaries

一旦将对象放置在 STM 容器中,应用程序开发人员就可以管理它所在的事务的范围。有些注释可以应用于 STM 感知类,以便在调用特定方法时自动创建事务。

Declarative approach

如果在方法签名上放置 @NestedTopLevel@Nested 注释,则 STM 容器将在调用该方法时启动一个新事务,并在方法返回时尝试提交它。如果调用线程已关联了一个事务,则这两个注释中的每个注释的行为略有不同:前一个注释将始终创建在其中方法将执行的新顶级事务,因此包含事务不表现为父级,即嵌套事务将独立提交或中止;后者注释将在调用事务中正确嵌套地创建事务,即事务充当新建事务的父级。

Programmatic approach

应用程序可以在访问 STM 对象的方法之前通过编程启动事务:

AtomicAction aa = new AtomicAction(); 1

aa.begin(); 2
{
    try {
        flightService.makeBooking("BA123 ...");
        taxiService.makeBooking("East Coast Taxis ..."); 3
        4
        aa.commit();
        5
    } catch (Exception e) {
        aa.abort(); 6
    }
}
1 用于手动控制事务边界(扩展中包含 AtomicAction 和其他许多有用的类)的对象。有关更多详细信息,请参阅 to the javadoc
2 Programmatically begin a transaction.
3 请注意,可以组合对象更新,这意味着可以将对多个对象的更新作为单个操作一起提交。[请注意,也可以开始嵌套事务,以便您可以执行投机性工作,然后可以放弃该工作,而不会放弃外部事务执行的其他工作]。
4 由于尚未提交事务,因此 flight 和 taxi 服务进行的更改在事务外部不可见。
5 由于提交成功,因此 flight 和 taxi 服务进行的更改现在对其他线程可见。请注意,依赖旧 state 的其他事务现在提交时可能会发生冲突,也可能不会发生冲突(STM 库提供了许多用于管理冲突行为的功能,这些功能在 NarayanaSTM 手册中有介绍)。
6 以编程方式决定中止事务,这意味着 flight 和 taxi 服务进行的更改将被丢弃。

Distributed transactions

在多个服务之间共享事务是可能的,但目前仅是高级用例,并且如果需要此行为,则应咨询 Narayana 文档。具体来说,STM 还不支持 Context Propagation guide 中描述的功能。