برنامه نویسی

موردی که SQL به مبارزه می پیوندد اما اسناد MongoDB می درخشد

ادعاهایی از قبیل “پیوستن آهسته” یا “پیوستن به مقیاس” نیست ، اغلب من را وادار می کند تا نشان دهم که چگونه می توان ردیف های کارآمد را در یک پایگاه داده SQL (به عنوان مثال) پیوست. با این حال ، درک کاربر از کندی باقی مانده است ، و گوش دادن به توسعه دهندگان و درک مشکلات آنها با پیوستن ضروری است.

پیوستن به جداول در پایگاه داده های رابطه ای گاهی اوقات می تواند منجر به برنامه های اجرای زیر حداقلی شود که فیلتر داده ها پس از زایمان رخ می دهد. این محدودیت بوجود می آید زیرا شاخص ها در هنگام انتخابات انتخابی در همان جدول کارآمد هستند. با این حال ، حداقل شاخص چند جدول ، حداقل برای OLTP وجود ندارد. انبارهای داده در پایگاه داده های اوراکل می توانند از شاخص های پیوستن Bitmap ، تحول ستاره و نماهای مادی برای غلبه بر این امر استفاده کنند ، اما آنها برای بار کاری OLTP مناسب نیستند. این برنامه های اعدام زیر حد متوسط ​​ممکن است به همین دلیل باشد که توسعه دهندگان فکر می کنند که پیوستن به آنها کند است ، حتی اگر این امر به خودی خود انجام شود که کند باشد.

سوءاستفاده می تواند کمک کند ، اما مزایای عادی سازی را تضعیف می کند. در مقابل ، بانکهای اطلاعاتی اسناد مانند MongoDB از اسناد تعبیه شده برای بهینه سازی نمایش داده های پیچیده با پیوندهای کمتری استفاده می کنند ، و شاخص های کامپوزیت چند کلید مسیرهای دسترسی کارآمد را ارائه می دهند که تمام فیلترهای انتخابی را پوشش می دهد.

در اینجا مثالی در PostgreSQL و MongoDB آورده شده است.

مدل رابطه PostgreSQL

بانکهای اطلاعاتی رابطه ای روابط یک به یک را به جداول جداگانه عادی می کنند. به عنوان مثال ، رابطه بین سفارشات و جزئیات مربوط به سفارش آنها را در نظر بگیرید ، جایی که هر سفارش می تواند چندین ورودی مرتبط در جدول جزئیات سفارش داشته باشد.

CREATE TABLE orders(
  id BIGSERIAL PRIMARY KEY,
  country_id INT,
  created_at TIMESTAMPTZ DEFAULT clock_timestamp()
);

CREATE TABLE order_details (
  id BIGINT REFERENCES orders ON DELETE CASCADE,
  line INT,
  product_id BIGINT,
  quantity INT,
  PRIMARY KEY(id, line)
);
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

من برخی از داده ها را با توزیع محصولاتی که با گذشت زمان در سفارشات کاهش می یابد ، وارد می کنم:

BEGIN TRANSACTION;

INSERT INTO orders(country_id)
 SELECT 10 * random() FROM generate_series(1,1000000);

INSERT INTO order_details (id, line, product_id, quantity)
 SELECT 
  id,
  generate_series(1,10),
  log(2,(1 + id * random())::int),
  100 * random()
  FROM orders;

COMMIT;
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

یک مدل داده رابطه ای به الگوهای دسترسی خاص بستگی ندارد. بهینه سازی این الگوهای نیاز به شاخص دارد. کلیدهای اصلی من شاخص های تعریف شده برای پیمایش بین سفارشات و جزئیات سفارش را تعریف می کنند ، اما من مورد استفاده دیگری دارم.
برای تجزیه و تحلیل سفارشات کشور ، محصول و محدوده تاریخ ، من شاخص های زیر را ایجاد می کنم:


CREATE INDEX ON orders (country_id, created_at DESC, id);

CREATE INDEX ON order_details (product_id, id);

حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

برای تجزیه و تحلیل آخرین سفارشات برای یک کشور و محصول خاص ، من از پرس و جو SQL زیر استفاده می کنم:


PREPARE query(int,int,int) AS
 SELECT id, created_at, product_id, quantity
 FROM orders
 JOIN order_details d USING(id)
 WHERE country_id=$1 AND product_id=$2
 ORDER BY created_at DESC LIMIT $3
;

حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

من خلاء می کنم و تجزیه و تحلیل می کنم تا بهترین برنامه اعدام را بدست آورم:

postgres=# VACUUM ANALYZE orders, order_details;
VACUUM
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

در حالت ایده آل ، چنین پرس و جو باید فقط ردیف های یک کشور و یک محصول را بخوانید و آنها را به تاریخ سفارش دهید تا بدون نیاز به خواندن همه ردیف ها و مرتب سازی آنها ، سفارش TOP-N را با محدودیت اعمال کنند.

postgres=# EXPLAIN (analyze, buffers, costs off)
           EXECUTE query(1, 15, 10)
;
                                                                                                                                                                                                 QUERY PLAN                                                         
----------------------------------------------------------------------------------------------------------------------------
 Limit (actual time=0.031..0.110 rows=10 loops=1)
   Buffers: shared hit=132
   ->  Nested Loop (actual time=0.030..0.108 rows=10 loops=1)
         Buffers: shared hit=132
         ->  Index Only Scan using orders_country_id_created_at_id_idx on orders (actual time=0.011..0.023 rows=39 loops=1)
               Index Cond: (country_id = 1)
               Heap Fetches: 0
               Buffers: shared hit=5
         ->  Index Scan using order_details_product_id_id_idx on order_details d (actual time=0.002..0.002 rows=0 loops=39)
               Index Cond: ((product_id = 15) AND (id = orders.id))
               Buffers: shared hit=127
 Planning:
   Buffers: shared hit=16
 Planning Time: 0.272 ms
 Execution Time: 0.127 ms
(15 rows)

حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

برنامه اعدام مؤثر است ، با شروع شاخص روشن orders.created_at برای از بین بردن نیاز به مرتب سازی. برای حفظ سفارش و فشار دادن فیلتر پیوست ، از یک حلقه تو در تو در توخالی برای بازیابی ردیف ها از جدول دیگر استفاده می کند.
از آنجا که فیلتر دیگری وجود دارد order_details.product_id، پس از پیوستن ، مجبور شد ردیف های بیشتری را بخواند (rows=39) برای به دست آوردن ردیف های مورد نیاز نهایی (rows=10) ، و سپس حلقه های بیشتر. از آنجا که مثال من کوچک است ، نتیجه از نظر زمان حداقل ، حلقه های تو در تو است (loops=39) و بافر (shared hit=127) ، اما مسئله را برجسته می کند: برای بازگشت نتایج به چهار ردیف و ده صفحه برای هر ردیف نیاز دارد.

اگر من همان پرس و جو را با محصول دیگری که اخیراً سفارش داده نشده است اجرا کنم ، قبل از یافتن ده مورد که شامل این محصول است ، سفارشات زیادی را می خواند:

postgres=# EXPLAIN (analyze, buffers, costs off)
           EXECUTE query(1, 8, 10)
;
                                                                 QUERY PLAN                                                                  
---------------------------------------------------------------------------------------------------------------------------------------------
 Limit (actual time=15.614..16.661 rows=10 loops=1)
   Buffers: shared hit=37582
   ->  Gather Merge (actual time=15.613..16.659 rows=10 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=37582
         ->  Nested Loop (actual time=1.396..9.112 rows=7 loops=3)
               Buffers: shared hit=37582
               ->  Parallel Index Only Scan using orders_country_id_created_at_id_idx on orders (actual time=0.015..0.546 rows=4165 loops=3)
                     Index Cond: (country_id = 1)
                     Heap Fetches: 0
                     Buffers: shared hit=70
               ->  Index Scan using order_details_product_id_id_idx on order_details d (actual time=0.002..0.002 rows=0 loops=12494)
                     Index Cond: ((product_id = 8) AND (id = orders.id))
                     Buffers: shared hit=37512
 Planning:
   Buffers: shared hit=16
 Planning Time: 0.272 ms
 Execution Time: 16.684 ms
(19 rows)
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

برای به دست آوردن نتیجه 10 ردیف ، این اعدام 12495 ردیف با 3 فرآیند موازی (ردیف = 4165 حلقه = 3) و در کل 37582 صفحه خوانده است ، قبل از آنکه نتواند Top-10 را پیدا کند که همه فیلترها را تأیید کند.

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

هنگامی که برنامه ریز پرس و جو تخمین می زند که این بیش از حد است ، تصمیم نمی گیرد که از مرتب سازی خودداری کند و به یک هش هش سوئیچ می کند.


postgres=# EXPLAIN (analyze, buffers, costs off)
           EXECUTE query(1, 5, 10)
;
                                                                 QUERY PLAN                                                                                                      
--------------------------------------------------------------------------------------------------------------------------------------------                                     
 Limit (actual time=30.370..30.373 rows=10 loops=1)
   Buffers: shared hit=1882
   ->  Sort (actual time=30.369..30.371 rows=10 loops=1)
         Sort Key: orders.created_at DESC
         Sort Method: top-N heapsort  Memory: 26kB
         Buffers: shared hit=1882
         ->  Hash Join (actual time=28.466..30.324 rows=236 loops=1)
               Hash Cond: (d.id = orders.id)
               Buffers: shared hit=1882
               ->  Index Scan using order_details_product_id_id_idx on order_details d (actual time=0.013..1.434 rows=2311 loops=1)                                              
                     Index Cond: (product_id = 5)
                     Buffers: shared hit=1387
               ->  Hash (actual time=28.400..28.401 rows=99672 loops=1)
                     Buckets: 131072  Batches: 1  Memory Usage: 5697kB
                     Buffers: shared hit=495
                     ->  Index Only Scan using orders_country_id_created_at_id_idx on orders (actual time=0.010..13.136 rows=99672 loops=1)                                      
                           Index Cond: (country_id = 1)
                           Heap Fetches: 0
                           Buffers: shared hit=495
 Planning:
   Buffers: shared hit=16
 Planning Time: 0.267 ms
 Execution Time: 30.415 ms
(23 rows)

حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

این طرح به اندازه نتیجه بستگی ندارد اما باید ردیف های زیادی را بخوانید (rows=2311 وت rows=99672) قبل از پیوستن ، فیلتر کردن آنها (به rows=236) ، و مرتب سازی آنها. این جایی است که به یک مشکل مقیاس پذیری تبدیل می شود: زمان پاسخ به اندازه اندازه پایگاه داده بستگی دارد تا اندازه نتیجه. پرس و جو که قرار است سفارشات را از یک پنجره زمانی کوچک بخواند ، باید کل تاریخ سفارشات را برای یک کشور و کل تاریخچه جزئیات برای یک محصول بخواند.

توجه داشته باشید که این مثال بهترین حالت است ، جایی که جداول تازه خالی شده اند ، و Index Only Scan بهینه با Heap Fetches: 0بشر در یک میز فعال گران تر خواهد بود.

مدل سند MongoDB

مدل سند MongoDB اجازه می دهد تا داده های مرتبط را در یک مجموعه واحد تعبیه کنید ، و بهینه سازی محل داده در حافظه و دیسک.

در اینجا مجموعه ای وجود دارد که داده های مشابهی را با نمونه قبلی بارگذاری می کند ، اما با جزئیات سفارش تعبیه شده در سند سفارشات ، مانند آن که در یک سند تجاری یا یک شیء برنامه ساخته شده است:

const bulkOps = [];
for (let i = 0; i < 1000000; i++) {
  const orderDetails = [];
  for (let line = 1; line <= 10; line++) {
    orderDetails.push({
      line: line,
      product_id: Math.floor(Math.log2(1 + i * Math.random())),
      quantity: Math.floor(100 * Math.random()),
    });
  }
  bulkOps.push({
    insertOne: {
      document: {
        country_id: Math.floor(10 * Math.random()),
        created_at: new Date(),
        order_details: orderDetails
      }
    }
  });
}
db.orders.bulkWrite(bulkOps);
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

یکی از مزیت های مدل سند ، امکان دریافت سفارش با جزئیات آن و بدون پیوستن است:

test> db.orders.find().sort({created_at: -1}).limit(1);
[
  {
    _id: ObjectId('67f1a477aabaf2dad73f4791'),
    country_id: 3,
    created_at: ISODate('2025-04-05T21:45:21.546Z'),
    order_details: [
      { line: 1, product_id: 19, quantity: 40 },
      { line: 2, product_id: 18, quantity: 10 },
      { line: 3, product_id: 18, quantity: 75 },
      { line: 4, product_id: 18, quantity: 81 },
      { line: 5, product_id: 16, quantity: 66 },
      { line: 6, product_id: 14, quantity: 17 },
      { line: 7, product_id: 19, quantity: 82 },
      { line: 8, product_id: 19, quantity: 81 },
      { line: 9, product_id: 17, quantity: 56 },
      { line: 10, product_id: 19, quantity: 59 }
    ]
  }
]
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

داشتن تمام زمینه ها در یک سند امکان ایجاد یک فهرست واحد را فراهم می کند که تمام فیلترها را در بر می گیرد ، و MongoDB از شاخص های چند کلید پشتیبانی می کند ، که زمینه های فهرست بندی را در زیر مجموعه های تعبیه شده امکان پذیر می کند:

db.orders.createIndex(
 { "country_id": 1, "order_details.product_id": 1, "created_at": -1 }
);
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

پرس و جو برای بازنشستگی ده سفارش آخر برای یک کشور و یک محصول بدون پیوستن ساده است:

db.orders.find(
  { country_id: 1, order_details: { $elemMatch: { product_id: 15 } } }
).sort({ created_at: -1 }).limit(10);
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

بیایید برنامه اجرای را بررسی کنیم:

mdb> db.orders.find(
   { country_id: 1, order_details: { $elemMatch: { product_id: 15 } } }
 ).sort({ created_at: -1 }).limit(10).explain(`executionStats`).executionStats
;
{
  executionSuccess: true,
  nReturned: 10,
  executionTimeMillis: 0,
  totalKeysExamined: 10,
  totalDocsExamined: 10,
  executionStages: {
    isCached: false,
    stage: 'LIMIT',
    nReturned: 10,
    executionTimeMillisEstimate: 0,
    works: 11,
    advanced: 10,
    limitAmount: 10,
    inputStage: {
      stage: 'FETCH',
      filter: {
        order_details: { '$elemMatch': { product_id: { '$eq': 15 } } }
      },
      nReturned: 10,
      executionTimeMillisEstimate: 0,
      works: 10,
      advanced: 10,
      docsExamined: 10,
      alreadyHasObj: 0,
      inputStage: {
        stage: 'IXSCAN',
        nReturned: 10,
        executionTimeMillisEstimate: 0,
        works: 10,
        advanced: 10,
        keyPattern: {
          country_id: 1,
          'order_details.product_id': 1,
          created_at: -1
        },
        indexName: 'country_id_1_order_details.product_id_1_created_at_-1',
        isMultiKey: true,
        multiKeyPaths: {
          country_id: [],
          'order_details.product_id': [ 'order_details' ],
          created_at: []
        },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: {
          country_id: [ '[1, 1]' ],
          'order_details.product_id': [ '[15, 15]' ],
          created_at: [ '[MaxKey, MinKey]' ]
        },
        keysExamined: 10,
        seeks: 1,
        dupsTested: 10,
        dupsDropped: 0
      }
    }
  }
}

حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

این طرح جزئیات زیادی را نشان می دهد ، اما مهمترین آنها:

  nReturned: 10,
  totalKeysExamined: 10,
  totalDocsExamined: 10,
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

برای به دست آوردن 10 ردیف برای نتیجه ، MongoDB فقط 10 ورودی فهرست و 10 سند را خوانده است. این بهینه ترین ، خواندن فقط آنچه لازم است. اسکن شاخص بهینه است زیرا حاوی مرزهای تمام فیلترهای برابری است و ردیف ها را بدون نیاز به یک نوع اضافی سفارش می دهد:

        indexBounds: {
          country_id: [ '[1, 1]' ],
          'order_details.product_id': [ '[15, 15]' ],
          created_at: [ '[MaxKey, MinKey]' ]
        },
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

علاوه بر سریع بودن ، عملکرد قابل پیش بینی است زیرا این طرح اجرای همیشه یکسان خواهد بود. این قابل مشاهده است allPlansExecution:

mdb> db.orders.find(
   { country_id: 1, order_details: { $elemMatch: { product_id: 15 } } }
 ).sort({ created_at: -1 }).limit(10).explain(`allPlansExecution`).queryPlanner
;
{
  namespace: 'test.orders',
  parsedQuery: {
    '$and': [
      {
        order_details: { '$elemMatch': { product_id: { '$eq': 42 } } }
      },
      { country_id: { '$eq': 1 } }
    ]
  },
  indexFilterSet: false,
  queryHash: '0DAE06A4',
  planCacheShapeHash: '0DAE06A4',
  planCacheKey: 'C3D96884',
  optimizationTimeMillis: 0,
  maxIndexedOrSolutionsReached: false,
  maxIndexedAndSolutionsReached: false,
  maxScansToExplodeReached: false,
  prunedSimilarIndexes: false,
  winningPlan: {
    isCached: false,
    stage: 'LIMIT',
    limitAmount: 10,
    inputStage: {
      stage: 'FETCH',
      filter: {
        order_details: { '$elemMatch': { product_id: { '$eq': 42 } } }
      },
      inputStage: {
        stage: 'IXSCAN',
        keyPattern: {
          country_id: 1,
          'order_details.product_id': 1,
          created_at: -1
        },
        indexName: 'country_id_1_order_details.product_id_1_created_at_-1',
        isMultiKey: true,
        multiKeyPaths: {
          country_id: [],
          'order_details.product_id': [ 'order_details' ],
          created_at: []
        },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: {
          country_id: [ '[1, 1]' ],
          'order_details.product_id': [ '[42, 42]' ],
          created_at: [ '[MaxKey, MinKey]' ]
        }
      }
    }
  },
  rejectedPlans: []
}

حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

فقط یک برنامه در حافظه نهان وجود دارد ، و هیچ برنامه ای رد نشده است.

پایان

هنگامی که توسعه دهندگان ادعا می کنند که “پیوستن آهسته” است ، مربوط به خود اعدام نیست. بانکهای اطلاعاتی SQL دارای ده ها سال بهینه سازی هستند ، با روش های مختلف پیوستن ، ذخیره و بافر. با این حال ، هنگامی که فیلترها نمی توانند قبل از پیوستن به پایین فشار بیایند ، از فیلتر زودرس جلوگیری می کند ، در نتیجه پیوستن به ردیف بیش از حد ، زمان پاسخ طولانی و برنامه های اجرای ناپایدار.

توسعه دهندگان و DBA موقعیت های مختلف را متفاوت می دانند. توسعه دهندگان زمان پاسخ آهسته و پیوندهای مشکل ساز را تجربه می کنند ، در حالی که DBA کارآیی پایگاه داده را در پیوستن به تعداد زیادی ردیف مشاهده می کند. به عنوان مثال ، هنگامی که میلیون ها ردیف برای بازگشت یک نتیجه کوچک در چند ثانیه پیوستند ، مسائل مربوط به DBAS را به نمایش داده شدگان ضعیف نشان می دهد ، در حالی که توسعه دهندگان پایگاه داده رابطه ای را به چالش می کشند.

یک بانک اطلاعاتی اسناد می تواند مدل سازی داده ها و عملکرد پرس و جو را افزایش دهد ، به خصوص هنگامی که شاخص های چند کلید برابری ، مرتب سازی و دامنه را بهینه می کنند. MongoDB از این امر به راحتی پشتیبانی می کند ، زیرا شاخص های آن برای اسناد طراحی شده اند. PostgreSQL می تواند اسناد را در JSONB ذخیره کند ، اما شاخص های موجود در آرایه ها به شاخص های جین احتیاج دارند و آنها نظم مرتب سازی را حفظ نمی کنند یا نمایش داده های دامنه را به طور کارآمد کنترل نمی کنند.

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا