IlmHamroh
JavaScript Full-stack/29-qism. Backend integratsiyalar: fayl, to'lov, bot, realtime6/69-dars22 daqiqa
Mundarija (26)

Fayl metama'lumotini DB'da boshqarish: egasi, dublikat va yetim fayllar

Qisqacha: Ombor faylning baytlarini saqlaydi, lekin uning egasini, asl nomini va qaysi vazifaga tegishli ekanini bilmaydi. Bu ma'lumot — metama'lumot — bazada turadi: fayllar jadvali (kalit, tur, hajm, sha256, holat) va vazifa_ilovalari (qaysi fayl qaysi vazifada). Egasi ichida UNIQUE (egasi_id, sha256) bir xil faylni ikki marta saqlamaydi, FOR UPDATE parallel so'rovlarda limitni ushlaydi, hech qayerga biriktirilmagan yetim (orphan) fayllar esa avval ombordan, keyin bazadan tozalanadi.

Bu darsda

  • Fayl baytlari va metama'lumotini ikki joyda saqlash sababini tushunasiz.
  • fayllar va vazifa_ilovalari jadvallarini, ularning cheklovlarini (constraint) haqiqiy SQL bilan sinaysiz.
  • sha256 bilan dublikat aniqlashni va nega u faqat egasi ichida bo'lishini bilasiz.
  • Biriktirish tranzaksiyasini, RESTRICT/CASCADE va RLS ni ishlatasiz.
  • Yetim fayllarni qaysi tartibda tozalash kerakligini o'lchov bilan asoslaysiz.

Oldin bilishingiz kerak: Presigned URL'lar, Soft delete, timestamps va tarix jadvallari, UPSERT va MERGE, Row Level Security, SKIP LOCKED bilan DB-asosli navbat.

Oldingi darsda presigned yuklash oxirida "holat bazada saqlanadi" dedik. Endi shu bazani quramiz.

1. Nega bu kerak?

Malika vazifasiga chek rasmini biriktirdi. Ilova uchta savolga javob berishi kerak:

  1. "Bu vazifada qaysi fayllar bor va ular qanday nomlangan?" — ro'yxat sahifasi.
  2. "Bu faylni shu foydalanuvchi ko'ra oladimi?" — har yuklab olishda.
  3. "Qaysi fayllar hech kimga kerak emas?" — tozalash.

Ombor bularning hech biriga javob bera olmaydi. U kalitlarni prefiks bo'yicha sanay oladi, xolos — va Object storage darsida ko'rdik, har so'rov millisekundlar. 10 000 vazifa ro'yxati uchun omborni aylanib chiqish — imkonsiz. Baza esa indeks bilan bir so'rovda javob beradi.

Shuning uchun qoida: baytlar — omborda, ma'lumot — bazada. Bazaga baytlarni (bytea) yozmang: zaxira nusxa va replikatsiya og'irlashadi, SELECT * megabaytlar tortadi.

flowchart LR
  A[vazifalar-api] -->|INSERT fayllar| D[(Postgres)]
  A -->|PutObject kalit| S[(Ombor S3)]
  D --- F["fayllar: kalit, mime, hajm, sha256, holat"]
  D --- I["vazifa_ilovalari: vazifa, fayl, asl_nom"]
  F -.->|kalit| S

Ikki joyda saqlashning narxi — sinxronlik. Bazada yozuv bor, omborda fayl yo'q (yoki aksincha) — bu darsning qolgan qismi shu muammo haqida.

2. Sxema: fayllar va vazifa_ilovalari

Kanon sxemasi (201-fayllar.sql, izohlar qisqartirildi):

sql
CREATE TABLE fayllar (
  id                bigint GENERATED ALWAYS AS IDENTITY,
  egasi_id          bigint      NOT NULL,
  kalit             text        NOT NULL,  -- S3 kaliti (uuid)
  mime              text        NOT NULL,  -- sehrli baytlardan
  hajm              bigint      NOT NULL,
  sha256            bytea,                 -- tasdiqgacha NULL
  holat             text        NOT NULL DEFAULT 'kutilmoqda',
  yaratilgan_vaqt   timestamptz NOT NULL DEFAULT now(),
  tasdiqlangan_vaqt timestamptz,
  CONSTRAINT fayllar_pkey PRIMARY KEY (id),
  CONSTRAINT fayllar_egasi_id_fkey FOREIGN KEY (egasi_id)
    REFERENCES foydalanuvchilar (id) ON DELETE CASCADE,
  CONSTRAINT fayllar_kalit_key UNIQUE (kalit),
  CONSTRAINT fayllar_egasi_id_sha256_key UNIQUE (egasi_id, sha256),
  CONSTRAINT fayllar_mime_check CHECK (mime IN ('image/png',
    'image/jpeg', 'image/webp', 'application/pdf', 'text/plain')),
  CONSTRAINT fayllar_hajm_check CHECK (hajm BETWEEN 1 AND 10485760),
  CONSTRAINT fayllar_sha256_check CHECK (octet_length(sha256) = 32),
  CONSTRAINT fayllar_holat_check
    CHECK (holat IN ('kutilmoqda', 'tayyor')),
  CONSTRAINT fayllar_tayyor_check CHECK ((holat = 'tayyor') =
    (sha256 IS NOT NULL AND tasdiqlangan_vaqt IS NOT NULL))
);

CREATE TABLE vazifa_ilovalari (
  vazifa_id      bigint      NOT NULL,
  fayl_id        bigint      NOT NULL,
  asl_nom        text        NOT NULL,
  qoshilgan_vaqt timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT vazifa_ilovalari_pkey PRIMARY KEY (vazifa_id, fayl_id),
  CONSTRAINT vazifa_ilovalari_vazifa_id_fkey FOREIGN KEY (vazifa_id)
    REFERENCES vazifalar (id) ON DELETE CASCADE,
  CONSTRAINT vazifa_ilovalari_fayl_id_fkey FOREIGN KEY (fayl_id)
    REFERENCES fayllar (id) ON DELETE RESTRICT,
  CONSTRAINT vazifa_ilovalari_asl_nom_check
    CHECK (char_length(asl_nom) BETWEEN 1 AND 255)
);
CREATE INDEX vazifa_ilovalari_fayl_id_idx
  ON vazifa_ilovalari (fayl_id);
CREATE INDEX fayllar_yaratilgan_vaqt_idx ON fayllar (yaratilgan_vaqt);

Har qarorning sababi:

Qaror Nega
kalit UNIQUE, uuid foydalanuvchi nomidan mustaqil (Fayl yuklash xavfsizligi)
mime CHECK ro'yxati SVG/HTML bazaga ham kira olmaydi — ikkinchi himoya qatlami
hajm 1…10 MB API limiti bilan bir xil
sha256 NULL bo'lishi mumkin presigned yuklashda server baytlarni hali ko'rmagan
tayyor_check "tayyor" = sha256 va tasdiq vaqti bor — yarim holat yo'q
asl nom vazifa_ilovalari da bitta fayl ikki vazifada ikki xil nom bilan bo'lishi mumkin
fayl FK — RESTRICT biriktirilgan faylni bevosita o'chirib bo'lmaydi

Bog'lanish N:M (Bog'lanishlar: 1:1, 1:N, N:M): bitta vazifada ko'p fayl, bitta fayl ko'p vazifada.

Quyidagi SQL bloklarini biz o'z PostgreSQL 17.6 klasterimizda (ICU, UTC) shu sxema bilan ishga tushirdik — natijalar haqiqiy. Kanon PG 18.6 da xuddi shu so'rovlarni bajargan; farq faqat bitta xato xabarida (pastda aytamiz).

3. Dublikat: sha256 va ON CONFLICT

3.1 Bir xil bayt — bitta yozuv

Foydalanuvchi bitta chekni ikki vazifaga ikki marta yukladi. Ikkita bir xil obyekt saqlash — ortiqcha pul. Fayl mazmunining hash'i (sha256) bir xil bo'lsa — fayl bir xil.

sql
INSERT INTO fayllar
  (egasi_id, kalit, mime, hajm, sha256, holat, tasdiqlangan_vaqt)
VALUES (1, 'fayllar/1/a.png', 'image/png', 2048,
        sha256('rasm'), 'tayyor', now())
RETURNING kalit, holat;
text
      kalit      | holat
-----------------+--------
 fayllar/1/a.png | tayyor
(1 row)

INSERT 0 1

O'sha baytlar boshqa kalit bilan:

sql
INSERT INTO fayllar
  (egasi_id, kalit, mime, hajm, sha256, holat, tasdiqlangan_vaqt)
VALUES (1, 'fayllar/1/b.png', 'image/png', 2048,
        sha256('rasm'), 'tayyor', now())
ON CONFLICT (egasi_id, sha256) DO NOTHING
RETURNING kalit;
text
 kalit
-------
(0 rows)

INSERT 0 0

INSERT 0 0 — hech narsa yozilmadi, RETURNING bo'sh. Ilova buni "dublikat" deb tushunadi: eski yozuvni o'qiydi va yangi yuklangan fayllar/1/b.png obyektini ombordan o'chiradi.

Boshqa foydalanuvchi o'sha baytlarni yuklasa:

sql
INSERT INTO fayllar
  (egasi_id, kalit, mime, hajm, sha256, holat, tasdiqlangan_vaqt)
VALUES (2, 'fayllar/2/c.png', 'image/png', 2048,
        sha256('rasm'), 'tayyor', now())
RETURNING egasi_id, kalit;
text
 egasi_id |      kalit
----------+-----------------
        2 | fayllar/2/c.png
(1 row)

INSERT 0 1

Alohida yozuv. Nega butun tizim bo'yicha dedup qilmaymiz? Hujumchi "maxfiy-hisobot.pdf" ning taxminiy nusxasini yuklaydi. Javob "dublikat" bo'lsa (yoki yuklash sezilarli tezroq bo'lsa) — u boshqa odamda shu fayl borligini bildi. Bu — yon kanal (side channel) orqali ma'lumot sizishi. Egasi ichidagi dedup bunday ma'lumot bermaydi.

3.2 Hash JS'da

Server sha256 ni oqim o'tayotganda hisoblaydi (Katta fayllarni stream bilan yuklash). Mantiq toza JS'da:

js
const hex = (b) => [...new Uint8Array(b)]
  .map((x) => x.toString(16).padStart(2, "0")).join("");
async function sha256(matn) {
  const b = new TextEncoder().encode(matn);
  return hex(await crypto.subtle.digest("SHA-256", b));
}

const fayllar = new Map(); // "egasi:sha256" → id
let keyingiId = 1;
async function yoz(egasi, matn) {
  const kalit = `${egasi}:${await sha256(matn)}`;
  if (fayllar.has(kalit)) {
    return { id: fayllar.get(kalit), dublikat: true };
  }
  fayllar.set(kalit, keyingiId);
  return { id: keyingiId++, dublikat: false };
}

console.log(await yoz(1, "rasm"));
console.log(await yoz(1, "rasm"));
console.log(await yoz(2, "rasm"));
console.log(await yoz(1, "Rasm"));
console.log((await sha256("pdf")).slice(0, 16));

Konsolda:

text
{ id: 1, dublikat: false }
{ id: 1, dublikat: true }
{ id: 2, dublikat: false }
{ id: 3, dublikat: false }
c35b21d6ca39aa7c

Bitta harf (R) — butunlay boshqa hash. Oxirgi qator — Postgres'ning sha256('pdf') natijasi bilan bir xil boshlanadi (pastdagi tasdiqlash blokida ko'rasiz): hash qaysi tilda hisoblansa ham bir xil.

Diqqat: Map dagi tekshiruv "bor-yo'qligini so'rab, keyin yozish" — ikki parallel so'rovda ikkalasi ham "yo'q" deb ko'radi. Bazada buni UNIQUE + ON CONFLICT hal qiladi: tekshiruv va yozish — bitta atomik amal. Biz kanon modulini o'z bazamizda yurgizdik: 10 ta parallel bir xil yuklash — 1 ta yozuv (pastda).

4. Holat va cheklovlar

Presigned yuklash boshlanganda server hali baytlarni ko'rmagan — sha256 NULL, holat kutilmoqda:

sql
INSERT INTO fayllar (egasi_id, kalit, mime, hajm)
VALUES (1, 'vaqtincha/1/x.pdf', 'application/pdf', 5000),
       (1, 'vaqtincha/1/y.pdf', 'application/pdf', 5000)
RETURNING kalit, holat, sha256;
text
       kalit       |   holat    | sha256
-------------------+------------+--------
 vaqtincha/1/x.pdf | kutilmoqda |
 vaqtincha/1/y.pdf | kutilmoqda |
(2 rows)

INSERT 0 2

Ikkalasi bir egasida, ikkalasida sha256 NULL — lekin UNIQUE (egasi_id, sha256) buzilmadi. Sababi: Postgres'da NULL'lar bir-biriga teng emas (NULL mantiqi), unique cheklov ularni to'qnashuv deb sanamaydi.

Endi cheklovlarni buzib ko'ramiz. \set VERBOSITY terse — faqat asosiy xabar, DETAIL qatorisiz (unda id va vaqt bor):

sql
\set VERBOSITY terse
INSERT INTO fayllar (egasi_id, kalit, mime, hajm)
VALUES (1, 'z.svg', 'image/svg+xml', 10);
INSERT INTO fayllar (egasi_id, kalit, mime, hajm)
VALUES (1, 'z.png', 'image/png', 20000000);
UPDATE fayllar SET holat = 'tayyor'
 WHERE kalit = 'vaqtincha/1/x.pdf';
INSERT INTO fayllar (egasi_id, kalit, mime, hajm, sha256)
VALUES (1, 'z2.png', 'image/png', 10, '\x00ff'::bytea);
text
ERROR:  new row for relation "fayllar" violates check constraint "fayllar_mime_check"
ERROR:  new row for relation "fayllar" violates check constraint "fayllar_hajm_check"
ERROR:  new row for relation "fayllar" violates check constraint "fayllar_tayyor_check"
ERROR:  new row for relation "fayllar" violates check constraint "fayllar_sha256_check"

"fayllar jadvalidagi yangi qator …_check cheklovini buzadi":

  1. SVG — ruxsat ro'yxatida yo'q.
  2. 20 MB — 10 MB dan katta.
  3. Hash'siz "tayyor" — yarim holat taqiqlangan.
  4. 2 baytli "sha256" — hash 32 bayt bo'lishi shart.

To'g'ri tasdiqlash — hamma ustun bitta UPDATE da:

sql
UPDATE fayllar
   SET holat = 'tayyor', sha256 = sha256('pdf'),
       tasdiqlangan_vaqt = now()
 WHERE kalit = 'vaqtincha/1/x.pdf'
RETURNING holat, encode(sha256, 'hex') AS sha256;
text
 holat  |                              sha256
--------+------------------------------------------------------------------
 tayyor | c35b21d6ca39aa7cc3b79a705d989f1a6e88b99ab43988d74048799e3db926a3
(1 row)

UPDATE 1

Hash JS'dagi c35b21d6ca39aa7c bilan boshlanadi. CHECK lar ilova xatosini ham ushlaydi: kod qaysidir joyda holatni yarim o'zgartirsa, baza rad etadi (CREATE TABLE va cheklovlar).

Tekshirib ko'ring: Nega mime ustuniga baza darajasida ham CHECK qo'yildi, axir API sehrli baytlarni tekshiradi-ku?

Javob

Chuqur himoya (defense in depth). Ertaga kimdir yangi marshrut yozib, tekshiruvni unutishi, migratsiya skripti yoki admin panel bazaga to'g'ridan yozishi mumkin. CHECK — oxirgi to'siq: SVG hech qanday yo'l bilan "tayyor fayl" bo'lib kira olmaydi.

5. Biriktirish: RESTRICT va CASCADE

Faylni vazifaga ulaymiz:

sql
INSERT INTO vazifa_ilovalari (vazifa_id, fayl_id, asl_nom)
SELECT v.id, f.id, 'Rasm (1).png'
  FROM vazifalar v, fayllar f
 WHERE v.egasi_id = 1 AND f.kalit = 'fayllar/1/a.png'
 ORDER BY v.id LIMIT 1
RETURNING vazifa_id, asl_nom;
text
 vazifa_id |   asl_nom
-----------+--------------
         1 | Rasm (1).png
(1 row)

INSERT 0 1

Biriktirilgan faylni bevosita o'chirishga urinamiz:

sql
\set VERBOSITY terse
DELETE FROM fayllar WHERE kalit = 'fayllar/1/a.png';
text
ERROR:  update or delete on table "fayllar" violates foreign key constraint "vazifa_ilovalari_fayl_id_fkey" on table "vazifa_ilovalari"

"fayllar dagi o'chirish vazifa_ilovalari jadvalidagi tashqi kalit cheklovini buzadi". Kanon PostgreSQL 18.6 da xabar aniqroq — u RESTRICT ni nomi bilan aytadi:

text
ERROR:  update or delete on table "fayllar" violates RESTRICT setting of foreign key constraint "vazifa_ilovalari_fayl_id_fkey" on table "vazifa_ilovalari"

Bu — PG 17 va 18 farqi; ma'nosi bir xil. Endi vazifaning o'zini o'chiramiz:

sql
DELETE FROM vazifalar
 WHERE id = (SELECT vazifa_id FROM vazifa_ilovalari LIMIT 1);
SELECT count(*) AS ilovalar_qoldi FROM vazifa_ilovalari;
text
DELETE 1
 ilovalar_qoldi
----------------
              0
(1 row)

Vazifa o'chdi — CASCADE uning ilovalarini ham o'chirdi. Fayl esa qoldi: endi u hech qayerga biriktirilmagan — yetim. Bu ataylab: fayl boshqa vazifada ham bo'lishi mumkin edi, uni o'chirish qarori — alohida tozalash jarayonida (Foreign key va referential actions).

5.1 Biriktirish tranzaksiyasi

Kanon modulining biriktir funksiyasi bitta tranzaksiyada to'rt narsani tekshiradi:

ts
await c.query("BEGIN");
// vazifa qatorini qulflaymiz — parallel biriktirishlar
// limitni birga oshirib yubormasin
const v = await c.query(
  `SELECT 1 FROM vazifalar WHERE id = $1 AND egasi_id = $2
     AND ochirilgan_vaqt IS NULL FOR UPDATE`,
  [vazifaId, egasiId],
);
if (v.rowCount === 0) {
  throw new IlovaXato("topilmadi", "Vazifa topilmadi");
}
const f = await c.query<{ holat: string }>(
  "SELECT holat FROM fayllar WHERE id = $1 AND egasi_id = $2",
  [faylId, egasiId],
);
const fq = f.rows[0];
if (!fq) throw new IlovaXato("topilmadi", "Fayl topilmadi");
if (fq.holat !== "tayyor") {
  throw new IlovaXato("tayyor-emas", "Fayl hali tasdiqlanmagan");
}
const n = await c.query<{ soni: number }>(
  `SELECT count(*)::int AS soni FROM vazifa_ilovalari
    WHERE vazifa_id = $1`, [vazifaId],
);
if ((n.rows[0]?.soni ?? 0) >= MAKS_ILOVA) {
  throw new IlovaXato("limit",
    `Bitta vazifaga ko'pi bilan ${String(MAKS_ILOVA)} ta fayl`);
}
await c.query(
  `INSERT INTO vazifa_ilovalari (vazifa_id, fayl_id, asl_nom)
   VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
  [vazifaId, faylId, aslNom],
);
await c.query("COMMIT");
  1. Vazifa — shu egasiniki va o'chirilmagan.
  2. Fayl — shu egasiniki: begona faylni o'z vazifangizga ulab, keyin "yuklab olib" bo'lmaydi.
  3. Fayl tayyor — tasdiqlanmagan presigned yuklash biriktirilmaydi.
  4. Limit — 10 ta. FOR UPDATE bo'lmasa, 15 ta parallel so'rov hammasi "9 ta bor" deb ko'rib, limitni oshirib yuborardi (Qulflar: row, table va advisory).

ON CONFLICT DO NOTHING — ikkinchi marta biriktirish xato emas, o'zgarishsiz (idempotent).

6. RLS: begona fayl ko'rinmaydi

vazifalar bazasida RLS 27-qismda yoqilgan edi. 201 fayllarga ham siyosat qo'shadi: faqat o'zining fayllari; ilova — vazifa orqali va fayl ham o'ziniki.

sql
\set VERBOSITY terse
BEGIN;
SET LOCAL ROLE vazifalar_ilova;
SELECT set_config('ilova.foydalanuvchi_id', '2', true);
SELECT egasi_id, kalit FROM fayllar;
INSERT INTO fayllar (egasi_id, kalit, mime, hajm)
VALUES (1, 'begona.png', 'image/png', 10);
ROLLBACK;
text
BEGIN
SET
 set_config
------------
 2
(1 row)

 egasi_id |      kalit
----------+-----------------
        2 | fayllar/2/c.png
(1 row)

ERROR:  new row violates row-level security policy for table "fayllar"
ROLLBACK

2-foydalanuvchi faqat o'z faylini ko'rdi; 1-foydalanuvchi nomiga yozish — RLS xatosi. Kod WHERE egasi_id = $1 ni unutsa ham, baza boshqa odamning faylini bermaydi (Row Level Security). Kanon moduli baribir har so'rovda egasini yozadi — RLS — ikkinchi qatlam.

7. Yetim fayllar

7.1 Qayerdan paydo bo'ladi

  • Presigned yuklash boshlandi, foydalanuvchi sahifani yopdi — kutilmoqda qoldi.
  • Fayl tasdiqlandi, lekin vazifaga biriktirilmadi (forma yuborilmadi).
  • Ilova olib tashlandi yoki vazifa o'chirildi (yuqoridagi CASCADE).

Qoida: biriktirilmagan va 24 soatdan eski — yetim. 24 soat — foydalanuvchiga formani tugatishga vaqt.

sql
UPDATE fayllar SET yaratilgan_vaqt = now() - interval '2 days'
 WHERE egasi_id = 1;
SELECT kalit, holat FROM fayllar f
 WHERE f.yaratilgan_vaqt < now() - interval '24 hours'
   AND NOT EXISTS (SELECT 1 FROM vazifa_ilovalari i
                    WHERE i.fayl_id = f.id)
 ORDER BY kalit;
text
UPDATE 3
       kalit       |   holat
-------------------+------------
 fayllar/1/a.png   | tayyor
 vaqtincha/1/x.pdf | tayyor
 vaqtincha/1/y.pdf | kutilmoqda
(3 rows)

a.png ham yetim — uning vazifasi o'chirilgan edi. NOT EXISTS — "ilovalarda yo'q" (Subquery va LATERAL).

7.2 Katta jadvalda tezligi

Kanon 100 000 fayl (90 % biriktirilgan) bilan tozalash so'rovining rejasini oldi (PostgreSQL 18.6, Docker, kanon mashinasi; Buffers va Index Searches qatorlari — PG 18 ning yangi chiqishi):

text
 Limit (actual rows=100.00 loops=1)
   Buffers: shared hit=12213
   ->  LockRows (actual rows=100.00 loops=1)
         Buffers: shared hit=12213
         ->  Nested Loop Anti Join (actual rows=100.00 loops=1)
               Buffers: shared hit=12113
               ->  Index Scan using fayllar_yaratilgan_vaqt_idx on fayllar f (actual rows=1885.00 loops=1)
                     Index Cond: (yaratilgan_vaqt < (now() - '24:00:00'::interval))
                     Index Searches: 1
                     Buffers: shared hit=4673
               ->  Index Scan using vazifa_ilovalari_fayl_id_idx on vazifa_ilovalari i (actual rows=0.95 loops=1885)
                     Index Cond: (fayl_id = f.id)
                     Index Searches: 1885
                     Buffers: shared hit=7440
 Planning:
   Buffers: shared hit=346
 Planning Time: 2.463 ms
 Execution Time: 6.437 ms

Pastdan yuqoriga o'qiymiz (EXPLAIN ANALYZE'ni o'qish):

  • Index Scan … fayllar_yaratilgan_vaqt_idx — eski fayllarni vaqt tartibida oladi;
  • har biri uchun vazifa_ilovalari_fayl_id_idx da "biriktirilganmi?" — Nested Loop Anti Join (NOT EXISTS ning rejasi);
  • 1 885 ta qator ko'rildi, 100 ta yetim topilgach Limit to'xtatdi;
  • LockRows — FOR UPDATE SKIP LOCKED.

6.4 ms — 100 000 fayl ichida. Ikkala indeks bo'lmasa — butun jadval skani. Jadval hajmi: fayllar 46 MB, vazifa_ilovalari 20 MB.

7.3 Tozalash tartibi: avval ombor, keyin baza

Kanon yetimlarniTozala:

ts
const r = await c.query<{ id: string; kalit: string }>(
  `SELECT f.id, f.kalit FROM fayllar f
    WHERE f.yaratilgan_vaqt < now() - make_interval(secs => $1)
      AND NOT EXISTS (SELECT 1 FROM vazifa_ilovalari i
                       WHERE i.fayl_id = f.id)
    ORDER BY f.yaratilgan_vaqt
    LIMIT $2
    FOR UPDATE OF f SKIP LOCKED`,
  [yoshSoniya, partiya],
);
for (const q of r.rows) await ombor.ochir(q.kalit);
if (r.rows.length > 0) {
  await c.query("DELETE FROM fayllar WHERE id = ANY($1::bigint[])",
    [r.rows.map((q) => q.id)]);
}
await c.query("COMMIT");

Nega avval ombor? Ikki variantni solishtiring:

Tartib O'rtada uzilsa Natija
Ombor → baza qator bazada qoladi keyingi ishga tushishda qayta topiladi; ombordan o'chirish idempotent (204)
Baza → ombor qator yo'q, fayl omborda hech kim bilmaydigan, abadiy pul to'lanadigan bayt

Birinchi tartibda xato o'zini tuzatadi. 3-mashqda buni simulyatsiya qilasiz.

FOR UPDATE SKIP LOCKED — API ikki nusxada ishlasa, ikkala tozalovchi bir vaqtda yursa ham, biri qulflagan qatorni ikkinchisi o'tkazib yuboradi (SKIP LOCKED bilan DB-asosli navbat). Partiya (100 ta) — tranzaksiya uzoq ochiq qolmasin.

Tozalovchini kim chaqiradi? Rejali vazifa (cron) — Cron va rejali vazifalar darsida. vaqtincha/ prefiksi uchun ombor o'zining lifecycle qoidasi bilan ham tozalashi mumkin — u ham o'sha darsda.

8. Vaqtincha → doimiy

Presigned yuklash vaqtincha/<egasi>/<uuid> ga tushadi. Tasdiqdan keyin server uni fayllar/ ga ko'chiradi (FaylOmbori.kochir — CopyObject, baytlar server orqali o'tmaydi) va bazadagi kalit ni yangilaydi:

stateDiagram-v2
  [*] --> kutilmoqda: forma, vaqtincha prefiksi
  kutilmoqda --> tayyor: tasdiq, kochir fayllar prefiksi
  kutilmoqda --> ochirildi: 24 soat, tasdiq yo'q
  tayyor --> biriktirilgan: vazifa_ilovalari
  biriktirilgan --> tayyor: ilova olindi
  tayyor --> ochirildi: 24 soat, yetim
  ochirildi --> [*]

Foydasi: vaqtincha/ dagi hamma narsa — "hali ishonchsiz". Ombor darajasida ham ularni alohida qoida bilan tozalash, ko'rish huquqini berMASlik oson.

9. Kanon modulini o'z bazamizda yurgizdik

Kanon testlari Postgres va MinIO talab qiladi. Biz Docker'ni yoqmadik: kanon metama.ts ni o'z PostgreSQL 17.6 klasterimizda (sxema 001–201) va soxta ombor bilan (ochir faqat kalitni yozib boradi) ishga tushirdik — dedup, parallel yuklash, parallel biriktirish va tozalash:

ts
const a = await yukla(1, "bir xil matn");
const b = await yukla(1, "bir xil matn");
const c = await yukla(2, "bir xil matn");
console.log("dublikat:", a.dublikat, b.dublikat, c.dublikat,
  "| a=b:", a.fayl.id === b.fayl.id,
  "| a=c:", a.fayl.id === c.fayl.id);

const p = await Promise.all(
  Array.from({ length: 10 }, () => yukla(1, "parallel")));
console.log("parallel 10: noyob id",
  new Set(p.map((x) => x.fayl.id)).size,
  "| yangi", p.filter((x) => !x.dublikat).length);
// … 15 ta faylni bitta vazifaga parallel biriktirish,
// begona vazifa, yetimlarni tozalash (partiya 3)

Konsolda (biz ishga tushirdik: Node 24.21.0, pg 8.23.1, PostgreSQL 17.6):

text
dublikat: false true false | a=b: true | a=c: false
parallel 10: noyob id 1 | yangi 1
parallel 15 biriktirish: o'tdi 10 | rad 5 | limit: Bitta vazifaga ko'pi bilan 10 ta fayl
begona vazifa: topilmadi
yetimlar: 8 | omborda o'chirildi: 8 | bazada qoldi: 10
  • 10 ta parallel bir xil yuklash — bitta yozuv, bittasi "yangi", to'qqizi "dublikat".
  • 15 ta parallel biriktirish — aynan 10 tasi o'tdi (FOR UPDATE).
  • Begona vazifaga ulash — "topilmadi" (403 emas: borligini ham aytmaymiz).
  • Yetimlar: 5 ta biriktirilmagan p-* + 3 ta dublikat sinovidagi — 8; omborda ham 8 ta ochir; qolgani — biriktirilgan 10 ta.

10. Hujumchi nigohi

  1. Begona faylni biriktirish. POST /vazifalar/7/ilovalar { faylId: 123 } — 123 boshqa odamniki. Tekshiruvsiz u o'z vazifasi orqali begona faylni yuklab oladi. Himoya: fayllar.egasi_id = $egasi + RLS WITH CHECK.
  2. Global dedup yon kanali. Yuqorida aytildi. Himoya: dedup faqat egasi ichida.
  3. Limit poygasi. Parallel so'rovlar limitni oshiradi. Himoya: FOR UPDATE yoki SERIALIZABLE.
  4. ID taxmin qilish. GET /fayllar/124 — ketma-ket ID. Himoya: har yuklab olishda egalik tekshiruvi; havola faqat muddatli presigned.
  5. Asl nom bilan hujum. asl_nom dagi "; filename=evil.exe — sarlavhaga tushganda. Himoya: nom tozalanadi (Fayl yuklash xavfsizligi), sarlavha contentDisposition bilan (Fayl yuklab berish va arxivlash).

11. Ko'p uchraydigan xatolar

Xato Sabab Davosi
Ro'yxat uchun omborni aylanib chiqish metama'lumot bazada yo'q fayllar + vazifa_ilovalari
Bir xil fayl o'nlab nusxada dedup yo'q UNIQUE (egasi_id, sha256) + ON CONFLICT
Dedup ishlamaydi (parallel) "avval SELECT, keyin INSERT" cheklov + ON CONFLICT
Fayl o'chdi, ilova "singan" ON DELETE CASCADE fayl FK'da RESTRICT, tozalash yetimlar orqali
Omborda egasiz baytlar avval bazadan o'chirildi avval ombor, keyin baza
Tozalovchilar bir faylni ikki marta oladi qulfsiz SELECT FOR UPDATE SKIP LOCKED
Tozalash so'rovi sekin indeks yo'q yaratilgan_vaqt va fayl_id indekslari

12. Mashqlar

Mashqlar kurs/mashqlar/29/06-metama/ da.

1-mashq (oson): to'g'ri yoki noto'g'ri

  1. Fayl baytlarini bytea ustunida saqlash — tavsiya etilgan yo'l.
  2. UNIQUE (egasi_id, sha256) ikkita NULL hash'li qatorga ruxsat beradi.
  3. Vazifa o'chirilsa, unga biriktirilgan fayl ham bazadan darhol o'chadi.
  4. Yetimlarni tozalashda avval ombordan o'chirish xavfsizroq.
Yechim
  1. Noto'g'ri — baytlar omborda, bazada faqat metama'lumot.
  2. To'g'ri — NULL'lar teng emas.
  3. Noto'g'ri — CASCADE faqat vazifa_ilovalari ni o'chiradi; fayl yetim bo'lib qoladi va keyin tozalanadi.
  4. To'g'ri — uzilsa qator qoladi va qayta urinish mumkin.

2-mashq (o'rta): yetimlar ro'yxati

yetimlar(fayllar, ilovalar, hozir, yosh = 24 soat) funksiyasini yozing: { id, kalit, yaratilgan } (ms) massivi va { vazifaId, faylId } ilovalaridan biriktirilmagan va yosh dan eski fayllar kalitlarini eng eskisidan boshlab qaytarsin. Pastdagi ma'lumot bilan ikki marta chaqiring: sukut bilan va ilovalar = [], yosh = 36 soat bilan. Ishora: Set bilan biriktirilgan ID'lar (Set).

Yechim
js
const SOAT = 3600_000;
const hozir = Date.parse("2026-10-28T12:00:00Z");
const oldin = (soat) => hozir - soat * SOAT;
const royxat = [
  { id: 1, kalit: "fayllar/1/a.png", yaratilgan: oldin(48) },
  { id: 2, kalit: "vaqtincha/1/x.pdf", yaratilgan: oldin(30) },
  { id: 3, kalit: "vaqtincha/1/y.pdf", yaratilgan: oldin(2) },
  { id: 4, kalit: "fayllar/2/c.png", yaratilgan: oldin(72) },
];
const ilovalar = [{ vazifaId: 1, faylId: 1 }];

function yetimlar(fayllar, ilovalar, hozir, yosh = 24 * SOAT) {
  const ulangan = new Set(ilovalar.map((i) => i.faylId));
  return fayllar
    .filter((f) => !ulangan.has(f.id) && f.yaratilgan < hozir - yosh)
    .sort((a, b) => a.yaratilgan - b.yaratilgan)
    .map((f) => f.kalit);
}
console.log(yetimlar(royxat, ilovalar, hozir));
console.log(yetimlar(royxat, [], hozir, 36 * SOAT));

Konsolda:

text
[ 'fayllar/2/c.png', 'vaqtincha/1/x.pdf' ]
[ 'fayllar/2/c.png', 'fayllar/1/a.png' ]

y.pdf atigi 2 soatlik — foydalanuvchi hali formani to'ldirayotgan bo'lishi mumkin. Bu JS — SQL'dagi NOT EXISTS + ORDER BY yaratilgan_vaqt ning aynan o'zi; farqi — baza buni indeks bilan millionlab qatorda qiladi.

3-mashq (qiyin): uzilgan tozalash

Soxta ombor(buziladigan) berilgan: ochir(k) uning buziladigan-chi chaqiruvida xato tashlaydi. tozala(baza, s3) funksiyasini yozing: baza (Set) dagi har kalitni avval s3 dan, keyin bazadan o'chirsin. 3-chaqiruvda buziladigan ombor bilan bir marta, xatoni ushlab, holatni chiqaring; so'ng qayta chaqirib, yakuniy holatni chiqaring.

js
function ombor(buziladigan) {
  const bor = new Set(["a", "b", "c", "d"]);
  let chaqiruv = 0;
  return {
    bor,
    ochir(k) {
      chaqiruv++;
      if (chaqiruv === buziladigan) throw new Error(`tarmoq: ${k}`);
      bor.delete(k);
    },
  };
}
Yechim
js
function ombor(buziladigan) {
  const bor = new Set(["a", "b", "c", "d"]);
  let chaqiruv = 0;
  return {
    bor,
    ochir(k) {
      chaqiruv++;
      if (chaqiruv === buziladigan) throw new Error(`tarmoq: ${k}`);
      bor.delete(k);
    },
  };
}

function tozala(baza, s3) {
  for (const k of [...baza]) {
    s3.ochir(k);
    baza.delete(k);
  }
}

const baza = new Set(["a", "b", "c", "d"]);
const s3 = ombor(3);
try { tozala(baza, s3); } catch (e) { console.log(e.message); }
console.log("baza:", [...baza], "| s3:", [...s3.bor]);
tozala(baza, s3);
console.log("baza:", [...baza], "| s3:", [...s3.bor]);

Konsolda:

text
tarmoq: c
baza: [ 'c', 'd' ] | s3: [ 'c', 'd' ]
baza: [] | s3: []

Uzilishdan keyin baza va ombor bir xil holatda — c va d ikkalasida bor. Qayta urinish hammasini tozaladi. Tartibni almashtiring (baza.delete avval) — uzilishdan keyin bazada d qoladi, omborda esa c va d: c endi hech qachon topilmaydi. [...baza] — Set ni aylanib chiqayotganda o'chirmaslik uchun nusxa.

13. Loyiha qadami: 201-fayllar.sql va metama.ts

Kanon ikki artefakt yozdi: sxema («Sxema» bo'limidagi CREATE TABLE lar + RLS siyosatlari) va metama.ts moduli. Modul funksiyalari:

Funksiya Nima qiladi
tayyorFaylYoz(db, f) INSERT … ON CONFLICT DO NOTHING; dublikat bo'lsa eski qator, dublikat: true
biriktir(db, egasi, vazifa, fayl, nom) tranzaksiya: egalik, holat, limit 10, FOR UPDATE, idempotent
ilovalar(db, egasi, vazifa) vazifa fayllari ro'yxati (nom, tur, hajm, kalit)
ilovaniOl(db, egasi, vazifa, fayl) ilovani olib tashlaydi (fayl yetim bo'ladi)
yetimlarniTozala(db, ombor, yosh, partiya) avval ombor, keyin baza; SKIP LOCKED, partiyalab

tayyorFaylYoz — dublikat holati:

ts
export async function tayyorFaylYoz(
  db: Pool | PoolClient,
  f: { egasiId: number; kalit: string; mime: string;
       hajm: number; sha256: string },
): Promise<{ fayl: Fayl; dublikat: boolean }> {
  const r = await db.query<FaylQator>(
    `INSERT INTO fayllar (egasi_id, kalit, mime, hajm, sha256,
                          holat, tasdiqlangan_vaqt)
     VALUES ($1, $2, $3, $4, decode($5, 'hex'), 'tayyor', now())
     ON CONFLICT (egasi_id, sha256) DO NOTHING
     RETURNING id, egasi_id, kalit, mime, hajm, sha256, holat`,
    [f.egasiId, f.kalit, f.mime, f.hajm, f.sha256],
  );
  const yangi = r.rows[0];
  if (yangi) return { fayl: qatordan(yangi), dublikat: false };
  const eski = await db.query<FaylQator>(
    `SELECT id, egasi_id, kalit, mime, hajm, sha256, holat
       FROM fayllar
      WHERE egasi_id = $1 AND sha256 = decode($2, 'hex')`,
    [f.egasiId, f.sha256],
  );
  const q = eski.rows[0];
  if (!q) {
    throw new Error("ON CONFLICT bo'ldi, lekin qator topilmadi" +
      " (parallel o'chirildi)");
  }
  return { fayl: qatordan(q), dublikat: true };
}

dublikat: true bo'lsa chaqiruvchi yangi yuklangan obyektni ombordan o'chiradi. decode($5, 'hex') — JS'dagi hex satr bytea ga.

Kanon testlari test/fayl-metama.test.ts da — 7 ta. Ular Postgres va MinIO bilan, har test fayli shablon bazadan vaqtinchalik nusxada ishlaydi. Tekshiriladi: dedup, parallel 10 → 1 yozuv, begona vazifa/fayl → topilmadi va limit, parallel 15 → aynan 10, yetimlar ombordan ham o'chadi, RESTRICT/CASCADE, RLS. Kanonda hammasi o'tdi (82/82). Bizda MinIO yo'q — to'plam ﹣ … # DATABASE_URL yoki S3_ENDPOINT yo'q bilan o'tkazildi, shuning uchun mantiqni «Kanon modulini o'z bazamizda yurgizdik» bo'limida soxta ombor bilan yurgizdik.

Commit xabari (modul va sxema uchun):

bash
M='feat(fayl): fayllar va vazifa_ilovalari — egasi bo'yicha dedup,'
M+=' biriktirish, yetimlar (201)'
git commit -m "$M"

vazifalar-api ga ulash 28-qism (auth) tugagach: migratsiya, uchta marshrut va tozalovchi cron.

14. Real ishda

  • Fayl bilan ishlaydigan ko'p ilovalar shu naqshni qo'llaydi: metama'lumot bazada, baytlar object storage'da.
  • Dedup — bulut xizmatlarida keng; foydalanuvchilararo dedup yon kanal sababli ehtiyotkorlik bilan qilinadi.
  • Kvota — "foydalanuvchiga 1 GB": SELECT sum(hajm) FROM fayllar WHERE egasi_id = $1 — omborni so'ramasdan.
  • Intervyuda: "Fayl metama'lumotini qayerda saqlaysiz?", "Ombor va baza nomuvofiqligini qanday tozalaysiz?", "Dedup qanday ishlaydi?"

Xulosa

  • Baytlar — omborda, metama'lumot (kalit, tur, hajm, sha256, holat, egasi) — bazada.
  • UNIQUE (egasi_id, sha256) + ON CONFLICT DO NOTHING — atomik dedup; faqat egasi ichida (yon kanal).
  • CHECK lar yarim holatni (hash'siz "tayyor"), SVG va katta faylni baza darajasida to'sadi.
  • Fayl FK RESTRICT, vazifa FK CASCADE — vazifa o'chsa fayl yetim bo'ladi.
  • Biriktirish tranzaksiyada: egalik, holat, limit, FOR UPDATE.
  • Yetimlar: avval ombor, keyin baza; FOR UPDATE SKIP LOCKED, partiya; 100 000 faylda 6.4 ms.

Keyingi dars: Rasmlarni qayta ishlash: sharp — yuklangan rasmdan kichik ko'rinishlar, WebP, EXIF/GPS tozalash va piksel bomba.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!