Tek Beyin, Çok Vitrin: Multi-Storefront E-Ticaret Mimarisi
Üç vitrinli bir mağazayı MariaDB'de denedim: 40 eşzamanlı alıcıda oku-yaz stok kodu on ürünü 27 kez sattı, stok ekranı sıfır gösterdi. Alan adı çözümü, önbellek anahtarı, bileşik yabancı anahtar ve görünüm kemeriyle vitrinler arası sızıntı nasıl kapanır.
Bu yazının İngilizcesi de var →
Bir mağazaya ikinci alan adını bağlamak bir öğleden sonralık iştir. Logo değişir, tema değişir, $_SERVER['HTTP_HOST'] üstüne iki satır if yazılır, herkes mutlu olur. Sonra bir sabah toptan fiyatı perakende vitrinine düşer, ya da aynı son ürünü iki vitrin iki ayrı müşteriye satar.
Multi store e-ticaret mimarisinin konusu bu ikinci gün. Metaforumuz caddede yan yana dizilmiş üç vitrin ve arkalarında tek bir depo. Müşteri camdan bakar, etiketi görür; etiket her vitrinde farklıdır, raf aynıdır. Alan adı işin kolay kısmı. Asıl iş her tablo için "bu veri kimin?" sorusunu cevaplamak ve cevabı kodun iyi niyetine bırakmamak.
Camın nerede çatladığını tahmin etmek yerine sandbox'ta denedim: MariaDB 10.11.14, PHP 8.3.6, üç vitrin, bir milyon sipariş, aynı ürüne saldıran 40 eşzamanlı alıcı.
Multi Store ile Multi-Tenant Aynı Şey mi?#
Kelimeleri önce ayıralım, çünkü şema buradan dallanıyor.
- Multi store (çoklu vitrin): Mağazalar tek sahibe ait. Katalog, stok ve panel ortak; marka, fiyat veya dil ayrı.
- Multi-tenant (çok kiracılı): Aynı yazılımda birbirini tanımayan müşteriler var. Biri öbürünün verisini görürse bu görüntü hatası değil, ihlaldir.
Sizin vitrinleriniz ikisinin arasında bir yerde durabilir. Aynı holdingin iki şirketi, tek depodan satan iki marka gibi. O yüzden sınırı baştan yazmak gerekiyor. Olgun platformlar da aynı soruya farklı sınırlar çizmiş:
| Platform | Birim | Ne ayrılıyor | Dikkat çeken sınır |
|---|---|---|---|
| Adobe Commerce | Site > mağaza > mağaza görünümü | Menü, kök kategori, tasarım, çeviri | Fiyat global (varsayılan) ya da site düzeyinde; mağaza başına fiyat yok |
| Saleor | Kanal | Ürün erişimi, varyant fiyatı, kargo fiyatı | Kanal başına tek para birimi; ürünler kanala göre yetkilendirilmiyor |
| Sylius | Kanal | Fiyat, kanalın temel para biriminde | Kanal başına tek temel para birimi |
| Medusa | Satış kanalı | Ürün erişimi; sepet ve sipariş kanala bağlı | Stok, kanalın bağlı olduğu lokasyonlardan okunuyor |
Tablodaki ortak fikir şu: Saleor ve Medusa'da depo vitrinin malı değil, vitrinin bağlandığı ayrı bir kavram. Bizim şema da böyle olacak.
Envanter: Hangi Veri Kimin?#
Kod yazmadan önce bu tabloyu doldurun. Sonradan doldurmanın faturası veri taşımadır.
| Veri | Kapsam | Gerekçe |
|---|---|---|
| Ürün kimliği, SKU | Ortak | Aynı fiziksel ürün |
| Fiyat, görünürlük | Vitrin | Etiket vitrine aittir |
| Stok | Depo | Raf tektir, vitrin rafa bağlanır |
| Sepet, sipariş, kupon | Vitrin | Satışın sahibi vitrindir |
| Ödeme ayarı | Vitrin | Vitrinler farklı üye işyeriyle çalışabilir |
| Müşteri hesabı | Karar sizin | Ortak hesap kolaylık, ayrı hesap sınır. Vitrinler ayrı şirketse önce hukukçuya sorun |
Gerçek: store_id her tabloya girmez. Stok tablosuna store_id koyarsanız aynı rafı üç kere saymaya başlarsınız. Sahibi belli veri kapsamlanır, ortak veri bilerek ortak bırakılır.
CREATE TABLE store_products (
store_id INT UNSIGNED NOT NULL,
product_id INT UNSIGNED NOT NULL,
price_minor INT UNSIGNED NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (store_id, product_id),
FOREIGN KEY (store_id) REFERENCES stores (id),
FOREIGN KEY (product_id) REFERENCES products (id)
);
CREATE TABLE stock (
warehouse_id INT UNSIGNED NOT NULL,
product_id INT UNSIGNED NOT NULL,
qty INT NOT NULL,
PRIMARY KEY (warehouse_id, product_id),
CHECK (qty >= 0)
);
CREATE TABLE store_warehouses (
store_id INT UNSIGNED NOT NULL,
warehouse_id INT UNSIGNED NOT NULL,
priority TINYINT UNSIGNED NOT NULL,
PRIMARY KEY (store_id, warehouse_id)
);
price_minor kuruş cinsinden tamsayı. Satır fiyatı siparişe ayrıca kopyalanır (unit_price_minor). Yarın vitrin fiyatı değişince dünkü faturanın tutarı değişmesin.
Kapı: Alan Adından Vitrine#
Vitrin seçimi isteğin ilk işi ve girdisi saldırganın elinde. Host başlığını istemci gönderir. OWASP'ın Host Header Injection rehberi iki sonucu öne çıkarıyor: önbellek zehirleme ve parola sıfırlama linkinin saldırganın alan adıyla üretilmesi. Doğrulanan Host'un yanından X-Forwarded-Host ile dolanmak da aynı rehberde.
Hata: Tanınmayan alan adını varsayılan vitrine düşürmek. Bu kolaylık saldırgana hem sizin içeriğinizi hem sizin linklerinizi verir.
Çözüm: Tam eşleşmeli liste. Listede yoksa null, yani 404. X-Forwarded-Host yalnızca kendi vekil sunucunuzdan geliyorsa okunur.
<?php
declare(strict_types=1);
final readonly class Vitrin
{
public function __construct(
public int $id,
public string $kod,
public string $paraBirimi,
) {
}
}
/** Alan adını vitrine çevirir. Tanınmayan alan adı varsayılan vitrine düşmez. */
final readonly class VitrinCozucu
{
/**
* @param array<string, Vitrin> $alanlar alan adı => vitrin
* @param list<string> $guvenilirVekiller yalnızca kendi vekil IP'leriniz
*/
public function __construct(
private array $alanlar,
private array $guvenilirVekiller = [],
) {
}
/** @param array<string, string> $server $_SERVER */
public function coz(array $server): ?Vitrin
{
$kaynak = $server['HTTP_HOST'] ?? '';
if (
isset($server['HTTP_X_FORWARDED_HOST'])
&& in_array($server['REMOTE_ADDR'] ?? '', $this->guvenilirVekiller, true)
) {
$kaynak = $server['HTTP_X_FORWARDED_HOST'];
}
$alan = strtolower(rtrim((string) preg_replace('/:\d+$/', '', $kaynak), '.'));
return $this->alanlar[$alan] ?? null;
}
}
On bir girdiyle sınadım. MAGAZA-A.test:443 ve sondaki noktalı magaza-a.test. doğru vitrine gitti. magaza-a.test.evil.test, evil.test:80@magaza-a.test ve boş başlık null döndü. Yabancı IP'den gelen X-Forwarded-Host yok sayıldı. Güvenilir vekilden gelen export-a.test, evil.test zinciri de null döndü, çünkü tam eşleşmede pazarlık yok.
Cam: Vitrinler Arası Sızıntı#
Paylaşılan veritabanında izolasyon bir WHERE koşuluna bağlı. Microsoft'un çok kiracılı veri rehberi de ortak veritabanını en ucuz yol olarak anlatırken aynı borcu hatırlatıyor: kiracılar birbirinin verisine erişemesin diye plan yapmanız gerekiyor. Bir sorguda unutulan store_id hata vermez. Yanlış veriyi düzgün biçimde gösterir.
Bu yüzden üç kat cam koyuyorum. Her biri bir öncekinin unutulduğu günü karşılıyor.
Birinci kat: vitrini parametre değil bağımlılık yapın#
Sorgu yazan sınıf vitrini kurucusunda alırsa unutma ihtimali geliştiricinin dikkatinden çıkar. Önbellekte de durum aynı: anahtarın başını çağıran taraf değil sınıf yazar.
interface AnahtarDegerDeposu
{
public function oku(string $anahtar): ?string;
public function yaz(string $anahtar, string $deger, int $saniye): void;
}
/** Anahtarın başını çağıran değil sınıf yazar; vitrini unutmak mümkün olmaz. */
final readonly class VitrinOnbellegi
{
public function __construct(
private AnahtarDegerDeposu $depo,
private Vitrin $vitrin,
) {
}
public function oku(string $anahtar): ?string
{
return $this->depo->oku($this->tamAnahtar($anahtar));
}
public function yaz(string $anahtar, string $deger, int $saniye = 300): void
{
$this->depo->yaz($this->tamAnahtar($anahtar), $deger, $saniye);
}
private function tamAnahtar(string $anahtar): string
{
return sprintf('vitrin:%d:%s:%s', $this->vitrin->id, $this->vitrin->paraBirimi, $anahtar);
}
}
final readonly class SiparisDeposu
{
public function __construct(
private PDO $pdo,
private Vitrin $vitrin,
) {
}
/** @return list<array<string, mixed>> */
public function sonSiparisler(int $adet = 20): array
{
$sorgu = $this->pdo->prepare(
'SELECT id, created_at FROM orders
WHERE store_id = :vitrin
ORDER BY created_at DESC
LIMIT :adet'
);
$sorgu->bindValue(':vitrin', $this->vitrin->id, PDO::PARAM_INT);
$sorgu->bindValue(':adet', $adet, PDO::PARAM_INT);
$sorgu->execute();
return $sorgu->fetchAll(PDO::FETCH_ASSOC);
}
}
Testte ana vitrini urun:10:fiyat anahtarına 49900 yazdı, aynı depodan okuyan export vitrini null aldı. Anahtar vitrin:1:TRY:urun:10:fiyat olarak düştü. Sayfa önbelleği ve CDN tarafında da anahtarın alan adını içerdiğini ayrıca kontrol edin.
Index tarafı da bundan etkileniyor. Bileşik index'te ilk sütun çoğu zaman store_id olur; MariaDB index seçimi yazısındaki eşitlik önce, aralık sonda kuralı burada (store_id, created_at) olarak karşımıza çıkıyor.
İkinci kat: yabancı anahtarı vitrine bağlayın#
Klasik şema order_items.order_id üzerinden siparişe bağlanır ve satırın hangi vitrine ait olduğunu sormaz. Kod hatası bir vitrinin ürününü öbürünün siparişine yazabilir. Bileşik yabancı anahtar bunu şemada kapatıyor:
ALTER TABLE orders ADD UNIQUE KEY uq_store_id (store_id, id);
CREATE TABLE order_items (
store_id INT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
product_id INT UNSIGNED NOT NULL,
qty INT UNSIGNED NOT NULL,
unit_price_minor INT UNSIGNED NOT NULL,
PRIMARY KEY (store_id, order_id, product_id),
CONSTRAINT fk_item_order FOREIGN KEY (store_id, order_id)
REFERENCES orders (store_id, id),
CONSTRAINT fk_item_product FOREIGN KEY (store_id, product_id)
REFERENCES store_products (store_id, product_id)
);
2 numaralı vitrinin siparişine 1 numaralı vitrinin kimliğiyle satır eklemeyi denedim: ERROR 1452, fk_item_order. 2 numaralı vitrinde satışta olmayan ürünü eklemeyi denedim: yine 1452, bu sefer fk_item_product. Veritabanı artık vitrinler arası satırı reddediyor, sizin dikkatinizi beklemiyor.
Teknik Detay: Bu yapı için orders üzerinde (store_id, id) unique index'i şart. Yoksa tablo hiç oluşmuyor: errno: 150 "Foreign key constraint is incorrectly formed".
Üçüncü kat: MariaDB'de görünüm kemeri#
PostgreSQL bu işi row security politikalarıyla motora veriyor. MariaDB 10.11'de CREATE POLICY yazınca ERROR 1064 alıyorsunuz. Yaklaşık bir karşılığı görünüm, fonksiyon ve yetkiyle kurulabiliyor. Doğrudan @vitrin_id kullanmak olmuyor: CREATE VIEW dokümanı değişkeni yasaklıyor, deneyince ERROR 1351. Fonksiyon üzerinden geçiliyor:
CREATE OR REPLACE FUNCTION aktif_vitrin() RETURNS INT UNSIGNED
DETERMINISTIC READS SQL DATA
RETURN @vitrin_id;
CREATE OR REPLACE SQL SECURITY DEFINER VIEW vitrin_orders AS
SELECT id, store_id, customer_email, created_at
FROM orders
WHERE store_id = aktif_vitrin()
WITH CHECK OPTION;
GRANT SELECT, INSERT ON vitrin.vitrin_orders TO 'vitrin_app'@'localhost';
Uygulama kullanıcısının ana tabloda yetkisi yok. Sonuçlar:
- Değişken atanmadan sorgu 0 satır döndü. Kapı varsayılan olarak kapalı.
orderstablosunu doğrudan okumakERROR 1142verdi.- Başka vitrine
INSERTdenemesiERROR 1369 CHECK OPTION failedverdi.
Acı Gerçek: Fonksiyondaki DETERMINISTIC kelimesi süs değil. Bir milyon siparişte son 20 kaydı üç koşuda ölçtüm:
| Kapsam | Plan | Okunan index kaydı | Sayfa | Süre |
|---|---|---|---|---|
Doğrudan store_id = 2 |
ref | 20 | 3 | 0,04-0,05 ms |
Görünüm, DETERMINISTIC |
ref | 20 | 3 | 0,07-0,12 ms |
Görünüm, NOT DETERMINISTIC |
index | 1.000.001 | 3.713 | 1.760-2.128 ms |
Görünüm, alt sorgu + NOT DETERMINISTIC |
index | 1.000.001 | 3.713 | 1.456-2.288 ms |
Varsayılan olan NOT DETERMINISTIC ile aynı sorgu on binlerce kat yavaşladı, tablo tek tek gezildi. Bedeli de var: bu fonksiyon oturum değişkeni okuduğu için CREATE FUNCTION dokümanının tanımına göre gerçekten deterministik sayılmaz ve doküman böyle bir beyanın yanlış sonuç verebileceğini yazıyor. Aynı hazır sorguyu vitrin değiştirerek dört kez çalıştırdım, query cache açıkken dört kez daha denedim, sayılar hep doğru geldi. Bu "güvenli" demek değil, "bu senaryolarda bozulmadı" demek.
İki sınırını da söyleyeyim. Değişkeni uygulama atadığı için SQL injection bulan biri SET @vitrin_id = 1 diyebilir; ben denedim, görünüm öbür vitrini açtı. Bu yüzden adı kemer, duvar değil. İkincisi kalıcı bağlantılar: PDO::ATTR_PERSISTENT ile aynı bağlantı ikinci "istekte" önceki vitrinin @vitrin_id değeriyle geldi. Kalıcı bağlantı kullanıyorsanız değişkeni her isteğin başında atayın, sonunda NULL yapın.
Arka Depo: Aynı Son Ürüne Üç Vitrin#
Vitrinler stok paylaşınca alıcılar farklı kapılardan aynı rafa koşuyor. Stoğu 10 olan ürüne 40 eşzamanlı PHP süreci saldırttım, her yöntemi beş tur çalıştırdım. Sandbox iki çekirdekli.
| Yöntem | Satılan (5 tur) | Hata | Kalan stok |
|---|---|---|---|
Oku, hesapla, SET qty = ? yaz |
12, 19, 27, 15, 19 | Yok | 0 |
Oku, SET qty = qty - 1 yaz |
10 | 1-3 alıcıya 4025 |
0 |
SELECT ... FOR UPDATE |
10 | Yok | 0 |
Tek koşullu UPDATE |
10 | Yok | 0 |
İlk satırı dikkatle okuyun. On ürün 27 kez satıldı ve stok ekranı tertemiz sıfır gösterdi. Fazla satış raporda görünmüyor; müşteri hizmetlerine telefon olarak dönüyor.
İkinci satırı CHECK (qty >= 0) kurtardı. Satış doğru ama bazı alıcılar Error 4025 aldı. Kısıt son savunma hattı olarak iyi, akış kontrolü olarak kötü.
Üçüncü ve dördüncü satır doğru. Tek ürün düşülüyorsa koşullu UPDATE daha sade:
/** Stok düşümü tek sorgudur: etkilenen satır 1 ise ürün sizindir. */
function stokDus(PDO $pdo, int $depoId, int $urunId, int $adet): bool
{
$sorgu = $pdo->prepare(
'UPDATE stock SET qty = qty - :adet
WHERE warehouse_id = :depo AND product_id = :urun AND qty >= :gerekli'
);
$sorgu->execute([
':adet' => $adet,
':depo' => $depoId,
':urun' => $urunId,
':gerekli' => $adet,
]);
return $sorgu->rowCount() === 1;
}
Sipariş ve ödeme kaydıyla aynı işlemin içinde çalıştırın; false dönerse işlemi geri alın. Depoya mal girişi tarafında ise eski dostumuz ON DUPLICATE KEY UPDATE iş görüyor.
Arama Motoru Üç Vitrini Nasıl Görür?#
Aynı ürün üç alan adında aynı metinle duruyorsa arama motoru için kopyadır. Google'ın çok bölgeli site rehberi aynı dilde benzer içerik için tercih edilen sürümü rel="canonical" ile seçmeyi ve hreflang eklemeyi söylüyor. Vitrin başına ayrı sitemap, robots.txt ve canonical üretimi de VitrinCozucu'nun döndürdüğü vitrinden beslenmeli. Canonical'ı HTTP_HOST'tan üretirseniz kapıda kapattığınız deliği sayfa başlığında yeniden açarsınız.
Ajana Yazdırırken Kuralı Nereye Koymalı?#
Kodlama ajanı yeni bir rapor sorgusu yazarken mevcut desene uyduğu kadar iyidir. "Her sorgu ve önbellek anahtarı vitrin kapsamında olur, stok tablosu hariç" satırını AGENTS.md dosyasına yazın; Claude Code, Codex, Cursor ve Antigravity bu dosyayı farklı kurallarla da olsa okuyor. Yalnız kural dosyası bağlamdır, garanti değil. Kapıyı tutan yine bileşik yabancı anahtar ve vitrin sızıntısı testleridir: iki vitrinle veri kurup birinin ekranında öbürünün kaydını arayan birkaç test.
Ne Zaman Ayrı Veritabanı?#
Yukarıdaki düzen tek veritabanında üç vitrin içindir. Microsoft rehberinin ayrımı burada işe yarıyor: ortak veritabanı en ucuz ve en yoğun model, ama gürültülü komşu riski taşıyor ve yedekleme, konum gibi farklı ihtiyaçları karşılamakta zorlanıyor.
| Durum | Tercih |
|---|---|
| Tek şirket, ortak depo, ortak panel | Tek veritabanı, store_id |
| Vitrinler ayrı şirket, sipariş verisi ayrılmalı | Tek çekirdek, vitrin başına veritabanı tartışılır |
| Tenant başına yedek, konum, şifreleme isteği | Tenant başına veritabanı |
| Bir vitrin trafiği öbürlerini boğuyor | Önce sorgu ve önbellek, sonra parçalama |
Ayrı veritabanı stok paylaşımını da zorlaştırır. Tek rafı iki veritabanından saymak ayrı bir yazının konusu.
Vitrin camını silmek kolaydır, asıl emek arkadaki rafı kimin saydığını bilmekte. Etiketi vitrine, rafı depoya, hesabı da veritabanına bırakın; 27. müşteriye "ürün kalmamış" telefonunu siz açmayın.
Teknolojiyle kalın, vitrinleriniz birbirinin camına yaslanmasın.
Yorumlar
0 yorum
Yorum yazın