طراحی لجر: موجودی باید از تراکنش‌ها به دست بیاید

پاسخ کوتاه: موجودی کیف‌پول را به‌عنوان حقیقت ذخیره نکنید. هر جابه‌جایی پول را به‌صورت یک ردیف فقط‌افزودنی در لجر ثبت کنید و موجودی را نتیجه جمع همین ردیف‌ها بدانید. انتقال دو ردیف است که جمعشان صفر است، اشتباه با یک ردیف برگشتی اصلاح می‌شود، و هر موجودی را می‌شود از روی تاریخچه بازسازی و توضیح داد. یک ستون موجودی کش‌شده به‌عنوان بهینه‌سازی اشکالی ندارد، به شرطی که بشود دورش ریخت و دوباره حسابش کرد.

یک مثال واقعی: «چرا موجودی من ۲۰ است؟»

کاربری به پشتیبانی می‌نویسد: «موجودی من باید ۱۲۰ باشد، اپ ۲۰ نشان می‌دهد.» با یک ستون Balance، تنها پاسخ صادقانه این است که «دیتابیس می‌گوید ۲۰». چیزی، در زمانی، به دلیلی آن را کم کرده، ولی ستون یادش نمی‌ماند.

با لجر، پشتیبانی یک کوئری اجرا می‌کند و داستان را می‌خواند: واریز ۱۰۰، اعتبار معرفی ۲۰، برداشت ۸۰، و یک ثانیه بعد برداشت ۸۰ دیگر با همان شناسه درگاه. تکراری بودن دیده می‌شود، اصلاح یک ردیف برگشتی است و گفت‌وگو به‌جای چند روز، چند دقیقه طول می‌کشد.

دلیل واقعی ساختن لجر همین است؛ نه زیبایی، بلکه توانایی پاسخ دادن به «چرا؟» درباره پول.

قاعده‌ها

  1. ردیف‌ها فقط اضافه می‌شوند. بدون UPDATE و DELETE. اصلاح یعنی ردیف جدید.
  2. هر عملیات کسب‌وکار یک شناسه دارد (TransactionId) که همه ردیف‌هایش در آن شریک‌اند.
  3. جمع انتقال صفر است. پول بین حساب‌ها جابه‌جا می‌شود؛ انتقال هیچ‌وقت پول نمی‌سازد یا از بین نمی‌برد.
  4. موجودی یک کوئری است، نه ستونی که باید یادتان بماند به‌روزش کنید.
  5. مبلغ‌ها عدد صحیح‌اند، بر حسب کوچک‌ترین واحد (ریال) و علامت‌دار: واریز مثبت، برداشت منفی.

کد قابل اجرا

با EF Core روی SQL Server یا SQLite:

using System.Data;
using Microsoft.EntityFrameworkCore;

public enum EntryType
{
    Deposit,
    Withdrawal,
    TransferIn,
    TransferOut,
}

public sealed class LedgerEntry
{
    public long Id { get; set; }
    public Guid AccountId { get; set; }

    /// <summary>Signed: credits are positive, debits negative.</summary>
    public long AmountRials { get; set; }

    public EntryType Type { get; set; }

    /// <summary>Groups the entries of one business operation (both legs of a transfer).</summary>
    public Guid TransactionId { get; set; }

    public DateTime CreatedAt { get; set; }
}

public sealed class LedgerDb(DbContextOptions<LedgerDb> options) : DbContext(options)
{
    public DbSet<LedgerEntry> Entries => Set<LedgerEntry>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<LedgerEntry>().HasIndex(e => new { e.AccountId, e.Id });
}

public sealed class LedgerService(LedgerDb db)
{
    public Task<long> BalanceAsync(Guid accountId, CancellationToken ct) =>
        db.Entries.Where(e => e.AccountId == accountId).SumAsync(e => e.AmountRials, ct);

    public async Task DepositAsync(Guid accountId, long amount, Guid transactionId, CancellationToken ct)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
        db.Entries.Add(Entry(accountId, amount, EntryType.Deposit, transactionId));
        await db.SaveChangesAsync(ct);
    }

    public async Task TransferAsync(Guid from, Guid to, long amount, Guid transactionId, CancellationToken ct)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);

        // Serializable: no other transaction can add entries for "from" between our check and our insert.
        await using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);

        if (await BalanceAsync(from, ct) < amount)
        {
            throw new InvalidOperationException("Insufficient funds.");
        }

        db.Entries.AddRange(
            Entry(from, -amount, EntryType.TransferOut, transactionId),
            Entry(to, amount, EntryType.TransferIn, transactionId));

        await db.SaveChangesAsync(ct);
        await transaction.CommitAsync(ct);
    }

    /// <summary>Transfers that don't net to zero. If this returns anything, stop and investigate.</summary>
    public async Task<IReadOnlyList<Guid>> UnbalancedTransfersAsync(CancellationToken ct) =>
        await db.Entries
            .Where(e => e.Type == EntryType.TransferIn || e.Type == EntryType.TransferOut)
            .GroupBy(e => e.TransactionId)
            .Where(g => g.Sum(e => e.AmountRials) != 0)
            .Select(g => g.Key)
            .ToListAsync(ct);

    private static LedgerEntry Entry(Guid account, long amount, EntryType type, Guid transactionId) => new()
    {
        AccountId = account,
        AmountRials = amount,
        Type = type,
        TransactionId = transactionId,
        CreatedAt = DateTime.UtcNow,
    };
}

نکته‌های مهم:

  • BalanceAsync تنها تعریف موجودی است. هیچ مسیر دیگری در کد نمی‌تواند با آن مخالف باشد.
  • بررسی موجودی و درج ردیف‌ها در یک تراکنش serializable هستند. بدون آن، دو انتقال همزمان می‌توانند هر دو از بررسی رد شوند؛ همان race ستون موجودی، فقط در جای دیگر.
  • UnbalancedTransfersAsync یک قاعده قابل پایش است. هر شب اجرایش کنید؛ باید همیشه فهرست خالی برگرداند.
  • TransactionId از سمت فراخواننده می‌آید. آن را کلید idempotency درخواست کنید تا انتقالی که دوباره ارسال شده دو بار ثبت نشود؛ یک ایندکس یکتا روی (TransactionId، AccountId، Type) اضافه کنید.

وقتی جمع زدن کند شد: snapshot

حسابی با سال‌ها ردیف، SUM را سنگین‌تر می‌کند. راه‌حل معمول یک جدول snapshot است: «حساب X بعد از ردیف N موجودی B داشت». موجودی می‌شود B + SUM(ردیف‌های بعد از N). snapshot هم مشتق‌شده است؛ می‌شود پاکش کرد و از روی ردیف‌ها دوباره ساخت. همین است که لجر را تنها منبع حقیقت نگه می‌دارد.

مزایا و معایب

مزایا

  • هر موجودی قابل توضیح و بازتولید است؛ فرق بین یک پاسخ پنج‌دقیقه‌ای پشتیبانی و یک بررسی سه‌روزه.
  • اصلاح‌ها تاریخچه قابل دیدن‌اند، نه بازنویسی بی‌صدا.
  • مغایرت‌گیری با بانک و درگاه به مقایسه دو فهرست ردیف تبدیل می‌شود.

معایب

  • ردیف‌های بیشتر و فکر بیشتر از یک ستون. گزارش‌گیری ایندکس و در نهایت snapshot لازم دارد.
  • همه مسیرهای نوشتن باید از سرویس لجر بگذرند؛ یک UPDATE «سریع» کل مدل را خراب می‌کند.
  • همزمانی هنوز مهم است: بررسی موجودی ایزولیشن یا قفل درست می‌خواهد.

کیف‌پول، سیستم امتیاز یا هر چیزی می‌سازید که موجودی‌اش باید قابل توضیح باشد؟ ببینید چطور می‌توانم کمک کنم.

پرسش‌های متداول

جمع زدن همه ردیف‌ها برای هر موجودی خیلی کند نیست؟

با یک ایندکس روی (AccountId, Id)، جمع ردیف‌های یک حساب تا مدت‌ها سریع است. وقتی نبود، snapshot دوره‌ای اضافه کنید (موجودی بعد از ردیف N) و فقط ردیف‌های بعد از آن را جمع بزنید. snapshot یک کش قابل بازسازی است، نه منبع حقیقت دوم.

اگر ردیف‌ها هیچ‌وقت ویرایش نشوند، یک ثبت اشتباه را چطور اصلاح کنم؟

با یک ردیف جدید: یک برگشت که اثر ردیف اشتباه را خنثی کند و بعد ردیف درست. تاریخچه هم اشتباه و هم اصلاح را نشان می‌دهد؛ دقیقاً همان چیزی که حسابرس یا تیم پشتیبانی لازم دارد.

برای کیف‌پول حسابداری دوطرفه لازم است؟

قاعده اصلی‌اش را لازم دارید: هر جابه‌جایی پول بین حساب‌ها به‌صورت ردیف‌هایی ثبت شود که جمعشان صفر است. این‌که حساب‌های کامل بدهکار و بستانکار مدل کنید یا مبلغ علامت‌دار برای هر حساب، یک انتخاب پیاده‌سازی است.