Spring事務最佳實踐

介紹

在本文中,我將向您展示各種 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)我們的傳輸服務:

image.png

為了演示當事務沒有根據(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é)的原子性

AfromACID代表原子性抄瓦,它允許事務將數(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í)行的缭召。

由于我們忘記添加@Transactionaltransfer方法,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 的默認隔離級別無法阻止該異常:


image.png

雖然多個并發(fā)用戶可以讀取賬戶余額5堂鲜,但只有第一個用戶UPDATE會將余額從 更改50。第二個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 CommittedRepeatable 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 注解背后的魔力

transfertestParallelExecution集成測試調(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類:

image.png

雖然這個 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é)點]

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末谒臼,一起剝皮案震驚了整個濱河市朝刊,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌蜈缤,老刑警劉巖拾氓,帶你破解...
    沈念sama閱讀 218,858評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異底哥,居然都是意外死亡咙鞍,警方通過查閱死者的電腦和手機房官,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,372評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來续滋,“玉大人翰守,你說我怎么就攤上這事∑W茫” “怎么了蜡峰?”我有些...
    開封第一講書人閱讀 165,282評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長朗恳。 經(jīng)常有香客問我事示,道長,這世上最難降的妖魔是什么僻肖? 我笑而不...
    開封第一講書人閱讀 58,842評論 1 295
  • 正文 為了忘掉前任肖爵,我火速辦了婚禮,結(jié)果婚禮上臀脏,老公的妹妹穿的比我還像新娘劝堪。我一直安慰自己,他們只是感情好揉稚,可當我...
    茶點故事閱讀 67,857評論 6 392
  • 文/花漫 我一把揭開白布秒啦。 她就那樣靜靜地躺著,像睡著了一般搀玖。 火紅的嫁衣襯著肌膚如雪余境。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,679評論 1 305
  • 那天灌诅,我揣著相機與錄音芳来,去河邊找鬼。 笑死猜拾,一個胖子當著我的面吹牛即舌,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播挎袜,決...
    沈念sama閱讀 40,406評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼顽聂,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了盯仪?” 一聲冷哼從身側(cè)響起紊搪,我...
    開封第一講書人閱讀 39,311評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎全景,沒想到半個月后耀石,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,767評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡蚪燕,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年娶牌,在試婚紗的時候發(fā)現(xiàn)自己被綠了奔浅。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,090評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡诗良,死狀恐怖汹桦,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情鉴裹,我是刑警寧澤舞骆,帶...
    沈念sama閱讀 35,785評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站径荔,受9級特大地震影響督禽,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜总处,卻給世界環(huán)境...
    茶點故事閱讀 41,420評論 3 331
  • 文/蒙蒙 一狈惫、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧鹦马,春花似錦胧谈、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,988評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至旭从,卻和暖如春稳强,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背和悦。 一陣腳步聲響...
    開封第一講書人閱讀 33,101評論 1 271
  • 我被黑心中介騙來泰國打工退疫, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人摹闽。 一個月前我還...
    沈念sama閱讀 48,298評論 3 372
  • 正文 我出身青樓蹄咖,卻偏偏與公主長得像褐健,于是被迫代替她去往敵國和親付鹿。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,033評論 2 355

推薦閱讀更多精彩內(nèi)容