Dilshod.dev

Bitta tushib qolgan WHERE

Dasturchining esida turishiga tayangan tenant izolyatsiyasi bir kun baribir buziladi — buning uchun bitta query yetadi. Chegarani Postgres ichiga tushirib, unutishning o'zini imkonsiz qilish yo'li.

Muallif: Dilshod Abdullayev10 daqiqa o'qish

Tasavvur qiling: mijoz dashboard'ingizdan oylik eksportni ochdi. Ustunlar tanish. Raqamlar begona. Faylning o'rtasida esa u nomini ham eshitmagan kompaniyaga yozilgan hisob-fakturalar turibdi — aynan raqobatchisi, ayni shu platformada.

Buning ortida hech qanday dramatik voqea yo'q. Kimdir juma kuni hisobot uchun endpoint qo'shdi, invoices ustidan query yozdi, ikkita jadvalni join qildi, sanaga qarab filtrladi va deploy qildi. WHERE tenant_id = $1 esa u yerda hech qachon bo'lmagan. Barcha testlar o'tdi, chunki test bazasida bor-yo'g'i bitta tenant bor edi.

Ko'p tenantli tizimlardagi ma'lumot sizib chiqishining butun hikoyasi shu. Va men shuning uchun izolyatsiya ilova kodida yashashiga endi ishonmayman.

Intizom — bu nazorat emas

Agar tenant chegarangiz "har bir dasturchi filtrni esidan chiqarmaydi" degan gap bo'lsa, xavfsizlik modelingizning xato ehtimoli siz umuman yozadigan querylar soniga proportsional. Shu paytgacha yozilganlariga emas — keyin yoziladiganlariga: bu postni o'qimagan odamlar tomonidan, kechqurun soat oltida, muddat siqib turganda, boshqa ikki yuzta query allaqachon to'g'ri yozilgan kod bazasida.

Code review ularning ko'pini ushlaydi. Muammo — "ko'pi"da. Sizib chiqish bu tajribaning yomonlashuvi emas, bu mijozga aytishga majbur bo'ladigan oshkor bo'lish hodisasi. Va buning uchun mingtadan bitta query yetadi.

Shuning uchun foydali savol "qanday qilib esimizda tutamiz?" emas. Savol shu: unutishni zararsiz qiladigan narsa nima?

Uchta model, uchta har xil hisob-kitob

Har tenantga alohida baza. Mavjud eng kuchli izolyatsiya. Query boshqa tenantning qatorlariga yetib bora olmaydi, chunki bu qatorlar umuman boshqa bazada. Tiklash tenant bo'yicha, "shovqinli qo'shni" muammosi cheklangan, "ma'lumotlarimiz jismonan alohidami?" deb so'ragan mijozga toza "ha" deb javob berasiz.

Hisob esa eksplutatsiyada keladi. Migration N marta ishlaydi va 500 tenantdan 340-chisida yiqilishi mumkin — natijada parkingizning yarmi bir schema versiyasida, yarmi boshqasida qoladi. Connection pool'lar ko'payadi. Tenantlar kesimidagi analitika query o'rniga butun boshli data pipeline'ga aylanadi. O'nta tenantda bu qulay. Mingtada bu to'liq stavkali ish.

Har tenantga alohida schema. Bitta baza, har tenantga bitta schema, har ulanishda search_path sozlanadi. Alohida bazalardan yengilroq, devor esa hamon ancha qattiq. Lekin migration'larning ko'payishi bilan bog'liq og'riqning katta qismi baribir sizga o'tadi, ustiga Postgres'ning o'zi ham masshtabda norozilik bildira boshlaydi: minglab schema degani yuz minglab jadval degani, tizim katalogining shishishi esa planning va pg_dump ni tashxis qilish qiyin bo'lgan tarzda sekinlashtiradi.

Umumiy jadval va tenant_id. Bitta schema, bitta jadvallar to'plami, hamma joyda tenant ustuni. Bitta migration, bitta connection pool, tenantlar kesimidagi hisobot esa arzimas ish. Ko'pchilik SaaS backend'lar shunday ishlaydi va bu odatda to'g'ri tanlov.

Buning evaziga u nima talab qilishini ochiq aytish kerak: siz jismoniy chegarani mantiqiysiga almashtirdingiz. Endi izolyatsiya — bu predikat, ya'ni tushirib qoldirish mumkin bo'lgan narsa. Umumiy jadvalni o'sha predikatni majburlaydigan mexanizmsiz tanlash — arzon variantni olib, to'lovni to'lamay ketish bilan barobar.

Postgres o'zi rad etsin

Row-Level Security — o'sha mexanizm. Ko'rinish qoidasini jadvalga bir marta yozasiz, baza esa uni shu daqiqadan boshlab har bir statement'ga qo'llaydi — shu jumladan kelasi yili kimdir bu yozuvlarni o'qimasdan yozadigan query'ga ham.

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- FORCE siyosatni jadval egasiga ham qo'llaydi.
-- Usiz owner roli quyidagi hamma narsani jimgina chetlab o'tadi.
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);

Ikkita clause, ikkita vazifa. USING nimani o'qish, yangilash yoki o'chirish mumkinligini filtrlaydi. WITH CHECK esa nimani yozayotganingizni tekshiradi — usiz tenant bemalol boshqa birovning tenant_id si bosilgan qatorlarni insert qilib ketaveradi. Bu sizib chiqishning teskari ko'rinishi va uni sezish yanada qiyinroq.

current_setting dagi true "o'rnatilmagan bo'lsa xato o'rniga NULL qaytar" degani. O'rnatilmagan tenant shunda NULL beradi, predikat rost bo'lmaydi va query hech narsa qaytarmaydi. Nol qatorga "yopilib" yiqilish — sizga kerak bo'lgan xatti-harakat: xato boshqa birovning hisob-fakturalariga emas, bo'sh hisobotga aylanadi.

Endi tushib qolgan filtr hech narsani anglatmaydi.

-- Kirishdagi o'sha hisobot query'si, hech qanday tenant filtrisiz.
SELECT id, total, issued_at FROM invoices WHERE issued_at >= date_trunc('month', now());
-- Postgres uni xuddi siz shunday yozgandek qayta yozadi:
--   ... AND tenant_id = current_setting('app.tenant_id', true)::uuid

Connection pool — hammasi buziladigan joy

RLS tenantni sessiya o'zgaruvchisidan o'qiydi va aynan shu joyni sekin o'qish kerak, chunki bu — xavfsizlik funksiyasini o'zining sizib chiqishiga aylantiradigan mina.

API'yingiz har foydalanuvchi uchun alohida ulanish ushlab turmaydi. U ulanishni pool'dan oladi, ishlatadi, qaytaradi. Agar tenantni SET app.tenant_id bilan o'rnatsangiz, bu sozlama so'rovda emas, ulanishda yashaydi va ulanish pool'ga o'sha sozlamani ko'tarib qaytadi. Keyingi so'rov — allaqachon boshqa mijoz — aynan o'sha ulanishni oladi va agar shu yo'lda biror joyda query'dan oldin tenant o'rnatilmasa, u oldingi so'rovning shaxsini meros qilib oladi.

Bu nosozlik tartibsizligi bilan yomon. U pool hajmiga, parallellikka va qaysi handler sozlashni o'tkazib yuborganiga bog'liq. Bitta ulanish va sichqonchani bosib yurgan bitta foydalanuvchi bilan lokalda u hech qachon takrorlanmaydi.

Yechim — SET LOCAL. U joriy tranzaksiya doirasida amal qiladi va commit yoki rollback'da bekor bo'ladi, demak pool'ga hech narsa qaytmaydi.

// Tenantga bog'liq har bir ish birligi tranzaksiya ichida bajariladi.
async function withTenant<T>(tenantId: string, fn: (tx: Tx) => Promise<T>): Promise<T> {
  return db.$transaction(async (tx) => {
    // SET LOCAL tranzaksiya doirasida: COMMIT yoki ROLLBACK'da yo'qoladi.
    // set_config ning uchinchi argumenti (is_local = true) — parametr qabul qiladigan shakli.
    // SET LOCAL bind parametrlarni qabul qilmaydi, shuning uchun id ni SQL ichiga hech qachon qo'shmang.
    await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
    return fn(tx);
  });
}

Bundan ikkita cheklov kelib chiqadi. Tenantga bog'liq har bir o'qish endi tranzaksiyani talab qiladi — hatto autocommit'da ishlab ketishi mumkin bo'lgan bitta statementli o'qishlar ham: bir oz ko'proq round trip, va uzoq davom etadigan tranzaksiyalar endi kuzatib turadigan narsaga aylanadi.

Agar siz PgBouncer kabi transaction-mode pooler ishlatsangiz, SET LOCAL to'g'ri, SET esa ochiqdan-ochiq xavfli — chunki pooler har tranzaksiyaga boshqa backend beradi. Sessiya doirasidagi har qanday narsa tanga tashlashga aylanadi. Bu men qaysi ulanishda turganimni o'ylab o'tirgandan ko'ra bitta qo'shimcha BEGIN uchun to'lashni afzal ko'radigan kam sonli joylardan biri.

Ilovani unuta olmaydigan qilib qo'ying

RLS — bu poydevor, butun bino emas. Ilova kodi tenantsiz query'ni umuman ishga tushira olmasligi kerak. NestJS'da so'rov doirasidagi tenant token'dan olinadi, AsyncLocalStorage kontekstiga tushadi va baza bilan faqat repository qatlami gaplashadi.

// tenant.context.ts — joriy tenantni bitta joy ushlab turadi.
const storage = new AsyncLocalStorage<{ tenantId: string }>();
 
export const runWithTenant = <T>(tenantId: string, fn: () => T) =>
  storage.run({ tenantId }, fn);
 
export function currentTenant(): string {
  const ctx = storage.getStore();
  // Xato tashlash standart qiymatdan yaxshiroq. Tenant yo'qligi — bu bug, ishlov beriladigan holat emas.
  if (!ctx) throw new Error('No tenant in context');
  return ctx.tenantId;
}
// tenant.middleware.ts — bir marta, chekkada o'rnatiladi.
@Injectable()
export class TenantMiddleware implements NestMiddleware {
  use(req: Request, _res: Response, next: NextFunction) {
    const tenantId = req.user?.tenantId;
    if (!tenantId) throw new UnauthorizedException();
    runWithTenant(tenantId, next);
  }
}
// tenant.repository.ts — bazaga yagona yo'l. Xom klient tashqariga chiqarilmaydi.
@Injectable()
export class TenantRepository {
  constructor(private readonly db: PrismaService) {}
 
  // Har bir chaqiruv o'ralgan, shuning uchun tenant har doim statementdan oldin o'rnatiladi.
  run<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
    return withTenant(currentTenant(), fn);
  }
 
  invoicesForMonth(month: Date) {
    // Bu query'da tenant_id yo'q. Uni RLS qo'shadi. Ikkala qatlam kelishgan.
    return this.run((tx) => tx.invoice.findMany({ where: { issuedAt: { gte: month } } }));
  }
}

Ko'p qatlamli himoya degani — repository va RLS bir-biriga bog'liq emas. Kirishdagi sizib chiqishni ularning har biri yolg'iz o'zi ham to'xtatgan bo'lardi. Birgalikda esa chetlab o'tish uchun bir vaqtda ikkita xato kerak bo'ladi, bu esa bitta unutilgan clause'dan sezilarli darajada boshqacha risk profili.

Filtr aslida tushib qoladigan beshta joy

Mening tajribamda dasturchi ongli ravishda yozayotgan endpoint kamdan-kam sizib chiqish manbai bo'ladi. Sizib chiqish "joriy tenant" noaniq yoki umuman yo'q bo'lgan kod yo'llarida yashiringan.

Fon vazifalari. So'rov yo'qligi middleware yo'qligini, bu esa kontekstda tenant yo'qligini bildiradi. Kechasi ishlaydigan billing vazifasi loyihasi bo'yicha barcha hisob-fakturalar bo'ylab yuradi — keyin esa kimdir uning query'sini bitta tenantga mo'ljallangan funksiyaga ko'chiradi. Vazifalar tenantni payload'da olib yurishi va uni aniq o'rnatishi shart, haqiqatan tenantlararo bo'lgan vazifa esa filtrsiz ishlash o'rniga tenantma-tenant aylanishi kerak.

Admin endpoint'lari. Ular hamma narsani ko'rishi kerak, shuning uchun kimdir ularni BYPASSRLS roli bilan ulaydi. O'sha rol endi kod bazangizda bor va u albatta qayta ishlatiladi — debug skripti uchun, migration uchun, "vaqtinchalik" ichki asbob uchun. Uni alohida ulanishda, alohida konfigda, alohida modulda saqlang va uning qo'llanish doirasini kengaytiradigan har qanday diff'ni xavfsizlik ko'rigi sifatida qarang.

Eksport va hisobotlar. Join va CTE'lardan iborat uzun querylar, ko'pincha ORM noqulaylik qilgani uchun repository qatlamidan tashqarida yozilgan. Aksariyat kod bazalarida bu eng yuqori riskli toifa: xom SQL, keng natijalar to'plami va faylni email qilib yuboradigan yetkazish mexanizmi.

Kesh kalitlari. Bunisi alohida jumlaga loyiq: invoice:summary:2026-08 — bu umuman bazaga murojaatsiz sodir bo'ladigan tenantlararo sizib chiqish. RLS bu yerda yordam bera olmaydi, chunki Redis — Postgres emas. A tenanti kalitni to'ldiradi, B tenanti uni o'qiydi va siz qurgan barcha izolyatsiya nazoratlari oddiy bir satr tufayli chetlab o'tiladi.

// Kod bazasidagi yagona kesh kaliti quruvchisi. Tenant ixtiyoriy emas.
export const cacheKey = (...parts: string[]) =>
  ['t', currentTenant(), 'v2', ...parts].join(':');

Qidiruv indekslari. Elasticsearch, Meilisearch, pgvector — o'z filtrlash qoidalariga ega va sizning siyosatlaringizdan bexabar ikkinchi ombor. Har bir hujjatda tenant maydoni bo'lishi, har bir query'da chaqiruvchi emas, wrapper qo'yadigan majburiy filtr bo'lishi va o'chirishlar ham tenantga bog'lanishi kerak.

Buni ushlab qoladigan test

Izolyatsiyani testlash mumkin, test esa shunchalik qisqaki, uni yozmaslikka bahona yo'q.

it('does not leak rows across tenants', async () => {
  const a = await createTenantWithInvoice({ total: 100 });
  const b = await createTenantWithInvoice({ total: 200 });
 
  const seenByA = await runWithTenant(a.id, () => repo.invoicesForMonth(startOfMonth()));
 
  expect(seenByA).toHaveLength(1);
  expect(seenByA.map((i) => i.tenantId)).not.toContain(b.id);
});

Buni umumiy helper qiling va tenantga bog'liq har bir repository metodi ustidan ishlating. Eng muhim variant — ataylab filtrsiz query'ni ishlatadigani: u repository'ngiz odobli ekanini emas, RLS yoqilganini isbotlaydi. Yozish tomonini ham tekshiring: boshqa tenantning id sini ko'tarib kelgan insert WITH CHECK tomonidan rad etilishi kerak, jimgina qabul qilinishi emas.

Meni bir marta tishlagan bitta ogohlantirish: testlaringiz ishlatadigan rol jadval egasi bo'lmasligi kerak — agar FORCE ROW LEVEL SECURITY qo'ymagan bo'lsangiz — va unda BYPASSRLS bo'lmasligi shart. Aks holda test noto'g'ri sabab bilan o'tadi va sizda hech narsani qo'riqlamaydigan yashil to'plam qoladi.

RLS sizga nimaga tushadi

Bularni uchinchi haftada o'zingiz kashf qilgandan ko'ra, hozir nomlab bergan yaxshiroq.

Query planning haqida fikr yuritish qiyinlashadi. Siyosat predikati har bir scan'ga qo'shiladi, planner'ning current_setting(...) bo'yicha baholari esa har doim ham siz kutgandek chiqmaydi. Ilgari Index Scan bo'lgan joylarda Seq Scan paydo bo'lishini kuzatib boring. Bu esa bizni indekslarga olib keladi: tenantga bog'liq jadvaldagi har bir indeks tenant_id bilan boshlanishi kerak, chunki bu ustun endi so'zning tom ma'nosida har bir query predikatida turibdi.

-- tenant_id bilan boshlash ham siyosatga, ham query'ning o'z filtriga xizmat qiladi.
CREATE INDEX invoices_tenant_issued_idx ON invoices (tenant_id, issued_at DESC);

Debug qilish g'alatiroq bo'ladi. "Qator bor, psql'da ko'rib turibman, lekin API 404 qaytaryapti" — bu deyarli har doim tenant sozlamasi muammosi, va bir necha marta duch kelmaguningizcha u shunga o'xshab ko'rinmaydi. Sekin querylar yonida faol app.tenant_id ni ham log qiling; bu o'zini darhol oqlaydi.

RLS ham bitta joyda intizom talab qilishda davom etadi: rollarni boshqarish. BYPASSRLS, superuser ulanishlari va jadval egaligi — ularning har biri yuqoridagi hamma narsani jimgina o'chirib qo'yadi. Siz inson omilini yo'q qilmadingiz. Siz uni minglab querylardan bir hovuch rolga ko'chirdingiz — butun gap ham shunda, chunki bir hovuch rolni ko'zdan kechirish mumkin.

Ostidagi shakl

Bu yerdagi qolip aslida tenantlar haqida emas. Gap shundaki, esda tutish orqali saqlanadigan har qanday invariant bir kun unutiladi, yechim esa uni unutishning o'zi ifodalab bo'lmaydigan qatlamga ko'chirish.

Siz bu harakatni boshqa joylarda ham ko'rgansiz. Insert'dan oldin tekshirish o'rniga unique constraint. Satr — bu user id ekanini aytadigan izoh o'rniga tip. Id haqiqatan bir narsaga ishora qilishiga umid qilish o'rniga foreign key.

Shuning uchun invariantni yozganingizda, uni buzishni qaysi qatlam rad eta olishini so'rang. Agar javob "dasturchi" bo'lsa, siz invariant yozmagansiz. Siz umid yozgansiz, umidlar esa jamoa o'sishi bilan juda yomon masshtablanadi.

O'xshash maqolalar

backendpayments

Soya rejimi: kodni ishonishdan oldin isbotlash

Joytop'dagi yangi to'lov tasdiqlash kodi bir hafta productionda soya rejimida ishladi — qaror qabul qilmasdan, faqat kuzatib. Ertaga u yagona qaror qiluvchiga aylanadi. Soya rejimi nima, u nimadan himoya qiladi va nega uni kuzatuvsiz yoqib qo'yib bo'lmaydi.

3 daqiqa o'qish
system-designbackend

Ikki marta yechilgan to'lov

Qayta urinish takroriy so'rov emas — to'lov ikki marta o'tib ketguncha. Idempotentlik kalitlari aslida qanday ishlaydi, sodda variant nega baribir ikki marta yechadi va qaysi Postgres cheklovi kafolatni haqiqiy qiladi.

7 daqiqa o'qish
postgresqlperformance

Postgres 18: asinxron I/O va UUIDv7 menda nimani o'zgartirdi

Asinxron I/O va ichki `uuidv7()` — asosiy yangiliklar shular edi. Ulardan biri men ikki yil chetlab o'tib kelgan muammoni jimgina hal qildi; ikkinchisi esa benchmarklar va'da qilganidan kamroq ish qildi.

5 daqiqa o'qish