Տվյալների բազայի նախագծում
Մոդելավորեք entity-ները, key-երը, index-ները, գումարը և ժամանակը այնպես, որ սխեման դիմանա տարիների փոփոխություններին։
30 վայրկյանում
Տվյալների բազայի նախագծումը որոշելն է, թե ինչ աղյուսակներ են պետք, ինչպես են դրանք կապված իրար, և որ կանոնները պետք է պարտադրի հենց տվյալների բազան։ Լավ սխեման ճիշտ տվյալները դարձնում է հեշտ, սխալները՝ անհնար, և հարցումներն արագ է պահում, երբ տվյալները աճում են։ Կոդը հեշտ է վերագրել, իսկ տվյալներն ու սխեմաները ապրում են տարիներ, ուստի հենց այստեղ է, որ զգույշ մտածելը ամենաշատն է արդարանում։
Ինչու եմ օգտագործում
- Սխեմայի նախագծմանը իրական ժամանակ եմ տրամադրում, որովհետև commerce համակարգերում իմ տեսած գրեթե յուրաքանչյուր լուրջ բագ հանգում էր տվյալների, որոնք տվյալների բազան թույլ էր տվել, բայց բիզնեսը՝ ոչ։
- Նախագծում եմ՝ հաշվի առնելով փոփոխությունը. ապրանքները ստանում են variant-ներ, պատվերները՝ արժույթներ, single-tenant հավելվածները դառնում են multi-tenant, և լավ սխեման դա կլանում է migration-ներով, ոչ թե վերագրումներով։
- Թույլ եմ տալիս, որ տվյալների բազան պարտադրի այն կանոնները, որոնցում ինքը լավն է (foreign key-եր, unique constraint-ներ, NOT NULL, check-եր), որպեսզի հավելվածի բագերը չկարողանան լուռ փչացնել տվյալները։
- Գումարը, ժամանակը և նույնացուցիչները դիտարկում եմ որպես հատուկ դեպքեր՝ ֆիքսված կանոններով, որովհետև այնտեղ սխալները թանկ են և դժվար է դրանք հետ շտկել։
Ինչպես է աշխատում
01Entity-ներ և կապեր
Սկսեք բիզնեսից, ոչ թե աղյուսակներից։ Թվարկեք գոյականները (հաճախորդ, ապրանք, պատվեր, վճարում) և դրանք կապող բայերը (հաճախորդը պատվերներ է տալիս, պատվերը պարունակում է ապրանքներ)։ Յուրաքանչյուր կարևոր գոյական դառնում է աղյուսակ՝ entity։ Այնուհետև որոշեք յուրաքանչյուր կապի cardinality-ն։ One-to-many-ն ամենատարածվածն է. մեկ հաճախորդն ունի շատ պատվերներ, ուստի orders-ը ստանում է customer_id foreign key։ Many-to-many-ն, ինչպես ապրանքներն ու կատեգորիաները, պահանջում է join աղյուսակ (product_categories)՝ երկու foreign key-երով և composite primary key-ով։ One-to-one-ը հազվադեպ է և սովորաբար նշանակում է ոչ պարտադիր լրացուցիչ տվյալներ, օրինակ՝ հաճախորդի պրոֆիլ։ Foreign key-երը ստիպում են տվյալների բազային մերժել «որբ» տողերը. ON DELETE վարքագիծը ընտրեք գիտակցաբար՝ RESTRICT բիզնես գրառումների համար, CASCADE՝ միայն իսկական «երեխաների» համար, ինչպես draft զամբյուղի order item-ները։
- Գոյականները դառնում են աղյուսակներ, բայերը՝ կապեր։
- 1:N. foreign key «շատ» կողմում։
- N:M. join աղյուսակ՝ composite key-ով։
- Անվանեք միատեսակ. աղյուսակները՝ հոգնակի, foreign key-երը՝ singular_id, snake_case։
02Նորմալիզացիա և դենորմալիզացիա
Նորմալիզացիան նշանակում է յուրաքանչյուր փաստը պահել մեկ անգամ։ Գործնականում թիրախը երրորդ նորմալ ձևն է (3NF). յուրաքանչյուր սյունակ կախված է key-ից, ամբողջ key-ից և ոչ մի բանից, բացի key-ից։ Եթե հաճախորդի email-ը պատճենված է յուրաքանչյուր պատվերում, այն փոխելը նշանակում է թարմացնել շատ տողեր, և որոշները բաց կթողնվեն։ Դենորմալիզացիան տվյալների դիտավորյալ պատճենումն է՝ արագության կամ պատմության համար։ Երկու դեպք ոչ միայն թույլատրելի են, այլև պարտադիր։ Snapshot-ներ. order item-ը պետք է պահի ապրանքի անունը և միավորի գինը գնման պահին, որովհետև ապրանքը հետագայում կփոխվի։ Read model-ներ. customers-ի վրա cache արված order_count կամ search index։ Լռելյայն նորմալիզացրեք, դենորմալիզացրեք գիտակցաբար և փաստաթղթավորեք, թե ով է պատճենը համաժամեցված պահում՝ trigger, job, թե հավելվածը։
03Key-եր. natural, թե՞ surrogate, UUID, թե՞ bigint
Natural key-ը իրական աշխարհի արժեք է, ինչպես email-ը կամ SKU-ն. surrogate key-ը իմաստ չունեցող գեներացված id է։ Natural արժեքները փոխվում են (մարդիկ փոխում են email-ները, SKU-ները վերանվանվում են), ուստի օգտագործեք surrogate primary key և natural արժեքը պաշտպանեք UNIQUE constraint-ով։ Surrogate-ի համար bigint auto-increment-ը կոմպակտ է (8 բայթ), արագ և B-tree-ում insert-ները հերթականությամբ է պահում, բայց id-ները գուշակելի են և պետք է գեներացվեն տվյալների բազայի կողմից։ UUID-ները (16 բայթ) կարող են գեներացվել ցանկացած տեղում, ինչը հարմար է բաշխված համակարգերի, offline կլիենտների և հանրային URL-ների համար։ Պատահական UUIDv4-ը insert-ները ցրում է ամբողջ index-ով և մեծ մասշտաբում վնասում գրելու արագությանը. ժամանակով դասավորված UUIDv7-ը լուծում է դա։ PostgreSQL 18-ն ունի ներկառուցված uuidv7() ֆունկցիա, մյուս դեպքերում v7-ը գեներացրեք հավելվածում։
- bigint. ներքին աղյուսակներ, join-եր, գրելու մեծ ծավալ։
- UUIDv7. հանրային id-ներ, offline կամ բազմահամակարգ ստեղծում։
- Երբեք մի բացահայտեք հաջորդական id-ներ այնտեղ, որտեղ թվարկումը (enumeration) ռիսկ է։
- Natural key-երը պահեք որպես UNIQUE սյունակներ, ոչ թե primary key-եր։
04Index-ների ռազմավարություն
Index-ը որոշ սյունակների դասավորված պատճենն է, որը թույլ է տալիս տվյալների բազային գտնել տողերը՝ առանց ամբողջ աղյուսակը scan անելու, ինչպես գրքի վերջում գտնվող առարկայական ցանկը։ Յուրաքանչյուր primary key և unique constraint ունի այդպիսին։ Ավելացրեք մյուսները այն հարցումների համար, որոնք իսկապես կատարում եք. join-երում օգտագործվող foreign key-եր, հաճախակի WHERE և ORDER BY կետերում հանդիպող սյունակներ։ Composite index-ում սյունակների հերթականությունը կարևոր է։ Leftmost-prefix կանոնի համաձայն՝ (customer_id, created_at) index-ն օգնում է customer_id-ով կամ customer_id-ով և ամսաթվերի միջակայքով ֆիլտրող հարցումներին, բայց ոչ միայն created_at-ով ֆիլտրողներին։ Հավասարության սյունակները դրեք առաջինը, իսկ միջակայքի կամ դասավորման սյունակները՝ վերջում։ Index-ները անվճար չեն. յուրաքանչյուրը դանդաղեցնում է գրելը և հիշողություն է զբաղեցնում, ուստի հեռացրեք չօգտագործվողները։ Միշտ հաստատեք EXPLAIN-ով։
-- "latest orders of a customer" in one index scan
CREATE INDEX orders_customer_created_idx
ON orders (customer_id, created_at DESC);
EXPLAIN
SELECT id, total_minor, status
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 20;
05Գումար և ժամանակ
Երբեք գումար մի պահեք floating-point սյունակում. բինար float-ներում 0.1 + 0.2-ը հավասար չէ 0.3-ի, և կլորացման սխալները կուտակվում են հազարավոր պատվերներում։ Օգտագործեք ամբողջ թվով մանր միավորներ (ցենտեր) bigint-ում կամ DECIMAL/NUMERIC՝ ֆիքսված scale-ով, և գումարի կողքին միշտ պահեք արժույթի կոդը (ISO 4217, օրինակ՝ EUR կամ USD)։ Գները, զեղչերը, հարկը և ընդհանուր գումարները պահեք հենց պատվերի վրա, որպեսզի պատմությունը երբեք չփոխվի։ Ժամանակի համար պահեք պահերը UTC-ով։ PostgreSQL-ի timestamptz-ը պահում է UTC և ցուցադրելիս փոխարկում. MySQL-ում DATETIME-ը time zone չունի, ուստի պայմանավորվածությամբ պահեք UTC, և խուսափեք TIMESTAMP-ից հեռավոր ապագայի ամսաթվերի համար, որովհետև այն ավարտվում է 2038-ին։ Ապագա տեղական իրադարձությունների համար, օրինակ՝ որևէ քաղաքում ժամը 10:00-ին ամրագրում, պահեք տեղական ժամը գումարած IANA time zone-ի անունը, օրինակ՝ Asia/Yerevan, որովհետև time zone-ների կանոնները կարող են փոխվել։
- Գումարներ. bigint մանր միավորներ կամ NUMERIC(19,4), երբեք float։
- Գումարի կողքին միշտ պահեք արժույթը։
- Պահերը՝ UTC-ով, ապագա տեղական իրադարձությունները՝ տեղական ժամ + IANA zone։
06Soft delete-ներ և անվտանգ migration-ներ
Soft delete-ը տողը նշում է որպես ջնջված (deleted_at timestamp)՝ այն հեռացնելու փոխարեն, որպեսզի կարողանաք վերականգնել այն և պահպանել պատմությունը։ Գինը՝ յուրաքանչյուր հարցում պետք է ֆիլտրի այն, իսկ unique constraint-ները պետք է անտեսեն ջնջված տողերը։ PostgreSQL-ում դա լուծում է partial unique index-ը, MySQL-ում օգտագործեք generated column։ Soft delete-ներ օգտագործեք օգտատիրոջը տեսանելի կոնտենտի համար, ոչ թե ամեն ինչի. audit-ի կարիքների համար append-only history աղյուսակը հաճախ ավելի մաքուր է, իսկ գաղտնիության օրենքները կարող են պահանջել իրական ջնջում։ Migration-ները փոխում են կենդանի սխեման, ուստի դրանք դարձրեք հետադարձ համատեղելի աշխատող կոդի հետ՝ expand and contract մոտեցմամբ. ավելացրեք նոր nullable սյունակը, deploy արեք կոդ, որը գրում է երկուսում էլ, backfill արեք խմբաքանակներով, փոխեք կարդալը, ապա ավելի ուշ release-ում հեռացրեք հին սյունակը։ Մեծ index-ները կառուցեք առանց գրելը արգելափակելու (PostgreSQL-ում՝ CREATE INDEX CONCURRENTLY)։
-- PostgreSQL: email unique only among non-deleted customers
CREATE UNIQUE INDEX customers_email_live_uq
ON customers (lower(email))
WHERE deleted_at IS NULL;
-- expand step: safe on a live table
ALTER TABLE orders ADD COLUMN currency char(3);
-- backfill in batches, then in a later release:
-- ALTER TABLE orders ALTER COLUMN currency SET NOT NULL;
07Օրինակ. e-commerce-ի հիմնական սխեմա
Ահա կոմպակտ հիմք, որը ցույց է տալիս գաղափարները միասին։ Products-ը պահում է ընդհանուր տեղեկատվությունը, variants-ը՝ այն, ինչ իրականում վաճառվում է (չափս, գույն, SKU, գին)։ Պատվերները պատկանում են հաճախորդին, պահում են իրենց արժույթն ու ընդհանուր գումարները և ունեն կարգավիճակ՝ CHECK constraint-ով։ Order item-ները հղվում են variant-ին, բայց նաև snapshot են անում անունը և միավորի գինը, որպեսզի հինգ տարի անց տպված պատվերը դեռ ցույց տա, թե ինչ է վճարել հաճախորդը։ Վճարումները առանձին աղյուսակ են, որովհետև մեկ պատվերը կարող է ունենալ մի քանի փորձ, refund կամ մասնակի capture։ Այս հիմքի շուրջ կավելացնեիք հասցեներ (որոնք նույնպես snapshot են արվում պատվերի վրա), պահեստի շարժեր, կիրառված ակցիաներ և կարգավիճակների պատմության աղյուսակ՝ audit-ի համար։
CREATE TABLE products (id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name text NOT NULL);
CREATE TABLE variants (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
product_id bigint NOT NULL REFERENCES products(id),
sku text NOT NULL UNIQUE,
price_minor bigint NOT NULL CHECK (price_minor >= 0));
CREATE TABLE orders (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
customer_id bigint NOT NULL REFERENCES customers(id),
status text NOT NULL CHECK (status IN ('pending','paid','shipped','cancelled','refunded')),
currency char(3) NOT NULL,
total_minor bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(id),
variant_id bigint NOT NULL REFERENCES variants(id),
name_snapshot text NOT NULL,
unit_price_minor bigint NOT NULL,
qty int NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, variant_id));
Օգտագործեք, երբ
- Բիզնես տվյալներ պահող ցանկացած պրոդուկտի առաջին migration-ը գրելուց առաջ։
- Մեծ ֆունկցիոնալություն ավելացնելիս, օրինակ՝ variant-ներ, multi-currency, multi-tenancy կամ loyalty։
- Երբ հարցումները դանդաղում են կամ հաշվետվություններում տվյալների անհամապատասխանություններ են հայտնվում։
- Migration-ներին դիպչող pull request-ները review անելիս։
Մի՛ օգտագործեք, երբ
- Մեկանգամյա prototype-ներ և spike-եր, որտեղ արագ սխեման նորմալ է, քանի դեռ ոչ ոք այն production չի հանում։
- Մի գերմոդելավորեք ապագայի հազվադեպ դեպքերը. նախագծեք հաջորդ իրատեսական քայլի համար և migration-ները պահեք էժան։
Այլընտրանքներ
- Document databases (MongoDB)
Ճկուն ներդրված փաստաթղթեր. լավ է կոնտենտի նման տվյալների համար, ավելի թույլ՝ ռելյացիոն կանոնների և բազմատող transaction-ների համար։
- JSON columns in a relational database
JSONB կամ JSON իսկապես փոփոխական ատրիբուտների համար՝ հիմնական դաշտերը ռելյացիոն պահելով։
- Event sourcing
Յուրաքանչյուր փոփոխություն պահել որպես իրադարձություն և դրանից ստանալ վիճակը. հզոր է audit-ով ծանրաբեռնված ոլորտների համար, բայց շատ ավելի բարդ։
Լավագույն պրակտիկաներ
- Կանոնները պարտադրեք տվյալների բազայում՝ NOT NULL, foreign key-եր, UNIQUE և CHECK constraint-ներ։
- Օգտագործեք surrogate primary key-եր և natural key-երը պահեք unique։
- Index-ներ ստեղծեք իրական հարցումների համար՝ composite index-ներում հավասարության սյունակները առաջինը։
- Գումարը պահեք որպես ամբողջ թվեր կամ ֆիքսված decimal-ներ՝ արժույթի կոդով։
- Պահերը պահեք UTC-ով, իսկ գներն ու հասցեները snapshot արեք պատվերների վրա։
- Գրեք expand-and-contract migration-ներ, որոնք անվտանգ են աշխատող կոդի համար։
- Աղյուսակները, սյունակները և constraint-ները անվանեք միատեսակ։
Տարածված սխալներ
- Գումարը պահել որպես FLOAT կամ DOUBLE։
- Հին պատվերների համար կարդալ ապրանքի ընթացիկ գինը՝ snapshot-ի փոխարեն։
- Foreign key-երի վրա index-ների բացակայություն, ինչը join-երն ու ջնջումները դանդաղ է դարձնում։
- Ավելացնել unique constraint, որը կոտրվում է, հենց որ soft-delete արված տողեր են հայտնվում։
- Մեծ աղյուսակի վրա lock անող migration գործարկել պիկային ժամերին։
Խորը վերլուծություն
08Ծառերի մոդելավորում՝ adjacency list, materialised path և closure table
Կատեգորիաները, մեկնաբանությունների շղթաները, կազմակերպության կառուցվածքները և թղթապանակները ծառեր են, և SQL-ում դրանք պահելու մի քանի եղանակ կա։ Adjacency list-ում յուրաքանչյուր տող ունի parent_id՝ պարզ է, հանգույցը տեղափոխելը էժան է, իսկ ռեկուրսիվ CTE-ներով (WITH RECURSIVE, որոնք աջակցում են PostgreSQL-ը և MySQL 8-ը) ենթածառ կարդալը չափավոր խորության դեպքում լավ է աշխատում։ Materialised path-ը պահում է ամբողջ ուղին որպես տող, օրինակ՝ /1/7/42/․ բոլոր հետնորդներին գտնելը արագ prefix որոնում է, բայց ճյուղը տեղափոխելիս պետք է վերագրել դրանից ներքև գտնվող բոլոր ուղիները։ PostgreSQL-ի ltree ընդլայնումը այս մոտեցումը դարձնում է լիարժեք։ Closure table-ը առանձին աղյուսակում պահում է նախնի-հետնորդ բոլոր զույգերը՝ խորության հետ միասին․ ցանկացած ենթածառ կամ նախնիների շղթա կարդալը դառնում է մեկ ինդեքսավորված join, բայց գինը ավելի շատ տողերն են և ավելի շատ աշխատանքը ավելացնելու ու տեղափոխելու ժամանակ։ Nested sets-ը արագ է կարդալու համար, բայց թարմացնելը ցավոտ է, և այսօր հազվադեպ է արժանի։ Ընտրեք ըստ գերակշռող գործողության․ հաճախակի տեղափոխությունների դեպքում՝ adjacency list, ենթածառերի ծանր ընթերցման դեպքում՝ path կամ closure table։
CREATE TABLE category_paths (
ancestor_id bigint NOT NULL REFERENCES categories(id),
descendant_id bigint NOT NULL REFERENCES categories(id),
depth int NOT NULL,
PRIMARY KEY (ancestor_id, descendant_id)
);
-- all products in category 7 and every subcategory, one join
SELECT p.*
FROM category_paths cp
JOIN products p ON p.category_id = cp.descendant_id
WHERE cp.ancestor_id = 7;
09Ամբողջականություն զուգահեռության պայմաններում՝ constraint-ներ, upsert և optimistic locking
Հավելվածի ստուգումները, օրինակ՝ «եթե email-ը չկա, ավելացրու», race condition են․ երկու հարցում կարող են միաժամանակ անցնել ստուգումը և երկուսն էլ ավելացնել տող։ Միայն տվյալների բազան կարող է կանոնները ատոմար կերպով ապահովել, ուստի դրանք դրեք հենց այնտեղ։ UNIQUE constraint-ը upsert-ի հետ միասին (PostgreSQL-ում INSERT ... ON CONFLICT, MySQL-ում ON DUPLICATE KEY UPDATE) կրկնօրինակները դարձնում է կանխատեսելի արդյունք։ Արտաքին կրկնությունների համար, ինչպես վճարման webhook-ը կամ երկու անգամ սեղմված checkout-ը, պահեք idempotency key unique constraint-ով և կրկնության դեպքում վերադարձրեք պահված արդյունքը։ PostgreSQL-ի exclusion constraint-ները կարող են արգելել համընկնող միջակայքերը, ինչը կրկնակի ամրագրումները կանխելու ճիշտ եղանակն է։ Մարդկանց կողմից խմբագրվող գրառումների համար optimistic locking-ը թույլ է տալիս lock չպահել, մինչ ինչ-որ մեկը տպում է․ պահեք version սյունակ, թարմացրեք WHERE version = կարդացած արժեքը, և զրո փոփոխված տողը համարեք կոնֆլիկտ, որը պետք է ցույց տալ օգտատիրոջը։ Pessimistic lock-երը (SELECT ... FOR UPDATE) հարմար են կարճ և «տաք» ուղիների համար, ինչպես պահեստի մնացորդի նվազեցումը։
-- optimistic lock: succeeds only if nobody changed the row since we read version 7
UPDATE products
SET price_cents = 1990, version = version + 1
WHERE id = 42 AND version = 7;
-- 0 rows affected => someone else saved first; reload and ask the user
-- PostgreSQL: no two bookings of a room may overlap
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&);
10Աճ՝ partitioning, պահպանման ժամկետ և արխիվացում
Աղյուսակների մեծ մասին partitioning երբեք պետք չի գալիս։ Բայց հիմնականում append-ով աճող աղյուսակները, որոնք հասնում են հարյուրավոր միլիոնների տողերի, ինչպես events-ը, log-երը, audit trail-երը կամ հին պատվերները, վաղ թե ուշ դրա կարիքն ունեն։ Partitioning-ը մեկ տրամաբանական աղյուսակը բաժանում է ֆիզիկական մասերի ըստ բանալու, սովորաբար՝ ամսական ժամանակային միջակայքով։ Այդ բանալիով ֆիլտրող հարցումները դիպչում են միայն համապատասխան partition-ներին (partition pruning), ինդեքսները մնում են բավական փոքր, որ տեղավորվեն հիշողության մեջ, իսկ պահպանման ժամկետի կիրառումը դառնում է ակնթարթային․ հին partition-ի detach-ը կամ drop-ը փոխարինում է միլիոնավոր տողերի DELETE-ին, որը կուռճացներ աղյուսակը և կծանրաբեռներ replication-ը։ Կանոններ, որոնք պետք է իմանալ․ primary key-ը և յուրաքանչյուր unique constraint պետք է ներառեն partition key-ը՝ և՛ PostgreSQL-ում, և՛ MySQL-ում, բանալիով չֆիլտրող հարցումները սկանավորում են բոլոր partition-ները, և չափազանց շատ partition-ները դանդաղեցնում են պլանավորումը․ հարյուրավորը նորմալ է, տասնյակ հազարները՝ ոչ։ Ապագա partition-ները ստեղծեք նախապես՝ պլանավորված job-ով կամ pg_partman-ի նման ընդլայնմամբ։ Եթե պարտավոր եք պահել detach արված partition-ները, արխիվացրեք դրանք ավելի էժան պահոց։
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
created_at timestamptz NOT NULL,
type text NOT NULL,
payload jsonb,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_11 PARTITION OF events
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
-- retention: instant, no row-by-row delete
ALTER TABLE events DETACH PARTITION events_2025_11;
DROP TABLE events_2025_11;
11Բազայի դեմ աշխատող pattern-ներ՝ polymorphic կապեր, EAV և cross-tenant հղումներ
Որոշ տարածված pattern-ներ աննկատ զոհաբերում են ամբողջականությունը։ Polymorphic association-ները, օրինակ՝ մեկնաբանություններ commentable_type-ով և commentable_id-ով, չեն կարող ունենալ foreign key, ուստի որբ տողերն ու տառասխալները աննկատ են մնում։ Այլընտրանքներ՝ յուրաքանչյուր ծնող տիպի համար մեկ nullable foreign key և CHECK, որ ճիշտ մեկն է լրացված, կամ յուրաքանչյուր ծնողի համար առանձին join աղյուսակ։ Entity-attribute-value աղյուսակները (entity_id, attribute, value) ճկուն են թվում, բայց յուրաքանչյուր ընթերցում դարձնում են բազմաթիվ join-եր և ամեն արժեք պահում են որպես տեքստ․ փոփոխական մասի համար JSONB սյունակը, իսկ այն ամենի համար, ինչով ֆիլտրում եք՝ իրական սյունակներ, գրեթե միշտ ավելի լավ է։ Multi-tenant սխեմաներում միայն customer_id-ի վրա foreign key-ը չի խանգարում, որ պատվերը հղի այլ tenant-ի հաճախորդի։ Tenant-ը դարձրեք բանալու մաս․ ծնող աղյուսակում unique constraint (tenant_id, id)-ի վրա, իսկ զավակում՝ composite foreign key (tenant_id, customer_id)։ Այդ դեպքում cross-tenant հղումներն անհնար են հենց կառուցվածքով, ոչ թե code review-ով։
ALTER TABLE customers ADD CONSTRAINT uq_customers_tenant_id UNIQUE (tenant_id, id);
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer_same_tenant
FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id);
-- exclusive arc instead of a polymorphic pair
ALTER TABLE comments ADD CONSTRAINT one_parent CHECK (
num_nonnulls(post_id, product_id) = 1
);
Իրական իրավիճակներ
Կրկնակի պատվերներ կրկնակի սեղմումներից և retry-երից
Խնդիր
Ամեն շաբաթ մի քանի հաճախորդից գումարը գանձվում է երկու անգամ։ Log-երը ցույց են տալիս երկու checkout հարցում, որոնք գալիս են միլիվայրկյանների տարբերությամբ, և երկուսն էլ անցնում են հավելվածի կոդում «արդեն պատվիրե՞լ է» ստուգումը։
Ինչպես լուծել
Երաշխիքը տեղափոխեք տվյալների բազա։ Կլիենտը checkout ձևը բեռնելիս գեներացնում է idempotency key (UUID) և այն ուղարկում ամեն փորձի հետ։ Orders աղյուսակին ավելացրեք idempotency_key սյունակ UNIQUE constraint-ով կամ առանձին idempotency աղյուսակ, որտեղ պահվում են բանալին, հարցման hash-ը և պատասխանը։ Պատվերն ու բանալին ավելացրեք մեկ transaction-ում․ զուգահեռ կրկնօրինակը կձախողվի constraint-ի վրա, և handler-ը կվերադարձնի պահված արդյունքը՝ երկրորդ պատվեր ստեղծելու փոխարեն։ Նույն բանալին փոխանցեք վճարային պրովայդերին, քանի որ դրանց մեծ մասն աջակցում է idempotent հարցումներ։ Առաջին սեղմումից հետո կոճակն անջատեք որպես հարմարություն, բայց երբեք դրա վրա մի հույս դրեք։ Բանալիները պահեք առնվազն retry-երի պատուհանի ընթացքում, սովորաբար 24 ժամ կամ ավելի։
400 միլիոն տողանոց events աղյուսակը դանդաղեցնում է ամեն ինչ
Խնդիր
Events աղյուսակը տարիներով աճել է։ Հին տողերի գիշերային DELETE-ները ժամեր են տևում, replica-ները հետ են մնում, backup-երը հսկայական են, իսկ վերջին տվյալների dashboard-ները դանդաղ են։
Ինչպես լուծել
Կատարեք partitioning ըստ ամսի created_at-ով, ինչը պահանջում է, որ primary key-ը ներառի created_at-ը։ Ստեղծեք նոր partitioned աղյուսակ, գրեք երկուսի մեջ էլ կամ հանգիստ պահին փոխեք գրառումների ուղղությունը, և վերջին ամիսները լրացրեք batch-երով․ հին պատմությունը կարող է մնալ legacy աղյուսակում, մինչև ժամկետն անցնի։ Գիշերային DELETE-ը փոխարինեք job-ով, որը detach և drop է անում պահպանման ժամկետից հին partition-ները՝ անհրաժեշտության դեպքում նախապես արխիվացնելով դրանք էժան պահոցում։ Ըստ ամսաթվի ֆիլտրող dashboard-ներն այժմ դիպչում են միայն մեկ-երկու փոքր partition-ի։ Ծանր ագրեգատների համար ավելացրեք օրական ամփոփ աղյուսակ, որը թարմացվում է job-ով, որպեսզի dashboard-ներն ընդհանրապես դադարեն հում events-ը սկանավորել։ Հաջորդ ամսվա partition-ը ստեղծեք ավտոմատ՝ մի քանի շաբաթ առաջ։
Ապրանքի յուրաքանչյուր կատեգորիա տարբեր ատրիբուտներ ունի
Խնդիր
Կոշիկներին պետք են չափս և լայնություն, գինուն՝ բերքի տարի և խաղողի տեսակ, էլեկտրոնիկային՝ լարում։ Թիմը վիճում է՝ ամեն ատրիբուտի համար սյունա՞կ, EAV աղյուսա՞կ, թե՞ JSON։
Ինչպես լուծել
Ատրիբուտները բաժանեք ըստ օգտագործման։ Այն, ինչ ունի յուրաքանչյուր ապրանք, կամ ինչով join, sort կամ constraint եք անում (գին, մնացորդ, բրենդ, կարգավիճակ), մնում է իրական սյունակ։ Երկար պոչը գնում է JSONB սյունակ (կամ MySQL-ում JSON), որը վավերացվում է հավելվածի սխեմայով ըստ կատեգորիայի և ինդեքսավորվում GIN-ով այնտեղ, որտեղ ֆիլտրում եք containment-ով։ Յուրաքանչյուր կատեգորիայի թույլատրելի ատրիբուտները սահմանեք attribute definitions աղյուսակում, որպեսզի admin UI-ն և վավերացումը կառավարվեն տվյալներով։ Եթե որևէ ատրիբուտ կենտրոնական է դառնում որոնման կամ դասավորման համար, այն վերածեք generated column-ի սովորական ինդեքսով։ Խուսափեք EAV-ից․ այն բազմապատկում է join-երը, կորցնում է տիպերը և պարզ ֆիլտրերը դարձնում դանդաղ։ Ատրիբուտներով faceted որոնումը, միևնույն է, սովորաբար search engine-ի գործն է։
Արագ ուղեցույց
- Կրկնօրինակներն անհնար դարձնել
ALTER TABLE orders ADD CONSTRAINT uq_orders_idem UNIQUE (idempotency_key);- Upsert PostgreSQL-ում
INSERT INTO prices (sku, cents) VALUES ('A1', 990) ON CONFLICT (sku) DO UPDATE SET cents = EXCLUDED.cents;- Upsert MySQL 8-ում
INSERT INTO prices (sku, cents) VALUES ('A1', 990) AS new ON DUPLICATE KEY UPDATE cents = new.cents;- Optimistic lock-ի ստուգում
UPDATE t SET ..., version = version + 1 WHERE id = ? AND version = ?; -- 0 rows = conflict- Առանց համընկնող ամրագրումների (PostgreSQL)
EXCLUDE USING gist (room_id WITH =, during WITH &&)- Նույն tenant-ի foreign key
FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id)- Ճիշտ մեկ ծնող է լրացված
CHECK (num_nonnulls(post_id, product_id) = 1) -- PostgreSQL- Սահմանափակել status սյունակը
CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled'))- Keyset pagination (կայուն և արագ)
WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20- Հին տվյալները ակնթարթորեն ջնջել
ALTER TABLE events DETACH PARTITION events_2025_11; DROP TABLE events_2025_11;
Իմ իրական նախագծերում
- Կենդանիների ապրանքների e-commerce պլատֆորմում աշխատել եմ մոտ 90 մոդելի հետ՝ 12 domain թղթապանակներում, իսկ գնագոյացումը մոդելավորված էր value object-ներով՝ փաթեթով և քաշով վաճառքի ռեժիմները մշակելու համար։
- 181 Prisma մոդել ունեցող multi-tenant POS SaaS-ում յուրաքանչյուր հարցում կրում է բացահայտ company id, և Prisma extension-ը սխալ է նետում, եթե tenant scope-ը բացակայում է։
- Նույն SaaS-ում գործընկերների պատվերների idempotency key-երը և կրկնօրինակներից մաքրված offline sync հրամանները տվյալները համահունչ են պահում, երբ հարցումները կրկնվում են։
- Տուրերի ամրագրման պլատֆորմը, որին մասնակցել եմ, մշակում է multi-currency գնագոյացում USD, EUR և AMD արժույթներով՝ փոխարժեքների շերտավոր cache-ով։
Հարցեր և պատասխաններ
Q1Ե՞րբ կդենորմալիզացնեիք սխեման։
Լռելյայն նորմալիզացնում եմ և դենորմալիզացնում երկու պատճառով։ Առաջինը պատմությունն է. պատվերը պետք է պահի ապրանքի անունը, գինը, հարկը և հասցեն այնպես, ինչպես դրանք էին գնման պահին, ուստի դրանք դիտավորյալ պատճենվում են պատվերի և նրա item-ների վրա։ Երկրորդը կարդալու արագությունն է. counter, նախապես հաշվարկված ընդհանուր գումար կամ հաշվետվությունների աղյուսակ, երբ կենդանի տվյալների join-ը չափազանց դանդաղ է։ Երկու դեպքում էլ որոշում եմ, թե ով է պատճենը ճիշտ պահում՝ transaction նույն գրառման մեջ, trigger, թե background job, և արդյոք այն կարելի է վերակառուցել ճշմարտության աղբյուրից։ Առանց տիրոջ դենորմալիզացիան հենց այն է, ինչից հաշվետվությունները սկսում են իրար հակասել։
Q2UUID, թե՞ auto-increment bigint որպես primary key։
Կախված է նրանից, թե որտեղ են id-ները ստեղծվում և ով է դրանք տեսնում։ Bigint identity սյունակները 8 բայթ են, արագ են և insert են արվում հերթականությամբ, ինչը B-tree index-ները կոմպակտ է պահում. դրանք հիանալի են ներքին աղյուսակների համար։ Նրանց թերություններն այն են, որ գուշակելի են և պետք է գան տվյալների բազայից։ UUID-ները կարող են գեներացվել կլիենտների, offline սարքերի կամ այլ ծառայությունների կողմից, և դրանք URL-ներում չեն բացահայտում քանակները։ Պատահական UUIDv4 արժեքները մեծ աղյուսակներում վնասում են insert-ի արագությանը, որովհետև ընկնում են index-ի ամենատարբեր տեղերում, ուստի նախընտրում եմ ժամանակով դասավորված UUIDv7։ Տարածված փոխզիջում է ներքին bigint primary key-ը գումարած unique հանրային UUID՝ API-ների համար։
Q3Ինչպե՞ս եք պահում գումարն ու գները։
Երբեք որպես floating point, որովհետև բինար float-ները չեն կարող ճշգրիտ ներկայացնել տասնորդական կոտորակների մեծ մասը, և սխալները կուտակվում են։ Օգտագործում եմ ամբողջ թվով մանր միավորներ bigint-ում, օրինակ՝ ցենտեր, կամ ֆիքսված ճշգրտությամբ DECIMAL կամ NUMERIC, և գումարի կողքին միշտ պահում եմ ISO արժույթի կոդը։ Կլորացումը տեղի է ունենում մեկ տեղում՝ փաստաթղթավորված կանոնով։ Պատվերները պահում են իրենց սեփական միավորի գները, զեղչերը, հարկը և ընդհանուր գումարները գնման պահի դրությամբ, այնպես որ հետագա գնի փոփոխությունը երբեք չի փոխում պատմությունը։ Multi-currency-ի համար պահում եմ գումարը այն արժույթով, որով վճարել է հաճախորդը, գումարած օգտագործված փոխարժեքը, ոչ թե միայն փոխարկված արժեքը։
Q4Ինչպե՞ս եք migration գործարկում մեծ production աղյուսակի վրա առանց downtime-ի։
Օգտագործում եմ expand and contract, որպեսզի deploy-ի ընթացքում և՛ հին, և՛ նոր կոդը աշխատեն։ Expand. ավելացնում եմ նոր սյունակներ որպես nullable կամ հաստատուն default-ով, ինչը ժամանակակից տվյալների բազաների մեծ մասն անում է առանց աղյուսակը վերագրելու, և index-ներ եմ ստեղծում առանց գրելը արգելափակելու՝ PostgreSQL-ում CREATE INDEX CONCURRENTLY-ով կամ MySQL-ում online DDL-ով։ Այնուհետև deploy եմ անում կոդ, որը գրում է երկուսում էլ, հին տողերը backfill եմ անում փոքր խմբաքանակներով և փոխում կարդալը։ Contract. ավելացնում եմ NOT NULL կամ constraint-ներ, երբ տվյալները մաքուր են, և հին սյունակները հեռացնում ավելի ուշ release-ում։ Նաև lock timeout եմ սահմանում, որպեսզի migration-ը արագ ձախողվի, այլ ոչ թե հերթ կանգնի երկար transaction-ների հետևում։
Q5Որո՞նք են soft delete-ների դրական և բացասական կողմերը։
Soft delete-ները տողը հեռացնելու փոխարեն սահմանում են deleted_at timestamp։ Դրական կողմերն են հեշտ undo-ն, այլ աղյուսակներից հղումների պահպանումը և պարզ audit trail-ը։ Բացասական կողմերն իրական են. յուրաքանչյուր հարցում պետք է բացառի ջնջված տողերը, և դա մոռանալը տվյալների արտահոսք է առաջացնում. unique constraint-ները պետք է անտեսեն ջնջված տողերը, ինչը partial index-ներ կամ generated column-ներ է պահանջում. աղյուսակներն անվերջ աճում են. իսկ գաղտնիության օրենքները, ինչպես GDPR-ը, կարող են պահանջել իրական ջնջում։ Soft delete-ներ օգտագործում եմ օգտատիրոջը տեսանելի կոնտենտի համար, որը մարդիկ կարող են ուզել վերադարձնել, իսկ մնացած ամեն ինչի համար՝ իրական ջնջում գումարած audit կամ history աղյուսակ։ Framework-ի scope-երն օգնում են, բայց չեն պաշտպանում raw SQL-ը կամ հաշվետվությունները։
Q6Ինչպե՞ս եք ընտրում, թե որ index-ները ստեղծել։
Ելնելով հարցումներից, ոչ թե սյունակներից։ Թվարկում եմ հաճախակի և կրիտիկական հարցումները, նայում նրանց WHERE, JOIN և ORDER BY կետերին և index-ները նախագծում դրանց համար։ Join-երում օգտագործվող foreign key-երին գրեթե միշտ index է պետք։ Composite index-ներում հավասարության ֆիլտրերը դնում եմ առաջինը, իսկ միջակայքի կամ դասավորման սյունակը՝ վերջում՝ leftmost-prefix կանոնի պատճառով, և երբեմն ներառում եմ լրացուցիչ սյունակներ, որպեսզի հարցումը սպասարկվի միայն index-ից։ Այնուհետև ստուգում եմ EXPLAIN-ով իրատեսական տվյալների վրա։ Հետևում եմ նաև մյուս կողմին. յուրաքանչյուր index դանդաղեցնում է insert-ներն ու update-ները և հիշողություն է զբաղեցնում, ուստի հեռացնում եմ այն index-ները, որոնք, ըստ օգտագործման վիճակագրության, երբեք չեն կարդացվում։
Սենիոր մակարդակի հարցեր
S1Ինչպե՞ս կնախագծեիք մնացորդները դրամապանակի կամ loyalty միավորների համակարգի համար։
Ես երբեք մնացորդը չեմ պահում որպես ճշմարտության միակ աղբյուր։ Ճշմարտության աղբյուրը append-only ledger է․ յուրաքանչյուր շարժ տող է՝ հաշվով, նշանով գումարով minor units-ով, արժույթով, տիպով, հղումով և idempotency key-ով, իսկ տողերը երբեք չեն թարմացվում կամ ջնջվում․ ուղղումները նոր, հակադարձ գրառումներ են։ Փոխանցումների համար օգտագործում եմ double entry՝ երկու տող, որոնց գումարը զրո է, գրված մեկ transaction-ում։ Ընթացիկ մնացորդը ածանցյալ է, բայց ամեն հարցման ժամանակ միլիոնավոր տողեր գումարելը դանդաղ է, ուստի հաշվի վրա պահում եմ balance սյունակ, որը թարմացվում է ledger-ի insert-ի հետ նույն transaction-ում՝ row lock-ով կամ պայմանական update-ով, օրինակ՝ WHERE balance + amount >= 0, որպեսզի կանխվի overdraft-ը։ Պարբերական job-ը համեմատում է cache արված մնացորդները ledger-ի գումարների հետ։ Փոխզիջումն ավելի շատ տողերն ու ավելի խիստ գրելու ուղիներն են՝ ամբողջական audit trail-ի և ամեն թիվ բացատրելու հնարավորության դիմաց։
S2Ե՞րբ է մեկ ռելյացիոն բազան դադարում բավարար լինել, և ի՞նչ եք փորձում առաջինը։
Շատ ավելի ուշ, քան թիմերի մեծ մասը կարծում է։ Նոր համակարգեր ավելացնելուց առաջ հերթով սպառում եմ էժան քայլերը․ ուղղում եմ հարցումներն ու ինդեքսները՝ հիմնվելով իրական slow-query տվյալների վրա, ավելացնում եմ connection pooling, սերվերը մեծացնում եմ ուղղահայաց, քանի որ մեծ հիշողությամբ և NVMe պահոցով ժամանակակից մեքենաները շատ բան են տանում, ավելացնում եմ cache «տաք» ընթերցումների համար՝ հստակ invalidation-ով, թեթև հնացածությունը հանդուրժող ընթերցումները, ինչպես հաշվետվությունները, տեղափոխում եմ replica-ներ, և partitioning եմ անում մի քանի հսկայական append-only աղյուսակների համար։ Հետո նայում եմ բեռի բնույթին։ Անալիտիկան տեղ ունի columnar պահոցում, full-text և faceted որոնումը՝ search engine-ում, իսկ հերթերը՝ queue-ում։ Հիմնական բազայի sharding-ը վերջին միջոցն է, քանի որ այն բարդացնում է ամեն հարցում, transaction և migration։ Երբ այն իսկապես անհրաժեշտ է, ըստ tenant-ի sharding-ը սովորաբար ամենամաքուր սահմանն է, հաճախ ընդլայնման կամ managed ծառայության միջոցով, ոչ թե սեփական routing կոդով։
S3Ինչպե՞ս եք մոդելավորում տվյալներ, որոնք տարիներ շարունակ պետք է audit-ի ենթակա լինեն, բայց և համապատասխանեն ջնջման հարցումներին։
Ես առանձնացնում եմ բիզնես գրառումը անձնական տվյալներից։ Պատվերները, հաշիվ-ապրանքագրերը և ledger-ի գրառումներն անփոփոխ են և պահում են իրենց անհրաժեշտ snapshot-ը, օրինակ՝ գները, հարկը և վճարման հասցեն, քանի որ հաշվապահական օրենքը սովորաբար պահանջում է դրանք պահել տարիներով։ Փոփոխությունների պատմությունը գրանցվում է կամ յուրաքանչյուր entity-ի append-only history աղյուսակում, որը գրում են trigger-ները կամ հավելվածը, կամ system-versioned աղյուսակներով, որտեղ բազան դա աջակցում է, ինչպես MariaDB-ն։ Event sourcing-ը ծայրահեղ տարբերակն է․ այն տալիս է կատարյալ audit trail, բայց դժվարացնում է հարցումներն ու սխեմայի փոփոխությունները, ուստի այն օգտագործում եմ միայն հիմնական ֆինանսական հոսքերի համար։ Ջնջման հարցումների համար անձնական տվյալներն ապրում են հնարավորինս քիչ տեղերում և հղվում են id-ով․ ջնջումն այդ դեպքում նշանակում է այդ տողերի անանունացում կամ անձին հատուկ encryption key-ի ջնջում, որպեսզի գաղտնագրված պատճենները դառնան անընթեռնելի։ Որտեղ կիրառելի է, օրենքով պահանջվող պահպանումը գերակա է, և ես փաստաթղթավորում եմ, թե որ տվյալներն են դրա տակ ընկնում։
Բառարան
- Adjacency list
- Ծառի պահպանում, որտեղ յուրաքանչյուր տող ունի parent_id, որը ցույց է տալիս իր ծնող տողը։
- Closure table
- Աղյուսակ, որը պահում է ծառի նախնի-հետնորդ բոլոր զույգերը, և ենթածառի հարցումը դառնում է մեկ join։
- Idempotency key
- Կլիենտի գեներացրած եզակի id, որով սերվերը ճանաչում և անվտանգ անտեսում է կրկնվող հարցումը։
- Optimistic locking
- Կոնֆլիկտային խմբագրումների հայտնաբերում version սյունակով գրելու պահին՝ կարդալիս տողերը lock անելու փոխարեն։
- Exclusion constraint
- PostgreSQL-ի constraint, որն արգելում է ցանկացած երկու տող, որոնց արժեքները կոնֆլիկտում են, օրինակ՝ համընկնող ժամանակային միջակայքեր։
- Partition pruning
- Planner-ի կողմից այն partition-ների բաց թողնումը, որոնք հարցման ֆիլտրով չեն կարող համապատասխան տողեր պարունակել։
- Polymorphic association
- Type և id սյունակների մեկ զույգ, որը հղում է տարբեր աղյուսակների տողերի․ foreign key ունենալ չի կարող։
- EAV
- Entity-attribute-value՝ յուրաքանչյուր ատրիբուտի պահում առանձին տողով։ Ճկուն է, բայց դանդաղ և թույլ տիպավորված։
- Keyset pagination
- Էջավորում վերջին տողի sort արժեքները հիշելով՝ OFFSET-ի փոխարեն, որպեսզի խորը էջերը մնան արագ։
- Exclusive arc
- Մեկ աղյուսակում մի քանի nullable foreign key և CHECK, որ դրանցից ճիշտ մեկն է լրացված։
Շարունակեք սովորել
Պրոդուկտի գաղափար ունե՞ք։Եկեք ստեղծենք այն։
Պատմեք ձեր գաղափարի, պրոդուկտի կամ թիմի մասին։ Սովորաբար պատասխանում եմ մեկ աշխատանքային օրվա ընթացքում։