開発・技術選定

独自ポイント機能を安全に作る — 残高を持たず履歴から積み上げる

独自ポイントやウォレット機能を実装するとき、最初に思いつくのは
User.point_balance のような残高カラムです。これはほぼ確実にズレます

なぜ残高カラムはズレるのか

user.point_balance -= 100   # 読んで
user.save()                 # 書く

読んでから書くまでの間に、別のリクエストが同じ処理をすると片方が消えます。
いわゆる Lost Update です。アクセスが少ないうちは表面化しませんが、
キャンペーンで同時アクセスが増えた瞬間に破綻します。

履歴を正とする

残高は「持つ」のではなく「求める」ようにしました。

class PointEntry(models.Model):
    user = models.ForeignKey(User, on_delete=models.PROTECT)
    amount = models.IntegerField()      # 付与は正、利用は負
    reason = models.CharField(max_length=40)
    created_at = models.DateTimeField(auto_now_add=True)

残高は集計で出します。

from django.db.models import Sum

balance = (
    PointEntry.objects.filter(user=user)
    .aggregate(total=Sum("amount"))["total"] or 0
)

こうすると 「なぜこの残高なのか」が必ず説明できるようになります。
問い合わせ対応でこれが効きます。残高カラムだけだと調査のしようがありません。

使用時のロック

集計方式でも、「残高以上に使えてしまう」問題は残ります。
ここは行ロックで直列化します。

from django.db import transaction

def use_points(user, amount):
    with transaction.atomic():
        # ユーザー行をロックして、同じユーザーの処理を直列化する
        locked = User.objects.select_for_update().get(pk=user.pk)
        balance = current_balance(locked)
        if balance < amount:
            raise InsufficientPoints(f"残高 {balance} < 利用 {amount}")
        PointEntry.objects.create(user=locked, amount=-amount, reason="use")

select_for_update()トランザクションの中でしか効きません
transaction.atomic() で囲み忘れると、静かに無効化されて何も守られません。

件数が増えてきたら

履歴が数十万件になると毎回の集計が重くなります。その段階で初めて
スナップショットを足しました。履歴は残したまま、月次で残高を確定させます。

2020-10-31 時点の確定残高: 3,200
以降は 11/1 からの履歴だけを集計して足す

最初から入れる必要はありません。まず正しさ、遅くなってから速さの順です。

まとめ

  • 残高はカラムで持たず、履歴の合計で求める(説明可能になる)
  • 利用時は select_for_update() で直列化する。必ず atomic() の中で
  • パフォーマンス対策のスナップショットは、遅くなってから入れれば間に合う