独自ポイントやウォレット機能を実装するとき、最初に思いつくのは
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()の中で - パフォーマンス対策のスナップショットは、遅くなってから入れれば間に合う