Pułapka Singleton: globalny stan, workery PHP-FPM i wzorzec, który źle się zestarzał
Byłem na dokładnie dwóch code review, gdzie ktoś zaproponował Singleton i miał rację. Byłem może na czterdziestu, gdzie nie miał. Wzorzec nie jest problemem. Problem polega na tym, że "chcę mieć tylko jeden egzemplarz tego obiektu" brzmi jak właściwa motywacja prawie zawsze, i prawie nigdy nią nie jest.
To jest retrospekcja produkcyjna. Kod jest prawdziwy. Incydenty się wydarzyły.
Kłamstwo PHP-FPM
Najgroźniejsze nieporozumienie dotyczące Singletona w PHP polega na przekonaniu, że daje ci jedną instancję na całą aplikację. Nie daje. Daje jedną instancję na każdy worker process. Na standardowej puli PHP-FPM z 32 workerami masz 32 Singletony.
To nie jest edge case. To jest domyślne zachowanie. Każda aplikacja PHP pod jakimkolwiek sensownym ruchem działa właśnie tak.
// Myślisz, że masz:
// Aplikacja → Singleton → jedna instancja
//
// W rzeczywistości masz:
// Request #1 → Worker #1 → Singleton::$instance (obiekt A)
// Request #2 → Worker #7 → Singleton::$instance (obiekt B)
// Request #3 → Worker #7 → Singleton::$instance (obiekt B) ← ten sam co #2
// Request #4 → Worker #1 → Singleton::$instance (obiekt A) ← ten sam co #1
class RateLimiter
{
private static ?self $instance = null;
private array $buckets = []; // mutable state: liczba requestów per IP per minuta
public static function getInstance(): static
{
if (static::$instance === null) {
static::$instance = new static();
}
return static::$instance;
}
public function check(string $ip, int $limit): bool
{
$key = $ip . ':' . floor(time() / 60);
$this->buckets[$key] = ($this->buckets[$key] ?? 0) + 1;
return $this->buckets[$key] <= $limit;
}
}
Ten rate limiter działał w produkcji przez trzy tygodnie, zanim zauważyliśmy, że nasz limit 100 requestów na minutę był egzekwowany jako mniej więcej 100 / 32 = 3 requesty na minutę per worker, albo wcale, gdy requesty trafiały na różne workery. Bufor w pamięci nigdy nie był współdzielony. Każdy worker zbierał własne liczniki i limity były bez znaczenia.
Naprawa nie polegała na "lepszym Singletonie". Naprawa to był Redis. Wzorzec był od początku nieodpowiedni do wymagania. Wymaganie brzmiało "jeden limit per IP w całej aplikacji", a to jest jawnie rozproszone ograniczenie. Żaden wzorzec in-process tego nie spełni.
Co ten wzorzec faktycznie gwarantuje
Sprowadzony do esencji, Singleton gwarantuje jedną instancję per process, per klasa, per kontekst ładowania klas. Nic ponadto. W PHP-FPM to jedna per worker, w skrypcie CLI jedna per uruchomienie, w suite testów działającej w jednym procesie jedna przez wszystkie testy, co jest zazwyczaj katastrofą, która wychodzi na jaw godziny później gdy ktoś uruchomi suite w innej kolejności.
Mechanizm getInstance() to w zasadzie leniwy konstruktor z globalnym dostępem. Wartościową gwarancją jest leniwa inicjalizacja. Niebezpieczną częścią jest globalny dostęp. Te dwie odpowiedzialności są ze sobą sprzęgnięte w tym wzorcu, i przez większość czasu chcesz jednej bez drugiej.
Kiedy jest faktycznie poprawny
Istnieją trzy scenariusze, w których widziałem Singleton użyty poprawnie w produkcyjnych systemach PHP.
Niemutowalna konfiguracja aplikacji ładowana raz ze środowiska to najczystszy przypadek:
final class AppConfig
{
private static ?self $instance = null;
private function __construct(
public readonly string $appEnv,
public readonly string $dbDsn,
public readonly int $dbPoolSize,
public readonly string $redisUrl,
) {}
public static function load(): static
{
if (static::$instance !== null) {
return static::$instance;
}
return static::$instance = new static(
appEnv: (string) ($_ENV['APP_ENV'] ?? 'production'),
dbDsn: (string) ($_ENV['DATABASE_URL'] ?? throw new \RuntimeException('DATABASE_URL not set')),
dbPoolSize: (int) ($_ENV['DB_POOL_SIZE'] ?? 10),
redisUrl: (string) ($_ENV['REDIS_URL'] ?? throw new \RuntimeException('REDIS_URL not set')),
);
}
}
Właściwości readonly sprawiają, że jest to bezpieczne: nie ma mutable state, który mógłby się psuć między requestami. Walidacja w konstruktorze oznacza, że błędna konfiguracja wybucha natychmiast przy starcie zamiast cicho w runtime.
Sam kontener IoC to drugi zasadny przypadek. Każdy nowoczesny framework PHP (Laravel, Symfony, Laminas) ma kontener IoC, który jest efektywnie Singletonem. Kontener jest tworzony raz, wypełniany bindingami i rozwiązywany przez cały cykl życia requestu. Jest to poprawne, ponieważ kontener jest bezstanowy między rozwiązaniami: trzyma definicje, nie instancje każdej zbindowanej klasy.
// Klasa Application Laravela rozszerza Container i jest bootstrapowana raz.
// $app jest przekazywany wszędzie, ale jest to ten sam obiekt.
// Co czyni to akceptowalnym: kontener trzyma domknięcia, nie rozwiązane obiekty.
// Rozwiązanie następuje na żądanie, a rozwiązane instancje mają własne reguły lifetime.
$app->bind(PaymentGatewayInterface::class, StripeGateway::class);
// ↑ przechowuje definicję bindingu — brak efektów ubocznych
$gateway = $app->make(PaymentGatewayInterface::class);
// ↑ rozwiązuje na żądanie — domyślnie świeża instancja
Trzeci przypadek to menadżery puli połączeń, ale wyłącznie menadżer, nie same połączenia. Menadżer puli, który śledzi dostępne połączenia, jest zasadnie Singletonem per process: powinien być dokładnie jeden per worker i żyć przez cały czas życia workera. Same połączenia muszą być pobierane i zwracane.
Katastrofa z testami
Powód, dla którego "Singleton to antywzorzec" stał się powszechną mądrością, wynika prawie wyłącznie z testów. Statyczny stan utrzymuje się między przypadkami testowymi w jednym procesie. Każdy test dotykający Singletona przecieka stan do następnego.
class UserRepositoryTest extends TestCase
{
public function testFindByEmail(): void
{
// Database::getInstance() otwiera prawdziwe połączenie w __construct.
// Jeśli poprzedni test zostawił otwartą transakcję, ten test
// będzie czekał w deadlocku na zwolnienie locka, który nigdy nie nastąpi.
$repo = new UserRepository(Database::getInstance());
$user = $repo->findByEmail('[email protected]');
$this->assertNotNull($user);
}
}
Standardowe obejście (Database::resetInstance() w tearDown()) jest gorsze niż choroba. Tesujesz teraz zachowanie resetu, a zapomnienie jednego teardownu psuje resztę suite w sposób, który jest żmudny do zdiagnozowania. Spędziłem więcej niż jeden piątkowy wieczór szukając właśnie tego.
Strukturalna naprawa to dependency injection z interfejsami:
interface DatabaseConnectionInterface
{
public function query(string $sql, array $params = []): array;
public function execute(string $sql, array $params = []): int;
public function beginTransaction(): void;
public function commit(): void;
public function rollback(): void;
}
class UserRepository
{
public function __construct(
private readonly DatabaseConnectionInterface $db
) {}
public function findByEmail(string $email): ?User
{
$rows = $this->db->query(
'SELECT * FROM users WHERE email = ? LIMIT 1',
[$email]
);
return $rows ? User::fromRow($rows[0]) : null;
}
}
Teraz test wstrzykuje mocka. Prawdziwe połączenie jest podłączone przez kontener. Test nigdy nie dotyka globalnego stanu.
class UserRepositoryTest extends TestCase
{
public function testFindByEmail(): void
{
$db = $this->createMock(DatabaseConnectionInterface::class);
$db->method('query')
->with('SELECT * FROM users WHERE email = ? LIMIT 1', ['[email protected]'])
->willReturn([['id' => 1, 'email' => '[email protected]', 'name' => 'Alice']]);
$repo = new UserRepository($db);
$user = $repo->findByEmail('[email protected]');
$this->assertSame('[email protected]', $user->email);
}
}
Zero statycznego stanu. Zero teardownów. Zero skażenia między testami. Test jest szybszy, deterministyczny i testuje dokładnie jedną rzecz.
Kontener DI robi to, czego chciałeś od Singletona
Developerzy sięgają po Singleton z jednego z trzech powodów: chcą leniwej inicjalizacji i nie chcą płacić za obiekt, dopóki nie jest potrzebny, chcą współdzielonego stanu żeby wszyscy czytali tę samą konfigurację i rozmawiali z tą samą pulą, albo chcą wygody i nie chcą przekazywać obiektu przez pół aplikacji. Kontener DI ze scope'ami lifetime rozwiązuje wszystkie trzy bez problemu globalnego stanu:
// Singleton-scoped: instancjonowany raz przez cały czas życia kontenera
$container->singleton(AppConfig::class, fn() => AppConfig::load());
// Transient: świeża instancja przy każdym rozwiązaniu
$container->bind(PaymentGatewayInterface::class, StripeGateway::class);
// Request-scoped (w procesie długożyciowym jak Swoole/RoadRunner):
// jedna instancja per request, zwalniana po zakończeniu requestu
$container->scoped(UserSession::class, fn($c) => new UserSession(
$c->make(RequestInterface::class)
));
Kontener to jedyny zasadny singleton w skali całej aplikacji. Wszystko inne dostaje swój lifetime zarządzany przez kontener. To nie jest semantyczna sztuczka, zmienia to operacyjne właściwości systemu. Możesz podmieniać implementacje w testach, resetować request-scoped stan między requestami w procesach długożyciowych i inspekcjonować graf zależności bez czytania każdej klasy.
Wzorzec, którego faktycznie używam
Dla obiektów, które naprawdę potrzebują istnienia jeden-per-process, używam helpera once() zamiast klasy Singleton:
function once(string $key, callable $factory): mixed
{
static $store = [];
return $store[$key] ??= $factory();
}
// Użycie: leniwa inicjalizacja, współdzielona w procesie, brak class pollution
$config = once(AppConfig::class, fn() => AppConfig::load());
Sześćdziesiąt znaków. Przechodzi code review. Nie rozsywa private static $instance po całej bazie kodu. W teście możesz wyczyścić cały statyczny store jedną funkcją reset zamiast szukać metod resetInstance() w dwudziestu klasach.
Na co zwracam uwagę w code review
Singleton to żółta flaga, nie czerwona. Moje pierwsze pytanie brzmi zawsze: jaki jest czas życia mutable state, który ten obiekt trzyma?
Jeśli odpowiedź brzmi "trzyma tylko wartości konfiguracyjne ustawione w czasie konstrukcji, nigdy potem niemodyfikowane" (wzorzec jest do obrony. Jeśli odpowiedź brzmi "akumuluje stan między requestami) liczniki, cache'e, bufory", to jest pułapka PHP-FPM. Naprawa to albo Redis/Memcached dla stanu, który musi przeżyć poza pojedynczym workerem, albo request-scoped lifetime zarządzany przez kontener dla stanu, który powinien się resetować per request.
Wzorzec nie jest zły. Globalna mutable state jest zła. Singleton sprawia, że globalną mutable state łatwo wprowadzić i trudno zauważyć, aż do incydentu w piątek po południu.