عنوان:

‫چه نوع طراحی برای جدول Status پیشنهاد میکنید؟


نویسنده: هادی مزارعی
تاریخ: ۱۴۰۵/۰۳/۰۹ ۰۶:۰۶
آدرس: www.dntips.ir
مساله:
1- فرض کنید حدود 500،000 قطعه داریم و برای هر قطعه بین 20 تا 30 عملیات باید انجام شود و هر عملیات ممکن است بیش از یک مرتبه تکرار شود.
2- با توجه به اینکه هر یک از عملیات‌ها داده‌های متفاوتی دارند، برای هر عملیات یک جدول طراحی شده است.
3- در انتها باید یک خروجی با نام Status داشته باشم که چندین ستون در خصوص مشخصات خود قطعه است و حدود 3 ستون از هر جدول (عملیات‌های مربوطه) باید اضافه شوند.

راه حل:
1- میشه در زمان تولید گزارش، جدول قطعات را با سایر جداول بصورت Left Join تولید کرد. البته Join کردن باید با آخرین عملیات انجام بشه:
select Parts.*, A.Column1, A.Column2, A.Column3 from Parts
left join (select top 1 * from operationA where Id = Parts.Id order by CreateDateTime desc) as A on Parts.Id = A.PartId

2- میشه یک جدول Status ایجاد کرد و در زمان ثبت فعالیت‌های هر قطعه، ستون‌های متناسب با هر فعالیت را مقدار دهی کرد و در زمان گزارش تقریبا داده‌ها آماده ارائه هستند.

سوال:
با توجه به تعداد بالای قطعات و وابستگی عملیات‌های زیاد با جدول قطعه، چه راه کاری مناسب‌تر است؟ در این مساله سرعت برای من اولویت هستش.

نظرات

  • وحید نصیری در ۱۴۰۵/۰۳/۰۹ ۰۷:۵۳
    با توجه به اولویت سرعت در زمان گزارش‌گیری و حجم داده (حدود ۵۰۰ هزار قطعه، هر کدام با ۲۰ تا ۳۰ عملیات تکراری)، راهکار دوم (ایجاد جدول Status و به‌روزرسانی آن هنگام ثبت عملیات) به مراتب مناسب‌تر است. در راهکار دوم، خروجی نهایی با یک SELECT ساده از جدول Status قابل تهیه است. حتی با ۵۰۰ هزار ردیف، این کوئری با یک ایندکس متداول روی PartId در کسری از ثانیه اجرا می‌شود. در راهکار اول، برای هر قطعه باید ۲۰ تا ۳۰ بار JOIN با زیرکوئری‌های TOP 1 انجام شود. حتی با ایندکس‌های بهینه، کوئری‌های چندگانه روی میلیون‌ها رکورد (تکرار عملیات) می‌تواند تأخیر زیادی ایجاد کند؛ به‌ویژه وقتی همزمان کاربران زیادی گزارش بگیرند.

    طراحی پیشنهادی راه‌حل Status (بهینه)
    ۱. ساختار جدول
    CREATE TABLE PartStatus (
        PartId BIGINT PRIMARY KEY,
        
        -- اطلاعات اصلی قطعه
        PartNumber NVARCHAR(50),
        PartName NVARCHAR(200),
        Status NVARCHAR(50),           -- وضعیت کلی قطعه
        CurrentStage NVARCHAR(100),
        LastUpdateDate DATETIME2,
        
        -- ستون‌های پراستفاده از عملیات‌ها (حدود ۳ ستون مهم از هر عملیات)
        OpA_Result NVARCHAR(100),
        OpA_Date DATETIME2,
        OpA_Value DECIMAL(18,4),
        
        OpB_Result NVARCHAR(100),
        OpB_Date DATETIME2,
        OpB_Value DECIMAL(18,4),
        
        -- ... همین‌طور برای عملیات‌های مهم
        
        -- اگر تعداد عملیات خیلی زیاد بود، از JSON استفاده کنید (SQL Server 2016+)
        -- ExtraData NVARCHAR(MAX)  -- JSON برای عملیات‌های کم‌اهمیت
    );
    بنابراین با اولویت سرعتی که اعلام کردید، قطعاً به سمت Denormalization (جدول Status) بروید. هزینه نوشتن اضافی کاملاً توجیه‌پذیر است چون گزارش‌گیری در چنین حجمی معمولاً ۱۰۰–۱۰۰۰ برابر بیشتر از نوشتن اتفاق می‌افتد.

    نحوه به‌روزرسانی (دو روش)
    الف) Trigger (ساده‌تر):
    روی هر جدول عملیات Trigger AFTER INSERT, UPDATE بگذارید که PartStatus را به‌روزرسانی کند.

    ب) Application Layer (پیشنهادی):
    در سرویس/ریپازیتوری که عملیات را ثبت می‌کند، بعد از درج موفق در جدول عملیات، تابع UpdatePartStatus را صدا بزنید.

    نکته مهم درباره طراحی فعلی
    این Query:
    select Parts.*, A.Column1
    from Parts
    left join (
        select top 1 *
        from OperationA
        order by CreateDateTime desc
    ) A
    در SQL Server اصولاً برای هر Part درست کار نمی‌کند، چون TOP 1 فقط یک رکورد از کل جدول OperationA برمی‌گرداند. معمولاً باید از OUTER APPLY یا ROW_NUMBER استفاده شود:
    SELECT
        P.*,
        A.Column1
    FROM Parts P
    OUTER APPLY
    (
        SELECT TOP 1 *
        FROM OperationA O
        WHERE O.PartId = P.Id
        ORDER BY O.CreateDateTime DESC
    ) A
    • هادی مزارعی در ۱۴۰۵/۰۳/۰۹ ۲۱:۲۱
      ضمن تشکر از پاسخ شما. با توجه به اینکه تنها آخرین عملیات در Status نمایش داده می‌شوند و با توجه به اینکه آخرین عملیات نیز ممکن است در جدول مربوط به خودش Update شود، آیا استفاده از شناسه جدول عملیات میتونه به عنوان یک راه حل پیاده‌سازی بشه؟ البته با این کار عملیات Left Join باید انجام بشه ولی دیگه نیازی به Outer Apply نیست. ضمن اینکه فقط در زمان Insert کردن آخرین عملیات، جدول Status بر مبنای شناسه قطعه از جدول Parts بروزرسانی خواهد شد.
      • وحید نصیری در ۱۴۰۵/۰۳/۱۰ ۰۷:۴۷
        ایدهٔ استفاده از «شناسه آخرین عملیات (Latest Operation ID)» در جدول PartStatus یک راه‌حل معقول و تمیز است و در بسیاری از سیستم‌ها استفاده می‌شود. اما آیا برای شرایط شما (۵۰۰٬۰۰۰ قطعه + اولویت سرعت) مناسب است یا نه؟

        CREATE TABLE PartStatus (
            PartId BIGINT PRIMARY KEY,
            
            -- اطلاعات پایه قطعه
            PartNumber NVARCHAR(50),
            CurrentStage NVARCHAR(100),
            OverallStatus NVARCHAR(50),
            LastActivityDate DATETIME2,
            
            -- ذخیره فقط شناسه آخرین رکورد هر عملیات
            OpA_LatestId BIGINT NULL,      -- FK به جدول OperationA
            OpB_LatestId BIGINT NULL,
            OpC_LatestId BIGINT NULL,
            -- ... تا ۲۵-۳۰ عملیات
        );
        سپس برای گزارش:
        SELECT 
            p.*,
            A.Column1, A.Column2, A.Column3,
            B.Column1, B.Column2, B.Column3,
            ...
        FROM PartStatus ps
        JOIN Parts p ON p.Id = ps.PartId
        LEFT JOIN OperationA A ON A.Id = ps.OpA_LatestId
        LEFT JOIN OperationB B ON B.Id = ps.OpB_LatestId
        -- و بقیه عملیات‌ها

        مزایا:
        - به‌روزرسانی ساده‌تر: فقط کافی است وقتی یک عملیات جدید ثبت شد (یا آپدیت شد)، PartStatus را آپدیت کنید و LatestId را ست کنید. نیازی به کپی کردن تمام ستون‌ها نیست.
        - Consistency بهتر: داده همیشه از منبع اصلی (جداول عملیات) خوانده می‌شود. اگر آخرین عملیات آپدیت شود، بلافاصله در گزارش اثر می‌کند (بدون نیاز به سینک کردن مقادیر).
        - فضای کمتر نسبت به دنورمالایز کامل (ذخیره فقط BIGINT به جای تکرار ۳ ستون برای هر عملیات).
        - حذف OUTER APPLY و TOP 1 ORDER BY که خودش بهبود قابل توجهی است.

        معایب مهم (باتوجه به اولویت سرعت):
        1. هنوز تعداد Join زیاد است:
        - حتی با LEFT JOIN روی کلید اصلی (Id)، وقتی ۲۰–۳۰ جوین دارید، SQL Server باید ۲۰–۳۰ جدول را به PartStatus جوین کند.
        - در عمل برای ۵۰۰٬۰۰۰ ردیف، optimizer ممکن است Hash Join بزند که حافظه و CPU زیادی مصرف می‌کند.

        2. عملکرد در گزارش‌های بزرگ:
        - هنوز هم کندتر از جدول کاملاً دنورمالایز شده (که تقریباً هیچ جوینی ندارد) است.
        - اگر گزارش شامل فیلتر یا مرتب‌سازی روی ستون‌های عملیات باشد (مثلاً WHERE OpA_Result = OK)، عملکرد افت می‌کند.

        3. مدیریت آپدیت عملیات:
        - اگر یک عملیات آپدیت شود (مثلاً نتیجه تغییر کند)، باید PartStatus را پیدا کنید و LatestId را دوباره ست کنید (چون همان رکورد است، ID تغییر نمی‌کند، اما باید مطمئن شوید که همان ID هنوز آخرین است).


        درکل توجه به اینکه سرعت برای شما اولویت بالاست، همچنان دنورمالایز کامل (کپی کردن ۲-۳ ستون مهم هر عملیات)، پیشنهاد می‌شود.

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

        بهترین تعادل (Hybrid):
        - ۸–۱۲ عملیات پراستفاده و مهم: ستون‌های واقعی (دنورمالایز) در PartStatus
        - بقیه عملیات: فقط LatestId
        اینطوری هم سرعت خوب دارید، هم حجم جدول Status معقول می‌ماند.