關於流資料上的事務操作

weixin_34337265發表於2018-09-06

概述

最近Flink母公司Data Artisans釋出了一篇部落格關於一個新的元件Streaming Ledger,給出了流資料的事務解決方案(就是常說的資料庫的事務,滿足ACID,隔離級別為Serializable)。

使用姿勢

  • 舉例使用經典的轉賬和存款問題
    • 它是基於Flink的,關於Flink任務初始化的一些內容就不放在這裡了
  • 首先建立StreamingLedger。
        // start building the transactional streams
        StreamingLedger tradeLedger = StreamingLedger.create("simple trade example");
  • 第二定義所需要用到的狀態和相應的KV型別,這裡分別是賬戶和賬單明細。
        // define transactors on states
        tradeLedger.usingStream(deposits, "deposits")
                .apply(new DepositHandler())
                .on(accounts, DepositEvent::getAccountId, "account", READ_WRITE)
                .on(books, DepositEvent::getBookEntryId, "asset", READ_WRITE);
  • 第三步是分別將輸入流,具體的事務操作,操作的狀態、從事件中獲取key的方法、別名(會在事務操作,即此處的TxnHandler中體現)、和許可權分別注入StreamLedger並輸出為一個sideOutput。
        // produce transactions stream
        DataStream<TransactionEvent> transfers = env.addSource(new TransactionsGenerator(1));

        OutputTag<TransactionResult> transactionResults = tradeLedger.usingStream(transfers, "transactions")
                .apply(new TxnHandler())
                .on(accounts, TransactionEvent::getSourceAccountId, "source-account", READ_WRITE)
                .on(accounts, TransactionEvent::getTargetAccountId, "target-account", READ_WRITE)
                .on(books, TransactionEvent::getSourceBookEntryId, "source-asset", READ_WRITE)
                .on(books, TransactionEvent::getTargetBookEntryId, "target-asset", READ_WRITE)
                .output();
  • 第四步是根據sideOuput的OutputTag輸出結果,到這裡,除了TxnHandler需要去實現以外,主幹邏輯已經完成了。
        //  compute the resulting streams.
        ResultStreams resultsStreams = tradeLedger.resultStreams();

        // output to the console
        resultsStreams.getResultStream(transactionResults).print();
  • 最後就是實現TxnHandler, 具體的轉賬和寫入明細的邏輯都在這裡。值得注意的是狀態的獲取依賴於上一步中在StreamLedger注入的別名,更新完狀態之後再輸出。
    private static final class TxnHandler extends TransactionProcessFunction<TransactionEvent, TransactionResult> {

        private static final long serialVersionUID = 1;

        @ProcessTransaction
        public void process(
                final TransactionEvent txn,
                final Context<TransactionResult> ctx,
                final @State("source-account") StateAccess<Long> sourceAccount,
                final @State("target-account") StateAccess<Long> targetAccount,
                final @State("source-asset") StateAccess<Long> sourceAsset,
                final @State("target-asset") StateAccess<Long> targetAsset) {

            final long sourceAccountBalance = sourceAccount.readOr(ZERO);
            final long sourceAssetValue = sourceAsset.readOr(ZERO);
            final long targetAccountBalance = targetAccount.readOr(ZERO);
            final long targetAssetValue = targetAsset.readOr(ZERO);

            // check the preconditions
            if (sourceAccountBalance > txn.getMinAccountBalance()
                    && sourceAccountBalance > txn.getAccountTransfer()
                    && sourceAssetValue > txn.getBookEntryTransfer()) {

                // compute the new balances
                final long newSourceBalance = sourceAccountBalance - txn.getAccountTransfer();
                final long newTargetBalance = targetAccountBalance + txn.getAccountTransfer();
                final long newSourceAssets = sourceAssetValue - txn.getBookEntryTransfer();
                final long newTargetAssets = targetAssetValue + txn.getBookEntryTransfer();

                // write back the updated values
                sourceAccount.write(newSourceBalance);
                targetAccount.write(newTargetBalance);
                sourceAsset.write(newSourceAssets);
                targetAsset.write(newTargetAssets);

                // emit result event with updated balances and flag to mark transaction as processed
                ctx.emit(new TransactionResult(txn, true, newSourceBalance, newTargetBalance));
            }
            else {
                // emit result with unchanged balances and a flag to mark transaction as rejected
                ctx.emit(new TransactionResult(txn, false, sourceAccountBalance, targetAccountBalance));
            }
        }
    }

原理

  • 其實我的第一想法是,臥槽好牛逼,這得涉及到分散式事務。把repo clone下來之後發現包含例子只有2000多行程式碼,一下子震驚了。但是實際的實現還是比較簡單地,當然也肯定會帶來一些問題。
  • 實際上上面這些API會轉換為一個source,一個sink,兩個map和一個包含了SerialTransactor(ProcessFunction的實現)的運算元。
  • 在這邊展示幾行程式碼應該就能明白是如何做到的。關鍵在於forceNonParallel,這就讓所有事情都變得明瞭了,事實上就是把狀態全部都託管到一個並行度為1的運算元上,處理的時候也是序列的,這裡我才反應過來關鍵在於隔離級別是Serializable。這裡帶來的問題就是所有狀態都儲存在一個節點,並且不能支援水平擴充套件,所能支撐的吞吐量也不能通過加機器來提升。
        SingleOutputStreamOperator<Void> resultStream = input
                .process(new SerialTransactor(specs(streamLedgerSpecs), sideOutputTags))
                .name(serialTransactorName)
                .uid(serialTransactorName + "___SERIAL_TX")
                .forceNonParallel()
                .returns(Void.class);

感想

其實看到這個功能的第一感覺是很牛逼,但是仔細看過了它的實現覺得真正應用上可能會有不少問題。因為對於最重要的處理事務的那個運算元來說,本質上它並不是Scalable的,沒有辦法橫向擴充套件。不過從功能上來說,確實引出了一個新的發展方向,希望以後還能看到有更優的解決方案,比如針對另外兩種隔離級別Read Committed和Repeatable read。

相關文章