---
title: "Dùng chung một kết nối cho cả ứng dụng: Singleton đúng cách"
url: https://phatdev.net/vi/blog/singleton-pattern-typescript-php
lang: vi
updated: 2026-10-21
alternate:
  en: https://phatdev.net/en/blog/singleton-pattern-typescript-php
markdown: https://phatdev.net/vi/blog/singleton-pattern-typescript-php.md
---

# Dùng chung một kết nối cho cả ứng dụng: Singleton đúng cách

> Vì sao web tự treo vì quá nhiều kết nối database khi bạn lưu file lúc chạy next dev, cách dùng chung một bản bằng Singleton (TypeScript và PHP), và khi nào không nên dùng.

Chuyên mục: Backend | Đăng: 2026-10-19 | Cấp độ: Trung cấp | 7 phút đọc

Tags: singleton, design-pattern, typescript, php, nextjs, hot-reload, database-pool

Mình đang sửa web bằng `next dev`, cứ lưu file là trang tự tải lại. Làm được vài chục lần thì MySQL báo `Too many connections` và cả site đứng hình. Code không hề sai chỗ nào. Chỉ là mỗi lần lưu file, ứng dụng lại mở thêm một bộ kết nối mới mà không đóng bộ cũ.

Chuyện này có một cái tên: thiếu Singleton, tức là một thứ lẽ ra chỉ nên có một bản dùng chung cho cả ứng dụng, nhưng lại bị tạo ra nhiều bản.

![Nhiều client cùng dùng chung một instance ở giữa](https://phatdev.net/uploads/blog/singleton-pattern-typescript-php/one-instance-1920.webp)

## Singleton là gì, nói bằng lời thường

Hãy nghĩ tới quầy lễ tân của một toà nhà. Cả toà nhà dùng chung một quầy. Nếu mỗi phòng tự dựng một quầy riêng, bạn sẽ có ba chục người ngồi không, tốn lương, và mỗi người nắm một phần thông tin khác nhau.

Trong code cũng vậy. Có những thứ tạo ra thì tốn kém, hoặc chỉ nên tồn tại một bản: nhóm kết nối tới database (gọi là connection pool), bộ đọc cấu hình, bộ ghi log. Singleton nghĩa là bạn tạo đúng một bản và mọi nơi trong code lấy lại chính bản đó.

Nếu không làm vậy, bạn gặp hai kiểu rắc rối. Kiểu một là phí tài nguyên: mỗi nhóm kết nối mở 10 kết nối, 30 file tự tạo nhóm riêng thì thành 300 kết nối, và database từ chối. Kiểu hai là lệch thông tin: hai bộ ghi log giữ hai cuốn sổ khác nhau, hai bộ nhớ đệm không thấy dữ liệu của nhau.

## Khi nào nên dùng, khi nào đừng

Singleton hợp với đồ hạ tầng, tức là những thứ không chứa quy tắc kinh doanh: kết nối database, logger, client gọi API bên ngoài.

Nó không hợp với thứ thay đổi theo từng người hoặc từng lượt truy cập, như "người đang đăng nhập" hay "giỏ hàng". Hãy tưởng tượng quầy lễ tân mà lại giữ giỏ hàng của mọi khách: người này nhìn thấy đồ của người kia. Ở server chạy lâu như Node.js, lỗi kiểu này vừa là lỗi thiết kế vừa là lỗi bảo mật.

Vấn đề còn lại của Singleton là ai cũng với tay lấy được. Hàm `placeOrder()` tự gọi `Settings::get()` bên trong thì nhìn tên hàm bạn không biết nó cần cấu hình gì. Khi viết test, bản Singleton dùng chung còn lưu lại dữ liệu của test trước, nên test lúc qua lúc rớt tuỳ thứ tự chạy. Cách đỡ đau là vẫn dùng một bản dùng chung, nhưng đưa nó vào hàm qua tham số thay vì để hàm tự lấy. Phần code bên dưới có ví dụ.

## Làm thế nào

Với một dự án Next.js hoặc Node.js, mình làm theo các bước này:

1. Xác định thứ gì đắt hoặc cần duy nhất: thường là nhóm kết nối database, client Redis, logger.
2. Tạo nó ở cấp file (module). Node.js nhớ kết quả của mỗi file đã nạp, nên các nơi `import` sau sẽ nhận đúng bản đó. Bạn không cần viết lớp phức tạp nào.
3. Nếu chạy `next dev`, gắn nó vào `globalThis` (một chỗ chứa toàn cục còn sống khi file được nạp lại). Đây là bước giải quyết chuyện mở đầu bài.
4. Khi viết hàm nghiệp vụ, đưa logger hoặc kết nối vào qua tham số.
5. Tính tổng kết nối: số kết nối mỗi nhóm nhân với số tiến trình chạy song song, và so với giới hạn của database.

Bước 5 hay bị quên. Singleton chỉ là một bản cho mỗi tiến trình. Chạy PM2 với 4 tiến trình thì có 4 nhóm kết nối. Với PHP còn khác nữa: mỗi lượt truy cập bắt đầu với bộ nhớ trống, nên Singleton của PHP chỉ sống trong đúng một lượt truy cập. Nó giúp bạn khỏi tạo lại đối tượng nhiều lần trong cùng lượt đó, chứ không giữ được gì giữa hai lượt. Muốn chia sẻ giữa các lượt thì cần Redis hoặc APCu.

![Một khối sáng đặt trên giá, xung quanh là các bản sao mờ dần và vòng mũi tên làm mới](https://phatdev.net/uploads/blog/singleton-pattern-typescript-php/hot-reload-1920.webp)

## Cho bạn nào viết code

Mình chạy lại toàn bộ đoạn dưới đây trên Node 20 (qua `tsx`) và PHP 8.2.

Cách ngắn nhất trong TypeScript là chỉ xuất ra bản đã tạo, không xuất lớp:

```ts
// logger.ts
export interface Logger { log(msg: string): void; count(): number }

class MemoryLogger implements Logger {
  private lines: string[] = [];
  log(msg: string) { this.lines.push(msg); }
  count() { return this.lines.length; }
}

// Chỉ export instance, không export class: không ai tạo được bản thứ hai
export const logger: Logger = new MemoryLogger();
```

Import `logger` từ hai file khác nhau rồi so sánh bằng `===` cho ra `true`.

Còn đây là nhóm kết nối database chống nhân bản khi `next dev` nạp lại file:

```ts
// lib/db.ts
import mysql, { type Pool } from "mysql2/promise";

const globalForDb = globalThis as unknown as { __appPool?: Pool };

function createPool(): Pool {
  return mysql.createPool({
    host: process.env.DB_HOST ?? "127.0.0.1",
    user: process.env.DB_USER ?? "app",
    password: process.env.DB_PASSWORD ?? "",
    database: process.env.DB_NAME ?? "app",
    connectionLimit: 10,
  });
}

export const pool: Pool = globalForDb.__appPool ?? createPool();
if (process.env.NODE_ENV !== "production") globalForDb.__appPool = pool;
```

Mình giả lập việc nạp lại file bằng cách xoá file khỏi bộ nhớ của Node rồi nạp 3 lần. Bản không dùng `globalThis` ra 3 nhóm kết nối, bản trên ra đúng 1. Dòng `if` cuối có chủ đích: khi chạy thật (production) file chỉ nạp một lần nên không cần gắn lên chỗ toàn cục.

Khi chạy thật, nơi nào cần ghi log thì nhận `logger` qua tham số, để test chỉ cần đưa vào một bản giả:

```ts
export class OrderService {
  constructor(private readonly logger: Logger) {}
  place(id: number): void { this.logger.log(`order ${id} placed`); }
}
```

Test của mình (chạy bằng `node:test`) qua cả: hai nơi nhận cùng một instance, và `OrderService` chạy được với logger giả mà không đụng logger thật.

Phiên bản PHP, đủ ba chốt chặn:

```php
final class Settings
{
    private static ?Settings $instance = null;
    private array $values;

    private function __construct()
    {
        $this->values = ['env' => getenv('APP_ENV') ?: 'dev'];
    }

    public static function get(): self
    {
        return self::$instance ??= new self();
    }

    public function read(string $key): ?string
    {
        return $this->values[$key] ?? null;
    }

    private function __clone() {}

    public function __wakeup(): void
    {
        throw new LogicException('Không được unserialize singleton');
    }
}
```

Constructor `private` chặn `new`, `__clone` private chặn `clone`, còn `__wakeup` ném lỗi để `unserialize` không đẻ ra bản thứ hai. Trên PHP 8.2, `Settings::get() === Settings::get()` ra `true`, và `new`, `clone` đều bị chặn với thông báo lỗi rõ ràng.

## Lỗi hay gặp

Lỗi hay gặp nhất là chuyện ở đầu bài: quên `globalThis` cho thứ đắt tiền khi chạy `next dev`. Dấu hiệu là số kết nối database cứ tăng mỗi lần bạn lưu file.

Lỗi thứ hai là hiểu nhầm "một bản" thành "một bản cho cả hệ thống". Chạy hai tiến trình là có hai bản. Muốn chắc chắn một việc chỉ chạy ở đúng một nơi, ví dụ một tác vụ định kỳ, bạn cần một khoá chung kiểu Redis, không phải Singleton.

Một lỗi kín đáo hơn: khởi tạo mà phải chờ (bất đồng bộ) trong hàm lấy bản. Hai lời gọi cùng lúc có thể cùng thấy "chưa có" và cùng tạo. Cách tránh là lưu lại lời hứa (Promise) của lần khởi tạo đầu thay vì lưu kết quả.

Cuối cùng, đừng vừa xuất lớp vừa xuất bản đã tạo. Sẽ có người tự `new` thêm một bản ở góc nào đó, và bạn mất cả buổi chiều tìm.

## Nên làm gì tiếp theo

Mở file tạo kết nối database của dự án bạn và xem hai chuyện: nó có nằm trên `globalThis` khi chạy `next dev` không, và con số kết nối nhân với số tiến trình có nằm trong giới hạn của database không. Hai việc đó mất mười phút và tránh được một buổi sáng treo web.

Nếu client Redis của bạn cũng cần chung một bản, bài [Cho web nhanh hơn bằng Redis: nhớ tạm kết quả để database đỡ quá tải](https://phatdev.net/vi/blog/redis-cache-aside-api) dùng đúng mẫu này. Còn [Next.js 14 App Router: những chỗ dễ vấp khi chạy thật (Server Action, cache, deploy)](https://phatdev.net/vi/blog/nextjs-app-router-server-actions) nói thêm về vòng đời file trên server Next.js.

## Nguồn tham khảo

- [Node.js docs: Modules, caching](https://nodejs.org/api/modules.html#caching)
- [MDN: globalThis](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis)
- [PHP manual: Static keyword and class constructors](https://www.php.net/manual/en/language.oop5.decon.php)
- [node-mysql2: createPool](https://sidorares.github.io/node-mysql2/docs)

## Bài liên quan

- [Cho web nhanh hơn bằng Redis: nhớ tạm kết quả để database đỡ quá tải](https://phatdev.net/vi/blog/redis-cache-aside-api.md)
- [Next.js 14 App Router: những chỗ dễ vấp khi chạy thật (Server Action, cache, deploy)](https://phatdev.net/vi/blog/nextjs-app-router-server-actions.md)
- [Bắt đầu với Python năm 2026: cài đặt, môi trường riêng và chạy chương trình đầu tiên](https://phatdev.net/vi/blog/python-start-2026.md)
