موردی که 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 ذخیره کند ، اما شاخص های موجود در آرایه ها به شاخص های جین احتیاج دارند و آنها نظم مرتب سازی را حفظ نمی کنند یا نمایش داده های دامنه را به طور کارآمد کنترل نمی کنند.