PHPでDynamoDBのトランザクションを試してみた


DynamoDBではトランザクションが使用できません。
…でしたが最近使用できるようになったみたいです。(2018-11-28)
https://aws.amazon.com/jp/blogs/news/new-amazon-dynamodb-transactions/

というわけで早速試してみました！

## 実行環境
- php7.2
- AWS SDK for PHP - Version 3
- AWS-東京リージョン(ap-northeast-1)

##準備
### 1. aws/aws-sdk-php
composer.jsonに以下のように記述します。

```composer.json
{
    "require": {
    	"aws/aws-sdk-php":"*"
    }
}
```

composerコマンドでインストールを行います。

```
composer install
```
### 2. DynamoDbClient
DynamoDBにアクセスするためのクライアントを返すphpを作成しておきます。



```client.php

<?php

use Aws\DynamoDb\DynamoDbClient;

$dynamodb = DynamoDbClient::factory([
    'credentials' => [
        'key' => 'AWSのアクセスキー',
        'secret' => 'AWSのシークレットキー',
    ],
    //東京リージョン
    'region' => 'ap-northeast-1',
    'version' => 'latest'
]);

return $dynamodb;

```
### 3. テーブル作成
DynamoDBのテーブルを作成するphpを作成します。
今回はトランザクションを試したいので2テーブル作成します。
(ちなみにこの作業はコンソールからでも大丈夫です。)

```createTable.php
<?php

use Aws\DynamoDb\Exception\DynamoDbException;

//DynamoDbClient
$dynamodb = require_once('client.php');

//作成するテーブルのスキーマ
$params = [
    [
        'TableName' => 'users',
        'KeySchema' => [
            [
                'AttributeName' => 'name',
                'KeyType' => 'HASH'
            ],
        ],
        'AttributeDefinitions' => [
            [
                'AttributeName' => 'name',
                'AttributeType' => 'S'
            ],
        ],
        'ProvisionedThroughput' => [
            'ReadCapacityUnits' => 10,
            'WriteCapacityUnits' => 10
        ]
    ],
    [
        'TableName' => 'logs',
        'KeySchema' => [
            [
                'AttributeName' => 'name',
                'KeyType' => 'HASH'
            ],
            [
                'AttributeName' => 'created_at',
                'KeyType' => 'RANGE'
            ],
        ],
        'AttributeDefinitions' => [
            [
                'AttributeName' => 'name',
                'AttributeType' => 'S'
            ],
            [
                'AttributeName' => 'created_at',
                'AttributeType' => 'S'
            ],
        ],
        'ProvisionedThroughput' => [
            'ReadCapacityUnits' => 10,
            'WriteCapacityUnits' => 10
        ]
    ]
];

try {
    foreach ($params as $param) {
        //テーブル作成
        $response = $dynamodb->createTable($param);

        if ($response['@metadata']['statusCode'] !== 200) {
            echo 'Failed.';
            exit();
        }

    }
//すでにテーブルが作成されている場合、例外処理が発生する
} catch (DynamoDbException $e) {
    var_dump($e->getMessage());
    exit();
}

echo 'Successfully.';


```

上記のcreateTable.phpを実行すると、usersとlogsという名前の2テーブルが作成されます。

##実行

### __TransactWriteItems__
 
トランザクションでデータを書き込むTransactWriteItemsを試してみます。
usersテーブルとlogsテーブルにデータを追加するphpを作成します。

- シナリオ
 1. 所持金300円のAさんが所持金0円のBさんに300円振り込む。
 2. 更にAさんがBさんに300円振り込もうとする。
 3. Aさんの所持金が-300円になってしまうため、2番の処理をロールバックする。

###1.初期データ準備
まず、初期データ(名前、 所持金)を流し込むphpを作成します。


```initial.php
<?php

use Aws\DynamoDb\Exception\DynamoDbException;
use Aws\DynamoDb\Marshaler;

//DynamoDbClient
$dynamodb = require_once('client.php');

$marshaler = new Marshaler();

$dynamodb->putItem([
    'TableName' => 'users',
    'Item' => [
        'name' => $marshaler->marshalValue('A'),
        'balance' => $marshaler->marshalValue(300),
    ],
]);

$dynamodb->putItem([
    'TableName' => 'users',
    'Item' => [
        'name' => $marshaler->marshalValue('B'),
        'balance' => $marshaler->marshalValue(0),
    ],
]);


```

上記のphpを実行すると下記のようにデータが作成されます。

- usersテーブル
![users.PNG](https://qiita-image-store.s3.amazonaws.com/0/322105/ce6f093d-de51-1c08-bacd-57959b6bb472.png)

###2.トランザション処理(commit)
振込処理のphpを作成します。

```transact.php
<?php

use Aws\DynamoDb\Exception\DynamoDbException;
use Aws\DynamoDb\Marshaler;

//DynamoDbClient
$dynamodb = require_once('client.php');

$marshaler = new Marshaler();

try {
    $response = $dynamodb->transactWriteItems([
        'TransactItems' => [
            //Bの残高を300足す
            [
                'Update' => [
                    'TableName' => 'users',
                    'Key' => $marshaler->marshalItem([
                        'name' => 'B'
                    ]),
                    'ExpressionAttributeValues' => [
                        ':val' => $marshaler->marshalValue(300),
                    ],
                    'UpdateExpression' => 'SET balance = balance + :val',
                ]
            ],
            //+300をログに記録
            [
                'Put' => [
                    'TableName' => 'logs',
                    'Item' => $marshaler->marshalItem([
                        'name' => 'B',
                        'process' => '+300',
                        'created_at' => date('Y-m-d H:i:s'),
                    ]),
                ]
            ],
            //Aの残高から300引く
            [
                'Update' => [
                    'TableName' => 'users',
                    'Key' => $marshaler->marshalItem([
                        'name' => 'A'
                    ]),
                    'ExpressionAttributeValues' => [
                        ':val' => $marshaler->marshalValue(300),
                    ],
                    'UpdateExpression' => 'SET balance =  balance - :val',
                    'ConditionExpression' => ':val <= balance'
                ]
            ],
           //-300円をログに記録
            [
                'Put' => [
                    'TableName' => 'logs',
                    'Item' => $marshaler->marshalItem([
                        'name' => 'A',
                        'process' => '-300',
                        'created_at' => date('Y-m-d H:i:s'),
                    ]),
                ]
            ],
        ]
    ]);

    if ($response['@metadata']['statusCode'] !== 200) {
        echo 'Failed.';
        exit();
    }

} catch (DynamoDbException $e) {
    var_dump($e->getAwsErrorCode());
    exit();
}

echo 'Successfully.';

```
上記を実行すると、以下のような結果になります。

- usersテーブル
![users.PNG](https://qiita-image-store.s3.amazonaws.com/0/322105/cab042d9-9709-e493-511c-b1cb16b22dd3.png)


- logsテーブル
![logs.PNG](https://qiita-image-store.s3.amazonaws.com/0/322105/2c8fa66f-5013-4c54-60f3-2fa6def00d92.png )


###3.トランザション処理(rollback)
この状態でtransact.phpをもう一度実行します。
そうすると、Aの残高を-300円する処理でTransactionCanceledExceptionが発生します。
(ConditionExpressionで振込額が残高を超えないよう条件を指定しているため)

結果的に処理が実行されず、データは変更されません。

ちなみにConditionExpressionを外して実行すると以下の結果になります。

- usersテーブル
![users.PNG](https://qiita-image-store.s3.amazonaws.com/0/322105/ec257a11-fb7f-5f7a-f6f9-ab502fc370b2.png)


- logsテーブル
![logs.PNG](https://qiita-image-store.s3.amazonaws.com/0/322105/9a6a6d91-d903-8508-d549-76ce7ed973ab.png)


##所感
今まで、DynamoDBはトランザクションに未対応だったのでRDSから移行するのにはボトルネックとなっていました。(Amazon SQSなどを併用して代替的に実現する方法はありましたが)

公式ドキュメントを見ると、テーブルロックはされないみたいですが、トランザクション分離レベルはserializableとなっており安全にデータを操作できそうです。

ただ、トランザクション内の各Itemに対して2回の読み取りまたは書き込みを行うようなので、使用は最小限に留めておいたほうが良さそうですね。

今回のトランザクション対応でDynamoDBを起用できるケースも増えるのではないしょうか。

---
[:christmas_tree: FORK Advent Calendar 2018](/advent-calendar/2018/fork)