メールのトランザクション設計


3日目で息切れしてきたので、今日は軽めな内容です。

データベース更新とメール送信の一貫性

商品購入の完了ページなど、よくデータベースを更新して、メールを送信してデータベースをコミットするという仕様があります。
データベース登録出来てないのに、完了メールを送るわけにはいかないので、これらを1トランザクションにできなきゃいけません。が、SMTPプロトコルにコミット/ロールバックの概念はありません。

さて、どう設計しましょうか、というお話です。

方式

A.DBトランザクション後にメールを送る

同一トランザクションはあきらめ、データベースを先にコミットし、その後でメールを送る、という設計です。
メール送信でエラーになったら、データベースには書き込めているので、メールだけ再送するように仕組みを作ったりします。

以下のようなイメージです。

public class OrderController {
    @Inject
    private OrderService orderService;

    // Controllerメソッドはトランザクション境界にしない
    public Result complete(Order order) {
        orderService.insertOrder(order);

        // DBトランザクションがうまくいった時だけメールを送る
        Email email = new SimpleEmail();
        email.setHostName("mail.server");
        email.setSmtpPort(465);
        email.setFrom("[email protected]");
        email.setSubject("TestMail");
        email.setMsg("This is a test mail ... :-)");
        email.addTo("[email protected]");

        try {
            email.send();
        } catch(Exception e) {
            RECOVERY_LOG.warn(email);
        }
        return Resutls.redirect("/showOrderComplete");
    }
}

public class OrderService {
    @Inject
    private EntityManagerProvider entityManagerProvider;

    // サービスメソッドをトランザクション境界にする
    @Transaction
    public void insertOrder(Order order) {
        EntityManager em = entityManagerProvider.get();
        em.persist(order);
    }
}


B. 送信リクエストテーブルに書き出し、別プロセスで送る

一度メール送信要求をテーブルに書きだして、そこで同一トランザクションとし、別プロセスがその送信要求を取り出して、実際にメール送信するという設計です。

これもよく使われる方式ですが、前述の方式より複雑で障害ポイントが増えるので、運用面では気をつける点が増えます。

そして、今度はバッチ側でメールは送れたけど、データベースの更新(送信要求の削除)でエラーになった場合、送信ステータスがUNKNOWNになるという問題もあります(可能性としてはだいぶ低いとは思いますがレアケース厨もいるので、そのときの対応方針は考えとかなきゃいけません)。

C. [現実解] A + ローカルキューに送る

さてここでSMTPのプロトコルについて考えてみます。もともとサーバをリレーして目的の宛先まで届く仕組みです。直接外部送信用のSMTPサーバに送ろうとするから、失敗の可能性が高まるわけです。ローカルに送ってスプーリングすればエラーの確率は、ほぼ考慮しなくても良くなります(プロセスの起動忘れか、ディスクフルのケースくらい、のレベルの違うトラブルだという点において)。さらに流量制御も比較的容易にできるようになります。

同じことがやはりキューイングの仕組みをもつアーキテクチャ全般に言えます。MQを使ってデータ送るときも、ローカルキューを作ってそこからリレーさせるのが定石のようです。

余談

メールアドレスのバリデーション

ローカルSMTPでもエラーとなる可能性として、送信先のメールアドレスが不正であるケースがあります。これは送信以前に除外しておくべきです。

というところで、メールアドレスのバリデーションを考えるわけです。

巷のバリデータはRFCに沿ったチェックをするんだというものが多いようですが、この話をするとき、いつも出てくるのはDOCOMOのRFC違反のアドレスの話です。
RFC違反のアドレスが届くかどうかは、相手のMTA依存なので、そのアドレスが正しいかどうかは実際に送ってみないと分からないということになります。ユーザが正しいメールアドレスだと言っているのに、厳しいバリデーション仕様を作りこんでも、運用時の問い合わせ・クレームを増やすだけになるので、戦略としては、とりあえず送ってみて、ダメだったら連続失敗回数をカウントアップし、何回か連続して送信失敗したら、不達アドレスとして以降送信しないようにする対応が現実的かと思います。

したがって、このとりあえず送ってみることのできる範囲を、バリデーションの仕様とした方がよいでしょう。

// TODO 後で書く

まとめ

メールは直接外部送信用SMTPサーバに送らず、ローカルにスプーリングしよう。メール以外の非同期キューイングアーキテクチャも同じくです。