介紹
在本文中,我將向您展示各種 Spring Transaction 最佳實踐金抡,它們可以幫助您實現(xiàn)底層業(yè)務需求所需的數(shù)據(jù)完整性保證瀑焦。
數(shù)據(jù)完整性至關(guān)重要,因為如果沒有適當?shù)氖聞仗幚砉8危膽贸绦蚩赡苋菀资艿娇赡軐Φ讓訕I(yè)務產(chǎn)生可怕后果的[競爭條件的影響榛瓮。]
模擬 Flexcoin 競爭條件
在[本文中],我解釋了 Flexcoin 是如何因為競爭條件而破產(chǎn)的统捶,一些黑客利用這種競爭條件竊取了 Flexcoin 可用的所有 BTC 資金榆芦。
我們之前的實現(xiàn)是使用純 JDBC 構(gòu)建的柄粹,但我們可以使用 Spring 模擬相同的場景喘鸟,這對于絕大多數(shù) Java 開發(fā)人員來說肯定更熟悉。這樣驻右,我們將使用現(xiàn)實生活中的問題作為示例什黑,說明在構(gòu)建基于 Spring 的應用程序時應該如何處理事務。
因此堪夭,我們將使用以下服務層和數(shù)據(jù)訪問層組件來實現(xiàn)我們的傳輸服務:
為了演示當事務沒有根據(jù)業(yè)務需求處理時會發(fā)生什么愕把,讓我們使用最簡單的數(shù)據(jù)訪問層實現(xiàn):
@Repository
@Transactional(readOnly = true)
public interface AccountRepository extends JpaRepository<Account, Long> {
@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);
}
getBalance
和方法都addBalance
使用 Spring@Query
注釋來定義可以讀取或?qū)懭虢o定帳戶余額的本機 SQL 查詢。
因為讀操作多于寫操作森爽,所以
@Transactional(readOnly = true)
在每個類級別上定義注釋是一種很好的做法恨豁。這樣,默認情況下爬迟,沒有注釋的方法
@Transactional
將在只讀事務的上下文中執(zhí)行橘蜜,除非現(xiàn)有的讀寫事務已經(jīng)與當前處理的執(zhí)行線程相關(guān)聯(lián)。但是付呕,當我們想改變數(shù)據(jù)庫狀態(tài)時计福,我們可以使用
@Transactional
注解來標記讀寫事務方法,并且徽职,如果沒有事務已經(jīng)啟動并傳播到該方法調(diào)用象颖,那么讀寫事務上下文將是為此方法執(zhí)行創(chuàng)建。有關(guān)
@Transactional
注釋的更多詳細信息姆钉,也請查看這篇文章说订。
妥協(xié)的原子性
A
fromACID
代表原子性抄瓦,它允許事務將數(shù)據(jù)庫從一個一致狀態(tài)移動到另一個一致狀態(tài)。因此克蚂,原子性允許我們在同一個數(shù)據(jù)庫事務的上下文中注冊多個語句闺鲸。
在 Spring 中,這可以通過@Transactional
注解來實現(xiàn)埃叭,所有應該與關(guān)系數(shù)據(jù)庫交互的公共服務層方法都應該使用注解摸恍。
如果您忘記這樣做,則業(yè)務方法可能會跨越多個數(shù)據(jù)庫事務赤屋,從而損害原子性立镶。
例如,假設我們實現(xiàn)了transfer
這樣的方法:
@Service
public class TransferServiceImpl implements TransferService {
@Autowired
private AccountRepository accountRepository;
@Override
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;
}
}
考慮到我們有兩個用戶类早,Alice 和 Bob:
| iban | balance | owner |
|-----------|---------|-------|
| Alice-123 | 10 | Alice |
| Bob-456 | 0 | Bob |
運行并行執(zhí)行測試用例時:
@Test
public void testParallelExecution()
throws InterruptedException {
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();
}
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: -5
Bob's balance: 15
所以媚媒,我們有麻煩了!鮑勃設法獲得了比愛麗絲最初在她帳戶中的更多的錢涩僻。
我們得到這個競爭條件的原因是該transfer
方法不是在單個數(shù)據(jù)庫事務的上下文中執(zhí)行的缭召。
由于我們忘記添加@Transactional
該transfer
方法,Spring 不會在調(diào)用此方法之前啟動事務上下文逆日,因此嵌巷,我們最終將運行三個連續(xù)的數(shù)據(jù)庫事務:
* 一個用于`getBalance`選擇 Alice 帳戶余額的方法調(diào)用
* `addBalance`用于從愛麗絲賬戶中扣款的第一個電話
* 另一個用于第二次`addBalance`通話,記入 Bob 的帳戶
方法之所以以AccountRepository
事務方式執(zhí)行是由于@Transactional
我們添加到類和addBalance
方法定義中的注釋室抽。
服務層的主要目標是定義給定工作單元的事務邊界搪哪。
如果服務要調(diào)用多個
Repository
方法,那么擁有跨越整個工作單元的單個事務上下文非常重要坪圾。
依賴交易默認值
@Transactional
因此晓折,讓我們通過向方法添加注釋來解決第一個問題transfer
:
@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;
}
現(xiàn)在,當重新運行testParallelExecution
測試用例時兽泄,我們將得到以下結(jié)果:
Alice's balance: -50
Bob's balance: 60
因此漓概,即使讀取和寫入操作是原子完成的,問題也沒有得到解決病梢。
我們這里的問題是由丟失更新異常引起的胃珍,Oracle、SQL Server飘千、PostgreSQL 或 MySQL 的默認隔離級別無法阻止該異常:
雖然多個并發(fā)用戶可以讀取賬戶余額5
堂鲜,但只有第一個用戶UPDATE
會將余額從 更改5
為0
。第二個UPDATE
會認為賬戶余額是它之前讀取的余額护奈,而實際上缔莲,余額已經(jīng)被另一筆成功提交的交易改變了。
為了防止丟失更新異常霉旗,我們可以嘗試多種解決方案:
- 我們可以使用樂觀鎖定痴奏,如[本文所述]
- 我們可以通過使用
FOR UPDATE
指令鎖定 Alice 的帳戶記錄來使用悲觀鎖定方法蛀骇,如[本文所述] - 我們可以使用更嚴格的隔離級別
根據(jù)底層關(guān)系數(shù)據(jù)庫系統(tǒng),這就是如何使用更高的隔離級別來防止丟失更新異常:
| Isolation Level | Oracle | SQL Server | PostgreSQL | MySQL |
|-----------------|--------|------------|------------|-------|
| Read Committed | Yes | Yes | Yes | Yes |
| Repeatable Read | N/A | No | No | Yes |
| Serializable | No | No | No | No |
由于我們在 Spring 示例中使用 PostgreSQL读拆,讓我們將隔離級別從默認值更改Read Committed
為Repeatable Read
.
正如我在本文@Transactional
中所解釋的擅憔,您可以在注釋級別設置隔離級別:
@Transactional(isolation = Isolation.REPEATABLE_READ)
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;
}
而且,在運行testParallelExecution
集成測試時檐晕,我們將看到丟失更新異常將被阻止:
Alice's balance: 0
Bob's balance: 10
僅僅因為默認隔離級別在許多情況下都很好暑诸,并不意味著您應該將其專門用于任何可能的用例。
如果給定的業(yè)務用例需要嚴格的數(shù)據(jù)完整性保證辟灰,那么您可以使用更高的隔離級別或更精細的并發(fā)控制策略个榕,例如樂觀鎖定機制。
Spring @Transactional 注解背后的魔力
transfer
從testParallelExecution
集成測試調(diào)用方法時芥喇,堆棧跟蹤如下所示:
"Thread-2"@8,005 in group "main": RUNNING
transfer:23, TransferServiceImpl
invoke0:-1, NativeMethodAccessorImpl
invoke:77, NativeMethodAccessorImpl
invoke:43, DelegatingMethodAccessorImpl
invoke:568, Method {java.lang.reflect}
invokeJoinpointUsingReflection:344, AopUtils
invokeJoinpoint:198, ReflectiveMethodInvocation
proceed:163, ReflectiveMethodInvocation
proceedWithInvocation:123, TransactionInterceptor$1
invokeWithinTransaction:388, TransactionAspectSupport
invoke:119, TransactionInterceptor
proceed:186, ReflectiveMethodInvocation
invoke:215, JdkDynamicAopProxy
transfer:-1, $Proxy82 {jdk.proxy2}
lambda$testParallelExecution$1:121
在transfer
調(diào)用方法之前西采,有一個 AOP(面向方面的編程)方面會被執(zhí)行,對我們來說最重要的是TransactionInterceptor
擴展TransactionAspectSupport
類:
雖然這個 Spring Aspect 的入口點是 . TransactionInterceptor
继控,但最重要的操作發(fā)生在它的基類TransactionAspectSupport
.
例如械馆,這是 Spring 處理事務上下文的方式:
protected Object invokeWithinTransaction(
Method method,
@Nullable Class<?> targetClass,
final InvocationCallback invocation) throws Throwable {
TransactionAttributeSource tas = getTransactionAttributeSource();
final TransactionAttribute txAttr = tas != null ?
tas.getTransactionAttribute(method, targetClass) :
null;
final TransactionManager tm = determineTransactionManager(txAttr);
...
PlatformTransactionManager ptm = asPlatformTransactionManager(tm);
final String joinpointIdentification = methodIdentification(
method,
targetClass,
txAttr
);
TransactionInfo txInfo = createTransactionIfNecessary(
ptm,
txAttr,
joinpointIdentification
);
Object retVal;
try {
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
commitTransactionAfterReturning(txInfo);
...
return retVal;
}
服務方法調(diào)用由invokeWithinTransaction
啟動新事務上下文的方法包裝,除非已經(jīng)啟動并傳播到此事務方法武通。
如果RuntimeException
拋出 a霹崎,則事務回滾。否則厅须,如果一切順利仿畸,則提交事務食棕。
結(jié)論
在開發(fā)一個重要的應用程序時朗和,了解 Spring 事務的工作方式非常重要。首先簿晓,您需要確保圍繞邏輯工作單元正確聲明事務邊界眶拉。
其次,您必須知道何時使用默認隔離級別以及何時使用更高的隔離級別憔儿。
根據(jù)該標志忆植,您甚至可以將事務路由到連接到副本節(jié)點的read-only
只讀節(jié)點,而不是[主節(jié)點]