Начало работы

Базовые подключения

Приложению почти всегда нужно соединение — с базой, с Redis. Здесь показано, как его открыть и куда положить, чтобы оно жило столько, сколько нужно: разово в скрипте и по одному на запрос в приложении. Этого достаточно для большинства проектов.

База PDO / CDOКеш RedisКлючевое область видимости

Что здесь решается

Открыть соединение — одна строка. Настоящий вопрос в другом: кто держит этот объект и как долго он живёт. От ответа зависит и скорость, и корректность.

Соединение — это один сокет с последовательным протоколом: запрос → ответ → следующий запрос. Пока оно занято одним делом, второе через него не пройдёт. А воркер Winter под Swoole обслуживает много запросов одновременно, каждый в своей корутине. Отсюда одно правило, из которого следует всё остальное на этой странице:

Одно соединение — одна единица работы. Не давайте двум запросам один и тот же сокет.

Разовое соединение

Самый простой случай: скрипт, миграция, разовая задача по cron, тест. Соединение нужно один раз, и держать его негде — берите inline-конфигурацию и получайте соединение сразу.

scripts/report.php
<?php
require 'vendor/autoload.php';

use Flytachi\Winter\Cdo\Config\Call\PgDbCall;

$cdo = (new PgDbCall(
  host:     env('DB_HOST', 'localhost'),
  port:     (int) env('DB_PORT', 5432),
  database: env('DB_NAME'),
  username: env('DB_USER'),
  password: env('DB_PASS'),
))->connection();

$id = $cdo->insert('reports', ['title' => 'Ноябрь', 'ready' => false]);

Есть варианты под каждый драйвер — PgDbCall, MySqlDbCall, SqliteDbCall и DbCall для всего остального. SQLite не требует ни сервера, ни учётных данных, что удобно в тестах:

php
use Flytachi\Winter\Cdo\Config\Call\SqliteDbCall;

$cdo = (new SqliteDbCall())->connection();            // в памяти
$cdo = (new SqliteDbCall(path: 'storage/app.sqlite'))->connection();

Только для разовой работы

Каждый такой вызов открывает новое соединение и закрывает его, только когда объект соберёт сборщик мусора. В приложении, обслуживающем запросы, так делать нельзя — соединение надо объявить зависимостью, см. ниже.

Соединение на запрос

В приложении соединение объявляют бином: контейнер создаст его, когда понадобится, и отдаст тем, кто его попросил. Область видимости — Scope::Request, то есть своё соединение на каждый запрос.

PDO

main/Configurations/DbConfiguration.php
<?php

namespace Main\Configurations;

use Flytachi\Winter\Kernel\App\Attribute\{Bean, Configuration, Value};
use Flytachi\Winter\Kernel\App\Scope;

#[Configuration]
final class DbConfiguration
{
  #[Bean(scope: Scope::Request)]
  public function pdo(
      #[Value('DB_HOST')] string $host,
      #[Value('DB_NAME')] string $name,
      #[Value('DB_USER')] string $user,
      #[Value('DB_PASS')] string $pass,
  ): \PDO {
      return new \PDO("pgsql:host={$host};dbname={$name}", $user, $pass, [
          \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
      ]);
  }
}

CDO

То же самое, но соединение отдаёт CDO — надстройка над PDO с insert(), update(), delete(), апсертами и построителем условий Qb. CDO наследует PDO, поэтому prepare()/query() тоже на месте.

main/Configurations/DbConfiguration.php
<?php

namespace Main\Configurations;

use Flytachi\Winter\Cdo\Config\Call\PgDbCall;
use Flytachi\Winter\Cdo\Connection\CDO;
use Flytachi\Winter\Kernel\App\Attribute\{Bean, Configuration, Value};
use Flytachi\Winter\Kernel\App\Scope;

#[Configuration]
final class DbConfiguration
{
  #[Bean(scope: Scope::Request)]
  public function db(
      #[Value('DB_HOST')] string $host,
      #[Value('DB_NAME')] string $name,
      #[Value('DB_USER')] string $user,
      #[Value('DB_PASS')] string $pass,
  ): CDO {
      return (new PgDbCall(
          host:     $host,
          database: $name,
          username: $user,
          password: $pass,
      ))->connection();
  }
}

Redis

main/Configurations/RedisConfiguration.php
<?php

namespace Main\Configurations;

use Flytachi\Winter\Kernel\App\Attribute\{Bean, Configuration, Value};
use Flytachi\Winter\Kernel\App\Scope;

#[Configuration]
final class RedisConfiguration
{
  #[Bean(scope: Scope::Request, name: 'redisStore')]
  public function store(
      #[Value('REDIS_HOST', 'localhost')] string $host,
      #[Value('REDIS_PORT', 6379)] int $port,
      #[Value('REDIS_PASS', '')] string $password,
      #[Value('REDIS_DB', 0)] int $database,
  ): \Redis {
      $redis = new \Redis();
      $redis->connect($host, $port, 9);

      if ($password !== '') {
          $redis->auth($password);
      }
      $redis->select($database);

      return $redis;
  }
}

Имя бина здесь задано явно (name: 'redisStore'), потому что соединений с Redis может быть несколько — например, отдельная база под кеш и под очередь. По типу \Redis их было бы не различить.

Как этим пользоваться

Дальше соединение внедряется как любая другая зависимость:

php
use Flytachi\Winter\Cdo\Connection\CDO;
use Flytachi\Winter\Cdo\Qb;
use Flytachi\Winter\DI\Attribute\{Autowired, Inject};

class OrderService
{
  #[Autowired]
  private CDO $db;                       // по типу

  #[Inject('redisStore')]
  private \Redis $cache;                 // по имени бина

  public function markPaid(int $id): void
  {
      $this->db->update('orders', ['status' => 'paid'], Qb::eq('id', $id));
      $this->cache->del("order:{$id}");
  }
}

Почему Request, а не Singleton

Это главное место страницы: единственная настройка, которую здесь легко сделать неправильно.

Синглтон-соединение ломается под нагрузкой

#[Bean] по умолчанию синглтон: метод выполнится один раз, и объект будет жить, пока живёт воркер. Для соединения это ошибка, которая проявляется не сразу.

Воркер обслуживает запросы корутинами одновременно. Один сокет, отданный всем корутинам сразу, означает, что две из них пишут в него вперемежку: ответ на чужой запрос, «packets out of order», а под Swoole — падение воркера целиком.

Scope::Request даёт по соединению на запрос — это уже корректно.

Из того же правила следует ещё одно ограничение: соединение нельзя класть в поле #[Singleton]-сервиса. Свойства синглтона заполняются один раз, при его сборке, — значит он навсегда запомнит соединение первого запроса и раздаст его всем последующим. Ядро это проверяет на старте и откажется подниматься, назвав проблемную цепочку.

Под FPM правила мягче — но не пишите под FPM

Там процесс обслуживает один запрос, конкуренции нет, и синглтон-соединение было бы корректно. Но приложение, написанное так, ломается при переезде на Swoole — тихо и под нагрузкой. Scope::Request верен в обоих рантаймах, поэтому по умолчанию всегда он.

Чего такому подключению не хватает

Scope::Request корректен, и для многих приложений на этом можно остановиться. Но у него есть цена, и когда она становится заметной — пора смотреть в сторону пула.

Соединение открывается заново на каждом запросе. TCP-рукопожатие, TLS, аутентификация, а у PostgreSQL ещё и форк процесса на стороне сервера.

Их количество ничем не ограничено. Тысяча одновременных запросов — тысяча попыток подключиться. База ответит too many connections, и упадёт не всплеск, а всё приложение.

Сбой базы не лечится сам. Резидентный воркер живёт неделями и может держать сокет, который сервер уже закрыл. Соединение на запрос эту проблему смягчает, но не снимает: сокет может умереть и в середине запроса.

Дальше

Обе продвинутые дороги — про пул: соединения переиспользуются, их число ограничено сверху, а мёртвые заменяются.

  • PPA — работа с базой: пул соединений, репозитории, сущности и миграции.
  • Redis — пакет winter-redis: пул соединений, сторы с префиксом, хеши, списки и стримы.
  • Внедрение зависимостей — области видимости подробно, #[Configuration], #[Bean] и #[Value].