Firestore — gondolkodj lekérdezésekben
- firestoreBevezető
- data-modelingKözéphaladó
01 — Az alapelv
Először a lekérdezés, aztán az adat
A négy gyakori lekérdezés-típus
- Egyetlen dokumentum lookup — ID alapján egyetlen rekord (1 olvasás).
- Kollekció szűrés — egy mező értéke alapján (N olvasás, ahol N az eredmény mérete).
- Kollekció rendezés és szűrés — pl. „aktív rendelések, létrehozási idő szerint csökkenően, top 20" (20 olvasás).
- Collection group query — minden azonos nevű subcollection (pl. „minden tenant összes rendelése"), egyetlen lekérdezésben.
- firestoreKözéphaladó
02 — A dokumentum
A dokumentum: egy egység, amit egyben olvasol
Korlátok, amiket érdemes észben tartani
| Korlát | Érték |
|---|---|
| Maximum dokumentum-méret | 1 MB |
| Maximum mezők egy dokumentumban | 20 000 |
| Maximum mélységi szint (beágyazott objektum) | 100 |
| Maximum egy tömbben | nincs konkrét határ, de a teljes dokumentum max. 1 MB |
| Maximum dokumentum-írás / másodperc (egy dokumentumra) | ~1 fenntartható |
| Maximum tranzakció-méret | 500 dokumentum |
Mit ágyazz be, mit ne
items tömbben tárolni. Egyetlen olvasás, és minden ott van.Beágyazás vagy subcollection?
Ha az elem mindig a parent kontextusában látható, az elemszám kicsi (max ~50), és a parent dokumentum összesen elfér 1 MB-ban. Pl. címek, telefonszámok, rendelés-tételek.
Ha az elemek száma korlátlan, vagy önállóan is le kell kérdezni őket. Pl. üzenetek, log bejegyzések, kommentek, történeti rekordok.
arrayUnion()-nal lehet hozzáadni, de egy konkrét elem mező-szintű módosításához az egész tömböt ki kell cserélni. Ha ez gyakori művelet, inkább subcollection legyen — még ha az elemszám kicsi is.- subcollectionKözéphaladó
03 — Hierarchia
Subcollection vagy root collection?
users / usr_abc # root collection
└── orders / ord_001 # subcollection
└── orders / ord_002
users / usr_abc # root collection
orders / ord_001 # separate root collection
└── userId: "usr_abc" # field for the relation
Mikor melyik?
- Subcollection akkor jó, ha az adat csak a parent kontextusában értelmes (pl. egy felhasználó saját címei, egy beszélgetés üzenetei). A struktúra magától beszédes lesz: a
users/usr_abc/addressesazonnal mond valamit. - Root collection akkor jó, ha az adatot gyakran a parent nélkül kérdezed le. Pl. „minden ma leadott rendelés" — ezt subcollection-ből csak collection group query-vel lehet, ami külön index, és egyébként is ugyanaz, mintha root lenne.
firestore:delete rekurzív kapcsolójával).Collection group query
import { collectionGroup, query, where, orderBy, limit } from 'firebase/firestore';
// Every order across every user — regardless of hierarchy
const q = query(
collectionGroup(db, 'orders'),
where('status', '==', 'active'),
orderBy('createdAt', 'desc'),
limit(20)
);
orders névre. Ha sok lekérdezésed parent-független, akkor lehet, hogy egyszerűbb lett volna eleve root collection-ben tartani az adatot, egy userId mezővel.- denormalizationKözéphaladó
04 — Denormalizáció
Denormalizáció — amit írsz kétszer, gyorsabban olvasod
JOIN users ON users.id = orders.user_id — egyetlen lekérdezés. Firestore-ban viszont a rendelés dokumentumon csak egy userId mező van. Hogy a nevet is megkapd, minden rendeléshez egy plusz olvasás kell — 20 rendelés listázásához tehát 20 + 20 = 40 olvasás.{
"id": "ord_001",
"userId": "usr_abc",
"userName": "Anna Kovács", // denormalized
"userEmail": "anna@example.com", // denormalized
"total": 12990,
"createdAt": "2026-04-21T09:14:00Z"
}
Mit kezdj a régi adattal?
Frissítjük vagy fagyasztjuk?
Egy onUpdate trigger a /users/{uid}-on, ami minden érintett rendelést újraír. Egyszerű, de drága lehet (sok rendelésnél sok írás). Akkor jó, ha az adat „mindig friss" elvárás (pl. profil oldalon).
A régi rendeléseken marad a régi név. Ez sokszor kifejezetten helyes! Egy 2 évvel ezelőtti számlán a vevő akkori neve a jó adat, nem a mostani. Pénzügyi és jogi rekordoknál mindig ezt válaszd.
Részleges denormalizáció
cachedUserName vagy denormUserName. Hat hónap múlva, amikor egy másik fejlesztő nézi, ez segít megérteni, miért van ott.- firestoreKözéphaladó
05 — Referenciák
Referenciák — foreign key Firestore-ban
| Szempont | String ID | DocumentReference |
|---|---|---|
| Tárolás | Sima string | Reference objektum (path) |
| Méret | kicsi | nagyobb (teljes path) |
| Migrálhatóság | Rugalmas (path-független) | Path-függő — kollekció átnevezésnél fáj |
| Típusbiztonság | Manuális | Beépített, de a path-ot SDK-szinten ismerni kell |
| Olvashatóság a Console-ban | Csak az ID | Kattintható link |
A referencia feloldása: 1 olvasás
N dokumentumot mutatsz, és minden elemhez kell egy referenciát is feloldani, akkor N + 1 probléma keletkezik (vagy N + N, ha minden elemnek külön referenciája van). Ez ugyanaz a probléma, amit ORM-ekben látunk — a megoldás Firestore-ban: denormalizálj.- many-to-manyKözéphaladó
06 — Many-to-many
Many-to-many kapcsolatok — három mintázat
1) Tömb a parent dokumentumban
{
"id": "usr_abc",
"name": "Anna Kovács",
"groupIds": ["grp_eng", "grp_design", "grp_admin"]
}
where('groupIds', 'array-contains', 'grp_eng') mindkét irányból működik: meg tudod kérdezni, hogy „melyik csoportokban van a user", és „kik vannak grp_eng-ben". Egyszerű, de van két korlátja: max 256 elem ajánlott egy tömbben (ennél több is mehet, de az index költsége nő), és nem tudsz extra metaadatot tárolni a kapcsolaton (pl. mikor csatlakozott, milyen szerepkörrel).2) Map a parent dokumentumban
{
"id": "usr_abc",
"groups": {
"grp_eng": { "role": "admin", "joinedAt": "2024-08-12" },
"grp_design": { "role": "member", "joinedAt": "2025-03-04" }
}
}
user.groups['grp_eng'].role). Hátránya: nem tudod lekérdezni, hogy „kik az adminok grp_eng-ben" — mert a map kulcsok nem indexelhetők egységesen. Akkor jó, ha mindig a user kontextusából indulsz.3) Kapcsolótábla (junction collection)
memberships
├── usr_abc__grp_eng # compound ID
│ ├── userId: "usr_abc"
│ ├── groupId: "grp_eng"
│ ├── role: "admin"
│ └── joinedAt: Timestamp
└── usr_xyz__grp_eng
where('userId', '==', ...) vagy where('groupId', '==', ...)), korlátlanul skálázódik, és tárolhat extra mezőket. Ára: minden lekérdezésnél két lépés (előbb a kapcsolótábla, aztán a céldokumentumok), amit denormalizációval lehet csökkenteni.Many-to-many döntés
- aggregationKözéphaladó
07 — Aggregátok
Aggregátok és számlálók
Aggregation queries
count(), sum() és average() műveleteket egy lekérdezésre — ez minden megkezdett 1000 dokumentum után 1 olvasásnak számít:import { collection, query, where, getCountFromServer, getAggregateFromServer, sum } from 'firebase/firestore';
// How many active orders are there?
const q = query(collection(db, 'orders'), where('status', '==', 'active'));
const snap = await getCountFromServer(q);
console.log(snap.data().count);
// What's total revenue?
const agg = await getAggregateFromServer(q, { revenue: sum('total') });
console.log(agg.data().revenue);
Mezőszámláló a parent dokumentumon
likeCount mező, amit minden lájk növel:import { doc, updateDoc, increment } from 'firebase/firestore';
await updateDoc(doc(db, 'posts', postId), {
likeCount: increment(1)
});
increment() egy szerver-oldali atomi művelet — a kliensnek nem kell beolvasnia és visszaírnia, így nincs race condition. De: ha a poszt nagyon népszerű és sokan lájkolják ugyanabban a pillanatban, beleütközünk a Firestore „1 write/sec per dokumentum" határába. Ekkor jön a sharding (lásd 08. szekció).- shardingHaladó
08 — Hot document
Hot document és sharding
Elosztott számláló
likeCount mező a posztra, és 100 user párhuzamosan lájkol. A Firestore sorbaállítja a kéréseket, és némelyik write timeout-ot kap. A megoldás: 10 shard-ra szétosztjuk a számlálót, és minden user egy véletlen shardba ír. Az összérték a shardok összege:// Structure: posts/{postId}/likeShards/{shardId}
// Each shard: { count: number }
const NUM_SHARDS = 10;
// Like — write to a random shard
async function incrementLike(postId: string) {
const shardId = Math.floor(Math.random() * NUM_SHARDS).toString();
const ref = doc(db, `posts/${postId}/likeShards/${shardId}`);
await updateDoc(ref, { count: increment(1) });
}
// Read — sum every shard
async function getLikeCount(postId: string): Promise<number> {
const snap = await getDocs(collection(db, `posts/${postId}/likeShards`));
return snap.docs.reduce((sum, d) => sum + d.data().count, 0);
}
Idő alapú particionálás
/stats/2026-04-28 dokumentumra írnánk minden hibalogot. De ha napi 100 ezer hiba van, az 1 dokumentumra ennyi write nem fér bele. Megoldás: óránkénti vagy 5-perces particionálás:stats
├── 2026-04-28-h00 # 0:00 - 0:59
├── 2026-04-28-h01
├── 2026-04-28-h02
└── ...
where szűrés a 24 dokumentumra.- paginationKözéphaladó
09 — Pagináció
Pagináció és időalapú lekérdezések
Cursor alapú pagináció
import { collection, query, orderBy, limit, startAfter, getDocs } from 'firebase/firestore';
// First page
const first = query(
collection(db, 'orders'),
orderBy('createdAt', 'desc'),
limit(20)
);
const snap = await getDocs(first);
const lastDoc = snap.docs[snap.docs.length - 1];
// Next page — continue from here
const next = query(
collection(db, 'orders'),
orderBy('createdAt', 'desc'),
startAfter(lastDoc),
limit(20)
);
startAfter() egy konkrét dokumentumtól folytatja a lekérdezést. Ez azért hatékony, mert a Firestore az indexen lépked, és nem kell a már átugrott rekordokat „beolvasnia". Egy SQL OFFSET 10000 minden átugrott sort érint — Firestore-ban a cursor csak megmondja, hol folytassa.Idő alapú lekérdezések — szervesen passzol
onSnapshot), és lapozható, akkor minden „új oldal" egy újabb listener — vagy elengeded a régieket, vagy a memóriahasználat növekszik. A Firestore SDK ezt nem oldja meg helyetted.- multi-tenancyHaladó
10 — Multi-tenancy
Multi-tenancy — négy stratégia
1) Egy projekt egy tenantnak — teljes szigetelés
2) Közös kollekció + tenantId mező
// One /orders root collection, every doc has a tenantId field
{
"id": "ord_001",
"tenantId": "acme-corp",
"userId": "usr_abc",
"total": 12990
}
where('tenantId', '==', currentTenant) kell, és a Security Rules-ban is meg kell védeni. Ha valaki elfelejti a szűrést, az adatok keveredhetnek — kockázatos.3) Tenantonként egy subcollection
tenants
├── acme-corp
│ ├── orders / ord_001
│ ├── products / prod_001
│ └── members / usr_abc
└── beta-inc
└── ...
match /tenants/{tid}/orders/{oid}, és csak az adott tenant tagjai férnek hozzá. Ez a leggyakoribb választás közepes SaaS-ekhez, mert egyszerre olcsó, biztonságos és átlátható.4) Identity Platform tenants
| Stratégia | Szigetelés | Költség | Skálázódás | GDPR egyszerűség |
|---|---|---|---|---|
| Egy projekt egy tenantnak | Teljes | Magas | Korlátos (sok tenant esetén) | Egyszerű (törléssel) |
| Közös + tenantId | Logikai | Alacsony | Kiváló | Bonyolult |
| Tenant subcollection | Path-szintű | Alacsony | Kiváló | Közepes |
| Identity Platform | Auth-szintű | Közepes | Kiváló | Egyszerű |
- data-modelingHaladó
11 — Példa
Egy SaaS rendelési rendszer — végigfutó példa
A lekérdezések, amiket ki kell szolgálni
- Egy tenant utolsó 30 napjának rendeléslistája, dátum szerint csökkenően.
- Egy konkrét felhasználó saját rendelései.
- Egy termék készletének ellenőrzése a rendeléskor.
- Havi árbevétel egy tenantra (riport).
- Top 10 legnépszerűbb termék egy tenantnál.
A struktúra
tenants / {tid}
├── name, plan, createdAt
├── members / {uid} # tenant-bound user
│ ├── role: 'admin' | 'user'
│ ├── displayName, email # denormalized from /users
│ └── joinedAt
├── products / {pid}
│ ├── name, price, stock
│ ├── purchaseCount # denormalized, for top lists
│ └── updatedAt
├── orders / {oid}
│ ├── userId, userDisplayName # denormalized
│ ├── items: [ # embedded array, max ~50 items
│ │ { productId, name, price, qty }
│ │ ]
│ ├── total, status, createdAt
│ └── month: '2026-04' # precomputed for reports
└── monthlyStats / {YYYY-MM} # aggregated monthly data
└── revenue, orderCount
A lekérdezések leképezése
| Query | Implementáció | Olvasás |
|---|---|---|
| 1. Tenant rendelései, 30 nap | tenants/acme/orders+ where('createdAt', '>', d30)+ orderBy('createdAt', 'desc')+ limit(50) | 50 |
| 2. Egy user rendelései | tenants/acme/orders+ where('userId', '==', uid)+ orderBy('createdAt', 'desc') | N (eredmény) |
| 3. Termék készlet | Egyszerű getDoc(tenants/acme/products/p123) | 1 |
| 4. Havi árbevétel | tenants/acme/monthlyStats/2026-04 egyetlen doc | 1 |
| 5. Top 10 termék | tenants/acme/products+ orderBy('purchaseCount', 'desc')+ limit(10) | 10 |
Háttér-szinkronizáció Cloud Function-nel
onWrite(/tenants/{tid}/orders/{oid}) trigger tartja karban:import { onDocumentWritten } from 'firebase-functions/v2/firestore';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
export const onOrderWritten = onDocumentWritten(
'tenants/{tid}/orders/{oid}',
async (event) => {
const db = getFirestore();
const tid = event.params.tid;
const before = event.data?.before.data();
const after = event.data?.after.data();
// 1) Update monthly stats
if (after) {
const month = after.month;
const diff = (after.total ?? 0) - (before?.total ?? 0);
await db.doc(`tenants/${tid}/monthlyStats/${month}`).set({
revenue: FieldValue.increment(diff),
orderCount: FieldValue.increment(before ? 0 : 1),
}, { merge: true });
}
// 2) Product purchase counters
if (!before && after) {
for (const item of after.items) {
await db.doc(`tenants/${tid}/products/${item.productId}`).update({
purchaseCount: FieldValue.increment(item.qty),
stock: FieldValue.increment(-item.qty),
});
}
}
}
);
sum() aggregation lenne minden olvasáskor — sokszorosa a tárolási és olvasási költségnek hosszú távon.A jó adatmodell minden lekérdezést két számjegynyi olvasással kiszolgál. Ha ezt nem tudja, akkor még nem ér véget a tervezés.
- data-modelingHaladó
12 — Konklúzió
Tervezési checklist
- ☐Felírtam a top 5-10 lekérdezést, amit ennek a kollekciónak ki kell szolgálnia. Mindegyikhez tudom, hány olvasást igényel.
- ☐Van olyan lekérdezés, ami N + 1 problémát okoz? Ha igen, denormalizációval megoldható-e?
- ☐A dokumentum mérete a tervezett tartalommal nem fogja közelíteni az 1 MB-os határt egy év múlva sem?
- ☐Ha beágyazott tömböt használok, az elemszáma korlátos (max ~50-100), és az elemek ritkán módosulnak részlegesen?
- ☐Subcollection esetén tudom, hogy a parent dokumentum törlése nem érinti a subcollection-t — és van-e tervem a takarításra?
- ☐Van-e olyan dokumentum, ami másodpercenként többször írásra kerülne? Ha igen, sharding kell.
- ☐Multi-tenant rendszer esetén az adatszigetelés a path-ban vagy a tenantId mezőben van? Mindkét esetben fedi-e a Security Rules?
- ☐Ha denormalizált adatokat használok, dokumentáltam, hogy melyik mező a forrás, és melyik a másolat?
- ☐Van-e olyan riport-lekérdezés, ami sok dokumentumot olvas? Ha igen, érdemesebb-e előre aggregálni Cloud Function-nel?
- ☐Az indexek deklarálva vannak a
firestore.indexes.json-ban, és nem hagyatkozom a Console „1-click" javaslataira élesben?