Database Engineering

ساخت تاریخچه غیرقابل تغییر: پیاده‌سازی Event Sourcing برای ردیابی حسابرسی در پایگاه‌های داده رابطه‌ای

در معماری نرم‌افزار مدرن، یکپارچگی داده و قابلیت ردیابی الزامات غیرقابل مذاکره هستند. در حالی که بسیاری از توسعه‌دهندگان لاگ‌های حسابرسی را به عنوان یک فکر ثانویه در نظر می‌گیرند و تنها سطری را به یک جدول جداگانه `audit_log` اضافه می‌کنند، رویکردی قوی‌تر Event Sourcing است. با در نظر گرفتن تغییرات وضعیت به عنوان رویدادهای غیرقابل تغییر، شما به تاریخچه کامل و قابل پخش مجدد از تکامل برنامه خود دست می‌یابید.

این پست به بررسی نحوه پیاده‌سازی الگوی Event Sourcing به طور خاص برای ردیابی حسابرسی در سیستم‌های پایگاه داده رابطه‌ای سنتی (مانند PostgreSQL یا MySQL) می‌پردازد و از ویژگی‌های SQL برای تضمین غیرقابل تغییر بودن داده‌ها و عملکرد بهره می‌برد.

مفهوم اصلی: رویدادها در مقابل وضعیت

به طور سنتی، پایگاه‌های داده رابطه‌ای وضعیت فعلی موجودیت‌ها را ذخیره می‌کنند. اگر ایمیل یک کاربر تغییر کند، ایمیل قبلی بازنویسی می‌شود. با Event Sourcing، ما وضعیت را ذخیره نمی‌کنیم؛ بلکه رویدادهایی را که آن وضعیت را ایجاد کرده‌اند، ذخیره می‌کنیم. برای اهداف حسابرسی، این بدان معناست که هر ایجاد، به‌روزرسانی و حذف به عنوان یک رکورد متمایز و فقط-افزودنی (append-only) ثبت می‌شود.

این رویکرد چندین مزیت ارائه می‌دهد:

  • حسابرسی کامل: شما دقیقاً می‌دانید چه چیزی تغییر کرده، چه کسی آن را تغییر داده و چه زمانی.
  • قابلیت پخش مجدد: می‌توانید وضعیت هر رکورد را در هر نقطه از زمان بازسازی کنید.
  • انطباق: الزامات سخت‌گیرانه مقرراتی برای خط سیر داده‌ها (مانند GDPR، HIPAA) را برآورده می‌کند.

طراحی طرحواره برای رویدادهای غیرقابل تغییر

پایه این الگو یک جدول اختصاصی audit_events است. برخلاف جداول استاندارد، این جدول باید غیرقابل تغییر بودن را اعمال کند. در PostgreSQL مدرن، می‌توانیم این کار را با استفاده از WITHOUT OVERWRITE یا با محدود کردن دسترسی‌های DELETE و UPDATE به طور کامل، به زیبایی انجام دهیم.

در اینجا یک تعریف طرحواره قوی آورده شده است:

CREATE TABLE audit_events (
    event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_id UUID NOT NULL,       -- شناسه موجودیت در حال تغییر (مثلاً user_id)
    event_type VARCHAR(50) NOT NULL,  -- مثال: 'USER_CREATED', 'EMAIL_UPDATED'
    payload JSONB NOT NULL,           -- تغییرات داده
    metadata JSONB,                   -- زمینه اضافی مانند آدرس IP، کاربرپسند (user agent)
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT chk_audit_no_delete CHECK (true) -- اعمال شده از طریق مجوزها
);

-- ایندکس‌ها برای بازیابی سریع
CREATE INDEX idx_audit_aggregate ON audit_events(aggregate_id, created_at DESC);
CREATE INDEX idx_audit_type ON audit_events(event_type);

اعمال غیرقابل تغییر بودن با مجوزهای پایگاه داده

بررسی‌های سطح کد می‌توانند دور زده شوند. برای اطمینان از غیرقابل تغییر بودن واقعی، باید عملیات مستقیم DELETE و UPDATE را روی جدول audit_events در سطح پایگاه داده محدود کنیم.

-- لغو مجوزهای نوشتن مستقیم از کاربران برنامه
REVOKE DELETE, UPDATE ON audit_events FROM app_user;

-- ایجاد یک تابع برای درج ایمن رویدادها
CREATE OR REPLACE FUNCTION insert_audit_event(
    p_aggregate_id UUID,
    p_event_type VARCHAR,
    p_payload JSONB,
    p_metadata JSONB
) RETURNS VOID AS $$
BEGIN
    INSERT INTO audit_events (aggregate_id, event_type, payload, metadata)
    VALUES (p_aggregate_id, p_event_type, p_payload, p_metadata);
END;
$$ LANGUAGE plpgsql;

بازسازی وضعیت: مدل خواندن

یکی از چالش‌های Event Sourcing، خواندن داده‌ها است. از آنجا که ما فقط رویدادها را ذخیره می‌کنیم، باید وضعیت فعلی را محاسبه کنیم. اگرچه این کار می‌تواند در کد برنامه انجام شود، اما SQL می‌تواند این کار را با استفاده از توابع پنجره‌ای (window functions) به طور کارآمد انجام دهد.

برای دریافت آخرین نسخه پروفایل یک کاربر، می‌توانید از یک عبارت جدول مشترک (CTE) برای دریافت جدیدترین رویداد برای هر aggregate استفاده کنید:

WITH latest_events AS (
    SELECT 
        aggregate_id,
        event_type,
        payload,
        created_at,
        ROW_NUMBER() OVER (PARTITION BY aggregate_id ORDER BY created_at DESC) as rn
    FROM audit_events
    WHERE aggregate_id = 'user-123-uuid'
)
SELECT payload, created_at
FROM latest_events
WHERE rn = 1;

پیاده‌سازی عملی در لایه برنامه

در کد برنامه خود، باید منطق تجاری که داده‌ها را تغییر می‌دهد در داخل یک تراکنش قرار دهید. وقتی کاربر پروفایل خود را به‌روزرسانی می‌کند، شما فقط جدول users را به‌روزرسانی نمی‌کنید؛ بلکه تابع ذخیره‌شده insert_audit_event را نیز فراخوانی می‌کنید.

برای مثال، در یک سرویس Python یا Node.js:

  • شروع تراکنش
  • اجرای منطق تجاری (به‌روزرسانی جدول users)
  • ذخیره رویداد (فراخوانی تابع ذخیره‌شده)
  • تایید تراکنش (Commit)

این کار تضمین می‌کند که ردیابی حسابرسی و تغییر داده واقعی به صورت یکجا رخ می‌دهند. اگر منطق تجاری شکست بخورد، رکورد حسابرسی نیز برگشت داده می‌شود و از ایجاد رویدادهای یتیم جلوگیری می‌کند.

نتیجه‌گیری

پیاده‌سازی Event Sourcing برای ردیابی حسابرسی در پایگاه‌های داده رابطه‌ای، مکانیزم قدرتمندی برای حفظ یکپارچگی داده‌ها و انطباق فراهم می‌کند. با جداسازی ذخیره‌سازی رویدادها از وضعیت فعلی، شما سیستمی ایجاد می‌کنید که نه تنها شفاف‌تر، بلکه ذاتاً قوی‌تر است. اگرچه این کار پیچیدگی مسیر خواندن را افزایش می‌دهد، اما مزایای یک تاریخچه غیرقابل تغییر و قابل پخش مجدد، برای سیستم‌های حیاتی بسیار بیشتر از هزینه‌های آن است. با یک جدول ساده audit_events شروع کنید، غیرقابل تغییر بودن را در سطح پایگاه داده اعمال کنید و به تدریج قابلیت‌های پخش مجدد خود را با رشد نیازهایتان توسعه دهید.

Share: