PDO Pool
Імпорт майже готовий: воркери перевіряють адреси через GeoDirectory. Лишається тільки зберегти перевірені адреси в базу даних. Звучить банально:
$pdo = new PDO('mysql:host=localhost;dbname=profiles', 'app', 'secret');
for ($i = 0; $i < 10; $i++) {
$workers->spawn(function () use ($queue, $pdo) {
foreach ($queue as $address) {
checkAddress($address);
saveAddress($pdo, $address);
}
});
}Один об'єкт PDO, який ділять десять корутин. У розділі про канали ми казали, що гонитви даних не трапляються в межах одного потоку, тож усе має бути гаразд, правда ж?
Ні. Це справедливо для пам'яті. Але з'єднання з базою даних це не пам'ять, це протокол поверх сокета: запит, відповідь, запит, відповідь, строго по черзі. Кожен запит до бази це точка очікування, у якій корутина засинає, а керування переходить до іншої. І та інша записує свій власний запит у той самий сокет:
// Воркер 1
$pdo->beginTransaction();
$pdo->exec("INSERT INTO addresses ...");
// точка очікування: воркер 1 засинає, воркер 2 прокидається
// Воркер 2
$pdo->beginTransaction(); // на тому самому з'єднанні!
$pdo->exec("UPDATE ...");
$pdo->commit(); // фіксує і свою транзакцію, і чужуВідповіді переплутуються, транзакції фіксують чужу роботу. Гонитви повернулися, тільки тепер вони живуть у з'єднанні, а не в пам'яті.
Гаразд, то дати кожній корутині власне з'єднання?
$workers->spawn(function () use ($queue) {
$pdo = new PDO(/* ... */); // власне з'єднання
// ...
});Годиться для десяти воркерів. Але уявіть не завдання імпорту, а сервер, де корутина створюється на кожен запит. Тисяча корутин означає тисячу TCP-з'єднань. MySQL за замовчуванням дозволяє 151, PostgreSQL 100. І відкривати з'єднання заради кількох мілісекунд роботи просто дорого: рукостискання з базою може тривати довше за сам запит.
Знайомо звучить? У розділі про канали була та сама розвилка: сто тисяч з'єднань до GeoDirectory чи черга на десять.
Пул: черга навпаки
Розв'язання називається пулом з'єднань: відкрити N з'єднань заздалегідь і роздавати їх корутинам на час їхньої роботи. Як виявляється, ми вже вміємо його будувати. Пул це канал, який тримає з'єднання:
$pool = new Channel(5);
for ($i = 0; $i < 5; $i++) {
$pool->send(new PDO(/* ... */));
}
// Усередині корутини:
$pdo = $pool->recv(); // узяти з'єднання
saveAddress($pdo, $address);
$pool->send($pdo); // повернути йогоВільні з'єднання лежать у буфері. Якщо всі вони зайняті, recv присипляє корутину, доки хтось не поверне з'єднання. Та сама синхронізація з попереднього розділу, тільки черга тримає ресурси, а не завдання.
Схема працює, але має слабке місце: треба не забути повернути з'єднання, хоч би що сталося, зокрема виняток чи скасування. А ще є транзакції, обірвані з'єднання, перепідключення. Для PDO про все це вже подбано, просто в ядрі.
PDO Pool
Пул вбудований у сам PDO і вмикається через атрибути конструктора:
$pdo = new PDO('mysql:host=localhost;dbname=profiles', 'app', 'secret', [
PDO::ATTR_POOL_ENABLED => true,
PDO::ATTR_POOL_MIN => 2,
PDO::ATTR_POOL_MAX => 10,
]);Ззовні нічого не змінилося: той найперший приклад, де єдиний $pdo іде до десяти воркерів, тепер коректний. Об'єкт $pdo більше не з'єднання, це фасад для пулу. Коли корутина виконує свій перший запит, пул дає їй виділене з'єднання, і всі запити цієї корутини йдуть через нього. Щойно корутина завершується, з'єднання повертається в пул, готове для наступної корутини.
Ніяких ручних recv і send: отримання й повернення відбуваються самі, у потрібні моменти, хоч би як усе обернулося. Код виглядає як звичайний синхронний PHP зі звичайним PDO, і саме в цьому й був увесь задум.
Транзакції
Транзакція це стан, який належить з'єднанню, тож пул ставиться до неї особливо: поки транзакція відкрита, з'єднання закріплене за своєю корутиною і не повернеться в пул:
$workers->spawn(function () use ($pdo, $queue) {
// ...
$pdo->beginTransaction();
$pdo->exec("INSERT INTO addresses (user_id, region) VALUES (...)");
$pdo->exec("UPDATE users SET address_checked = 1 WHERE id = ...");
$pdo->commit();
// тільки тепер з'єднання може повернутися в пул
});А що, як корутина завершується, не викликавши commit? Пригадайте розділ про Scope: воркера могли скасувати просто посеред транзакції, і це нормальний сценарій, а не катастрофа. Перед поверненням з'єднання пул автоматично виконує ROLLBACK. Незавершена транзакція не витече в наступну корутину й не зависне в базі даних.
Коли з'єднання обривається
Імпорт може тривати годину. За годину база даних може перезавантажитися, мережа може моргнути, а DBA може вбити сесію. У класичному PHP скрипт просто впав би, але застосунок, що працює довго, має вміти продовжувати роботу.
Пул перевіряє з'єднання, коли їх повертають: обірване знищується замість того, щоб бути переданим наступній корутині. А якщо запит зазнає невдачі через обірване з'єднання, достатньо просто повторити його на тому самому $pdo, пул дасть корутині свіже з'єднання:
try {
saveAddress($pdo, $address);
} catch (PDOException $e) {
saveAddress($pdo, $address); // пул уже підставив нове з'єднання
}Не потрібно писати логіку перепідключення, не потрібно перестворювати об'єкт PDO. Просто повторіть запит, про решту подбає пул.
Зрештою, код працює так, ніби в кожної корутини було власне з'єднання з базою даних. Насправді з'єднань лише десять, і пул постійно передає їх з рук у руки, пильнує за транзакціями та відкидає мертві. Але жодна з цих кухонних справ не видна ззовні, і в цьому головна користь: ми просто пишемо звичайний код із PDO.
До речі, наші воркери досі працюють напівсліпо: вони перевіряють адреси й зберігають їх, але ніхто не рахує, скільки адрес виявилося поганих, чи які саме. Як отримати результати назад від корутин і зручно їх зібрати? Ось цим ми й займемося в наступному розділі.