Pouzdano integriranje PHP usluga s obrascima transakcijskog izlaznog i ulaznog spremnika
Opasan trenutak u integraciji servisa nije HTTP zahtjev. To je potvrđivanje transakcije neposredno prije ili poslije njega. Ako je narudžba potvrđena, ali se njezin događaj nikada ne pošalje, nizvodni sustavi ostaju trajno nesvjesni. Ako se događaj pošalje prije potvrđivanja, a transakcija se kasnije poništi, potrošači djeluju na narudžbu koja ne postoji.
Obrazac transakcijskog outboxa zatvara taj jaz pohranjivanjem poslovne promjene i događaja u jednoj transakciji baze podataka. Zaseban relej isporučuje potvrđene događaje. Obrazac inboxa dovršava dizajn tako što učinak na bazu podataka potrošača čini idempotentnim.
Ovaj vodič izrađuje dva PHP 8.3 servisa podržana PostgreSQL-om: servis Orders s outboxom i servis Billing s inboxom. Isporuka je namjerno opisana kao najmanje jednom. Ponovni pokušaji mogu proizvesti duplikate zahtjeva, ali ne mogu proizvesti duplikate računa.
Preduvjeti i arhitektura
Potrebni su vam PHP 8.3 s proširenjima PDO PostgreSQL, cURL i JSON; PostgreSQL 14 ili noviji; te pristup naredbenom retku za obje baze podataka. Produkcijski HTTP promet trebao bi koristiti TLS, iako lokalne naredbe za provjeru koriste loopback HTTP.
Tijek je:
- API Orders umeće narudžbu i događaj
OrderCreatedu jednoj transakciji. - Relej nakratko preuzima redak outboxa, potvrđuje preuzimanje, a zatim izvršava mrežni zahtjev.
- API Billing umeće ID događaja u svoj inbox i stvara račun u jednoj transakciji.
- Relej označava događaj objavljenim tek nakon što Billing vrati uspjeh.
Relej nikada ne drži transakciju baze podataka otvorenom tijekom HTTP-a. Njegov najam omogućuje oporavak nakon rušenja, dok FOR UPDATE SKIP LOCKED omogućuje višestrukim procesima releja da preuzmu različite retke.
Struktura projekta
reliable-integration/
config.php
orders.php
inbox.php
relay.php
orders.sql
billing.sql
Stvorite baze podataka
U produkciji koristite zasebne baze podataka i vjerodajnice kako nijedan servis ne bi mogao mijenjati tablice drugog servisa. Sljedeće sheme pripadaju datotekama orders.sql i billing.sql, redom.
-- orders.sql
CREATE TABLE orders (
id uuid PRIMARY KEY,
amount_cents bigint NOT NULL CHECK (amount_cents > 0),
currency char(3) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE outbox (
id uuid PRIMARY KEY,
aggregate_id uuid NOT NULL REFERENCES orders(id),
event_type text NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
available_at timestamptz NOT NULL DEFAULT now(),
attempts integer NOT NULL DEFAULT 0,
claimed_by text,
lease_until timestamptz,
published_at timestamptz
);
CREATE INDEX outbox_ready_idx
ON outbox (available_at, occurred_at)
WHERE published_at IS NULL;
-- billing.sql
CREATE TABLE processed_events (
event_id uuid PRIMARY KEY,
processed_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE invoices (
id uuid PRIMARY KEY,
order_id uuid NOT NULL UNIQUE,
amount_cents bigint NOT NULL CHECK (amount_cents > 0),
currency char(3) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Stvorite dvije PostgreSQL baze podataka uobičajenim administrativnim postupkom, a zatim primijenite svaku datoteku računom s odgovarajućim ovlastima:
psql 'postgresql://[email protected]/orders' -f orders.sql
psql 'postgresql://[email protected]/billing' -f billing.sql
php -m | grep -E 'curl|json|pdo_pgsql'
Aplikacijski računi trebaju samo prava povezivanja, korištenja sheme, korištenja sekvenci gdje je primjenjivo te SELECT, INSERT i UPDATE nad vlastitim tablicama. Ne bi trebali posjedovati baze podataka niti dobiti ovlasti za stvaranje sheme.
Zajednička konfiguracija i ograničeni pozivi bazi podataka
Stavite stvaranje veze i generiranje UUID-a u config.php. Istek vremena povezivanja ne ograničava upite, stoga kod također konfigurira istjeke vremena PostgreSQL naredbi i zaključavanja.
<?php
declare(strict_types=1);
function requiredEnv(string $name): string
{
$value = getenv($name);
if ($value === false || $value === '') {
throw new RuntimeException("Missing environment variable: {$name}");
}
return $value;
}
function database(string $dsnName): PDO
{
$pdo = new PDO(
requiredEnv($dsnName),
requiredEnv($dsnName . '_USER'),
requiredEnv($dsnName . '_PASSWORD'),
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_PERSISTENT => false,
]
);
$pdo->exec("SET statement_timeout = '3000ms'");
$pdo->exec("SET lock_timeout = '1000ms'");
return $pdo;
}
function uuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
function jsonResponse(int $status, array $body = []): never
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($body, JSON_THROW_ON_ERROR);
exit;
}
U svaki PostgreSQL DSN uključite connect_timeout=3, na primjer pgsql:host=127.0.0.1;port=5432;dbname=orders;connect_timeout=3. Istek vremena naredbe od tri sekunde neovisan je i izričito postavljen.
Zapišite narudžbu i događaj atomarno
Krajnja točka Orders validira malu JSON naredbu i zapisuje oba zapisa u jednoj transakciji. Spremite je kao orders.php.
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
jsonResponse(405, ['error' => 'method_not_allowed']);
}
try {
$input = json_decode(file_get_contents('php://input'), true, 32,
JSON_THROW_ON_ERROR);
$amount = filter_var($input['amount_cents'] ?? null, FILTER_VALIDATE_INT);
$currency = strtoupper((string) ($input['currency'] ?? ''));
if ($amount === false || $amount < 1 ||
preg_match('/^[A-Z]{3}$/', $currency) !== 1) {
jsonResponse(422, ['error' => 'invalid_order']);
}
$db = database('ORDERS_DSN');
$orderId = uuidV4();
$eventId = uuidV4();
$payload = json_encode([
'order_id' => $orderId,
'amount_cents' => $amount,
'currency' => $currency,
], JSON_THROW_ON_ERROR);
$db->beginTransaction();
$stmt = $db->prepare(
'INSERT INTO orders (id, amount_cents, currency) VALUES (?, ?, ?)'
);
$stmt->execute([$orderId, $amount, $currency]);
$stmt = $db->prepare(
'INSERT INTO outbox
(id, aggregate_id, event_type, payload)
VALUES (?, ?, ?, CAST(? AS jsonb))'
);
$stmt->execute([$eventId, $orderId, 'OrderCreated', $payload]);
$db->commit();
jsonResponse(201, ['order_id' => $orderId, 'event_id' => $eventId]);
} catch (JsonException) {
jsonResponse(400, ['error' => 'invalid_json']);
} catch (Throwable $error) {
if (isset($db) && $db->inTransaction()) {
$db->rollBack();
}
error_log($error->getMessage());
jsonResponse(500, ['error' => 'internal_error']);
}
Ispad baze podataka uzrokuje neuspjeh zahtjeva umjesto prihvaćanja narudžbe bez događaja. Suprotno tome, ispad releja ne blokira stvaranje narudžbe; potvrđeni događaji ostaju dostupni za kasniju isporuku.
Učinite Billing idempotentnim pomoću inboxa
Relej autentificira točno tijelo zahtjeva pomoću HMAC-SHA256. Billing bilježi ID događaja prije primjene njegova učinka, u istoj transakciji. Spremite ovo kao inbox.php.
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
jsonResponse(405, ['error' => 'method_not_allowed']);
}
$body = file_get_contents('php://input');
$provided = $_SERVER['HTTP_X_EVENT_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $body, requiredEnv('EVENT_SECRET'));
if (!hash_equals($expected, $provided)) {
jsonResponse(401, ['error' => 'invalid_signature']);
}
try {
$event = json_decode($body, true, 32, JSON_THROW_ON_ERROR);
$id = (string) ($event['id'] ?? '');
$type = (string) ($event['type'] ?? '');
$data = $event['data'] ?? [];
if ($type !== 'OrderCreated' ||
preg_match('/^[0-9a-f-]{36}$/D', $id) !== 1 ||
preg_match('/^[0-9a-f-]{36}$/D', $data['order_id'] ?? '') !== 1 ||
!is_int($data['amount_cents'] ?? null) ||
($data['amount_cents'] ?? 0) < 1 ||
preg_match('/^[A-Z]{3}$/D', $data['currency'] ?? '') !== 1) {
jsonResponse(422, ['error' => 'invalid_event']);
}
$db = database('BILLING_DSN');
$db->beginTransaction();
$insert = $db->prepare(
'INSERT INTO processed_events (event_id)
VALUES (?) ON CONFLICT DO NOTHING RETURNING event_id'
);
$insert->execute([$id]);
if ($insert->fetchColumn() !== false) {
$invoice = $db->prepare(
'INSERT INTO invoices
(id, order_id, amount_cents, currency)
VALUES (?, ?, ?, ?)'
);
$invoice->execute([
uuidV4(),
$data['order_id'],
$data['amount_cents'],
$data['currency'],
]);
}
$db->commit();
http_response_code(204);
} catch (JsonException) {
jsonResponse(400, ['error' => 'invalid_json']);
} catch (Throwable $error) {
if (isset($db) && $db->inTransaction()) {
$db->rollBack();
}
error_log($error->getMessage());
jsonResponse(500, ['error' => 'internal_error']);
}
Ako stvaranje računa ne uspije, poništava se i umetanje u inbox. Ako Billing potvrdi transakciju, ali se njegov odgovor izgubi, relej ponovno šalje događaj; sukob u inboxu pretvara taj ponovni pokušaj u uspješno neizvršavanje. Jedinstveno ograničenje nad invoices.order_id pruža dodatnu invarijantu, a ne zamjenu za inbox.
Izradite relej s kratkim transakcijama
Spremite radnik kao relay.php. Njegov najam od 20 sekundi ugodno je dulji od isteka vremena povezivanja od jedne sekunde i ukupnog HTTP isteka vremena od pet sekundi.
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
pcntl_async_signals(true);
$stopping = false;
pcntl_signal(SIGTERM, function () use (&$stopping): void {
$stopping = true;
});
pcntl_signal(SIGINT, function () use (&$stopping): void {
$stopping = true;
});
$db = database('ORDERS_DSN');
$worker = gethostname() . ':' . getmypid();
$url = requiredEnv('BILLING_URL');
$secret = requiredEnv('EVENT_SECRET');
while (!$stopping) {
$db->beginTransaction();
$claim = $db->prepare(
"WITH picked AS (
SELECT id FROM outbox
WHERE published_at IS NULL
AND available_at <= now()
AND (lease_until IS NULL OR lease_until < now())
ORDER BY occurred_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE outbox AS o
SET claimed_by = ?, lease_until = now() + interval '20 seconds',
attempts = attempts + 1
FROM picked
WHERE o.id = picked.id
RETURNING o.id, o.event_type, o.payload::text, o.attempts"
);
$claim->execute([$worker]);
$event = $claim->fetch();
$db->commit();
if ($event === false) {
usleep(250000);
continue;
}
if ($stopping) {
$release = $db->prepare(
'UPDATE outbox SET claimed_by = NULL, lease_until = NULL
WHERE id = ? AND claimed_by = ? AND published_at IS NULL'
);
$release->execute([$event['id'], $worker]);
break;
}
$body = json_encode([
'id' => $event['id'],
'type' => $event['event_type'],
'data' => json_decode($event['payload'], true, 32,
JSON_THROW_ON_ERROR),
], JSON_THROW_ON_ERROR);
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Event-Signature: ' . hash_hmac('sha256', $body, $secret),
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 1,
CURLOPT_TIMEOUT => 5,
]);
curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$ok = curl_errno($curl) === 0 && $status >= 200 && $status < 300;
curl_close($curl);
if ($ok) {
$done = $db->prepare(
'UPDATE outbox
SET published_at = now(), claimed_by = NULL, lease_until = NULL
WHERE id = ? AND claimed_by = ? AND lease_until > now()'
);
$done->execute([$event['id'], $worker]);
} else {
$delay = min(60, 2 ** min((int) $event['attempts'], 6));
$retry = $db->prepare(
"UPDATE outbox
SET claimed_by = NULL, lease_until = NULL,
available_at = now() + CAST(? AS integer) * interval '1 second'
WHERE id = ? AND claimed_by = ?"
);
$retry->execute([$delay, $event['id'], $worker]);
}
}
Uvjeti vlasništva su važni. Ako zahtjev nadživi svoj najam, drugi radnik može ponovno preuzeti događaj. Izvorni radnik ne smije označiti preuzimanje novijeg radnika dovršenim. Dvostruka isporuka ostaje sigurna jer Billing posjeduje granicu idempotentnosti.
Pokrenite i testirajte cijeli put
Za ove naredbe koristite zasebne terminale. Varijable okruženja drže vjerodajnice izvan upravljanja izvornim kodom, iako su upravitelj tajni za produkciju ili zaštićena datoteka okruženja poželjniji od povijesti ljuske.
export ORDERS_DSN='pgsql:host=127.0.0.1;port=5432;dbname=orders;connect_timeout=3'
export ORDERS_DSN_USER='orders_app'
export ORDERS_DSN_PASSWORD='replace-locally'
export BILLING_DSN='pgsql:host=127.0.0.1;port=5432;dbname=billing;connect_timeout=3'
export BILLING_DSN_USER='billing_app'
export BILLING_DSN_PASSWORD='replace-locally'
export EVENT_SECRET='replace-with-a-long-random-secret'
export BILLING_URL='http://127.0.0.1:8081/inbox.php'
php -S 127.0.0.1:8080
php -S 127.0.0.1:8081
php relay.php
curl --fail-with-body \
-H 'Content-Type: application/json' \
--data '{"amount_cents":2599,"currency":"EUR"}' \
http://127.0.0.1:8080/orders.php
Upitajte obje baze podataka i potvrdite jedan objavljeni redak outboxa, jedan redak inboxa i jedan račun. Za testiranje oporavka zaustavite Billing, stvorite drugu narudžbu i promatrajte ponovne pokušaje s rastućim available_at. Ponovno pokrenite Billing i potvrdite konačnu obradu. Za testiranje uklanjanja duplikata privremeno postavite published_at događaja na null u potrošnoj testnoj bazi podataka, ponovno pokrenite relej i provjerite da se broj računa ne povećava.
Produkcijska sigurnost, vidljivost i performanse
Zamijenite PHP-ov razvojni poslužitelj podržanim web poslužiteljem i PHP-FPM-om. Ne izlažite javno ni PostgreSQL ni krajnju točku Billinga. Dopustite samo potrebnu putanju servis-prema-servisu u host i cloud vatrozidima, koristite TLS, rotirajte HMAC tajnu i razmotrite uzajamni TLS tamo gdje identitet servisa to opravdava. Primijenite ograničenja veličine zahtjeva prije PHP-a i nikada ne zapisujte tajne ni potpuna osjetljiva tijela zahtjeva u dnevnike.
Pokrenite relej pod namjenskim neprivilegiranim računom. Konfigurirajte upravitelja servisa s istekom vremena zaustavljanja većim od HTTP proračuna od pet sekundi plus vremena čišćenja baze podataka. Više instanci je sigurno, ali povećavajte ih tek nakon mjerenja kapaciteta nizvodnog sustava i konkurencije u bazi podataka.
Korisne metrike uključuju broj neobjavljenih redaka, starost najstarijeg spremnog događaja, pokušaje prema vrsti događaja, istjeke najmova, latenciju isporuke, status odgovora, sukobe inboxa i neuspjehe obrade. Upozorenja bi trebala naglašavati starost najstarijeg događaja, a ne samo dubinu reda: mali red koji sadrži jedan trajno zaglavljeni događaj operativno je značajan.
Za veću propusnost preuzmite malu seriju u jednoj kratkoj transakciji, a zatim je obradite izvan transakcije uz ograničenu konkurentnost. Najmove držite duljima od najgoreg dopuštenog trajanja zahtjeva, dodajte podrhtavanje kašnjenjima ponovnih pokušaja i definirajte pravilo za otrovne događaje. Nemojte zauvijek ponavljati trajne neuspjehe validacije 4xx; stavite ih u karantenu s njihovim metapodacima o pogrešci radi kontrolirane istrage. Nastavite ponavljati prolazne mrežne neuspjehe i odgovarajuće odgovore 5xx.
Uobičajeni načini neuspjeha
- Objavljivanje unutar transakcije narudžbe: mrežna latencija produljuje zaključavanja, a poništavanje može ostaviti već isporučen događaj.
- Zadržavanje preuzimanja tijekom pozivanja Billinga: spor HTTP pretvara outbox u sustav konkurencije za zaključavanja.
- Označavanje uspjeha bez provjere vlasništva: radnik s isteklim najmom može prepisati stanje novijeg nositelja najma.
- Uklanjanje duplikata izvan transakcije potrošača: rušenje između umetanja u inbox i poslovnog učinka može potisnuti nedovršeni rad.
- Pretpostavljanje da ponovni pokušaji podrazumijevaju učinke točno jednom: vanjski učinci poput e-pošte ili poziva za plaćanje zahtijevaju vlastite idempotentne ključeve i trajne strojeve stanja.
- Korištenje jednog isteka vremena svugdje: istjeci vremena za povezivanje, upit, zaključavanje i HTTP štite različite granice te moraju stati unutar proračuna najma i gašenja.
Završni kontrolni popis za provjeru
- Narudžba i događaj outboxa zajedno se potvrđuju ili poništavaju.
- Preuzimanja se potvrđuju prije početka bilo kakvog mrežnog ili poslovnog rada.
- Najmovi kojima je isteklo vrijeme mogu se ponovno preuzeti, a ažuriranja provjeravaju vlasništvo.
- Signal primljen tijekom rezervacije uzrokuje trenutačno oslobađanje bez novog rada isporuke.
- Operacije povezivanja, zaključavanja, upita i HTTP-a imaju zasebne ograničene istjeke vremena.
- Billingov zapis inboxa i račun dijele jednu transakciju.
- Ponovna reprodukcija događaja vraća uspjeh bez dupliciranja njegova učinka.
- Vjerodajnice imaju najmanje potrebne ovlasti, promet je ograničen, a produkcijski HTTP koristi TLS.
- Nadzorne ploče prikazuju starost zaostatka, ponovne pokušaje, istjek najma i neuspjehe potrošača.
- Proračuni gašenja implementacije premašuju ograničenu operaciju radnika u tijeku.
Pouzdana integracija ne proizlazi iz pretvaranja da se neuspjesi mogu ukloniti. Proizlazi iz odlučivanja o tome gdje točno živi trajna istina, omogućavanja ponavljanja svake nesigurne granice i osiguravanja da je svaki ponovni pokušaj bezopasan. S outboxom, relejom s najmom i transakcijskim inboxom, rušenja prestaju biti tajanstveni rubni slučajevi i postaju uobičajeni prijelazi stanja koje je vaš sustav dizajniran preživjeti.