Xây dựng Web App Xử lý Triệu Kết nối Đồng thời với Elixir và Phoenix Framework: Từ Cài đặt đến Production

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Tại sao Hầu hết Web Stack Sụp đổ Dưới Tải Đồng thời Thực sự

Bạn hẳn đã từng gặp cảnh này: app Node.js hay Ruby on Rails chạy ổn ở staging, rồi traffic tăng đột biến và mọi thứ chậm như rùa. Thread pool đầy ắp, memory leo thang, và đội ops bắt đầu thêm instance. Câu trả lời quen thuộc là “scale ngang” — nhưng đó chỉ là che đậy vấn đề gốc rễ.

Thủ phạm thực sự là mô hình xử lý đồng thời. Hầu hết web framework dùng chung một thread pool cho tất cả request. Khi tải cao, request xếp hàng chờ thread rảnh. Vượt qua vài nghìn kết nối đồng thời, CPU tiêu tốn nhiều hơn cho context switching hơn là xử lý công việc thực sự.

Elixir tiếp cận theo hướng khác. Nó chạy trên máy ảo BEAM — runtime tương tự đằng sau Erlang, thứ đang vận hành WhatsApp, Discord và RabbitMQ. Thay vì OS thread, BEAM dùng lightweight process. Chúng ta đang nói đến hàng triệu process, mỗi cái chỉ tốn 1–2 KB bộ nhớ. Mỗi request có process riêng biệt. Không có shared mutable state cần lock, không có thread pool nào để cạn kiệt.

Phoenix, web framework xây trên Elixir, thừa hưởng tất cả điều đó. Một benchmark Phoenix nổi tiếng đã chứng minh 2 triệu kết nối WebSocket đồng thời trên một server duy nhất. Tôi đã chạy điều này trong production trên VPS 4 vCPU — trong lúc traffic tăng đột biến đến mức đủ bão hòa một setup Rails hay Express thông thường, response time hầu như không thay đổi.

Cài đặt: Thiết lập Elixir và Phoenix

Cài đặt Erlang và Elixir

Elixir cần Erlang/OTP bên dưới, nên bạn cần cả hai. Cách sạch nhất để quản lý chúng là asdf, một version manager xử lý cả hai runtime mà không xung đột.

# Cài đặt asdf
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
echo '. "$HOME/.asdf/completions/asdf.bash"' >> ~/.bashrc
source ~/.bashrc

# Thêm plugin Erlang và Elixir
asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git
asdf plugin add elixir https://github.com/asdf-vm/asdf-elixir.git

# Cài đặt các dependency (Ubuntu/Debian)
sudo apt-get install -y build-essential autoconf m4 libncurses5-dev \
  libwxwidgets-dev libgl1-mesa-dev libglu1-mesa-dev libpng-dev \
  libssh-dev unixodbc-dev xsltproc fop libxml2-utils

# Cài đặt các phiên bản cụ thể
asdf install erlang 26.2.5
asdf install elixir 1.16.3-otp-26
asdf global erlang 26.2.5
asdf global elixir 1.16.3-otp-26

Kiểm tra cài đặt:

elixir --version
# Erlang/OTP 26 [erts-14.2.5] ...
# Elixir 1.16.3 (compiled with Erlang/OTP 26)

Cài đặt Phoenix và Tạo App Đầu tiên

Phoenix dùng mix, build tool tích hợp sẵn của Elixir — hãy nghĩ nó như npm hay Cargo, nhưng dành cho Elixir.

# Cài đặt generator ứng dụng Phoenix
mix archive.install hex phx_new

# Tạo project Phoenix mới
mix phx.new my_app --database postgres
cd my_app

Lệnh này tạo ra một project Phoenix đầy đủ với PostgreSQL adapter. Bỏ qua database hoàn toàn bằng --no-ecto nếu bạn chỉ đang thử nghiệm.

Phoenix 1.7+ đi kèm binary esbuild độc lập tự tải về — không cần Node.js cho việc biên dịch asset cơ bản. Tải dependency và khởi động server:

mix deps.get
mix ecto.create   # Tạo database
mix phx.server    # Khởi động dev server

Truy cập http://localhost:4000 và bạn sẽ thấy trang chào mừng của Phoenix. Thay đổi file sẽ tự động kích hoạt live reload.

Cấu hình: Xây dựng cho Hiệu năng Đồng thời Cao

Hiểu về Mô hình Process

Đáng dừng lại ở đây trước khi chỉnh bất kỳ thông số nào. Mỗi HTTP request trong Phoenix tạo ra một Elixir process riêng. Đây không phải OS thread — BEAM scheduler quản lý chúng hoàn toàn trong userspace. Server của bạn có thể duy trì hàng trăm nghìn process đồng thời mà kernel không cần tham gia.

Cài đặt production nằm trong config/runtime.exs. File này được đánh giá lúc khởi động, không phải lúc biên dịch, nên biến môi trường hoạt động đúng cách:

import Config

config :my_app, MyAppWeb.Endpoint,
  http: [
    port: String.to_integer(System.get_env("PORT") || "4000"),
    # Tăng số acceptor cho traffic cao
    transport_options: [num_acceptors: 100]
  ],
  cache_static_manifest: "priv/static/cache_manifest.json"

Cấu hình Phoenix Channel cho Đồng thời WebSocket

Channel là nơi mô hình đồng thời của Elixir thực sự tỏa sáng. Mỗi client kết nối chạy trong process riêng — một phòng chat với 50.000 người dùng đang hoạt động tức là 50.000 process độc lập, mỗi cái có trạng thái riêng biệt. Nếu bạn muốn hiểu sâu hơn về cách xây dựng ứng dụng thời gian thực với WebSocket, đây là implementation channel tối giản:

# lib/my_app_web/channels/room_channel.ex
defmodule MyAppWeb.RoomChannel do
  use Phoenix.Channel

  def join("room:" <> room_id, _params, socket) do
    {:ok, assign(socket, :room_id, room_id)}
  end

  def handle_in("new_msg", %{"body" => body}, socket) do
    broadcast!(socket, "new_msg", %{body: body})
    {:noreply, socket}
  end
end

Kết nối nó trong socket module:

# lib/my_app_web/channels/user_socket.ex
defmodule MyAppWeb.UserSocket do
  use Phoenix.Socket

  channel "room:*", MyAppWeb.RoomChannel

  def connect(_params, socket, _connect_info), do: {:ok, socket}
  def id(_socket), do: nil
end

Connection Pooling Database với Ecto

Database trở thành điểm nghẽn từ sớm hơn nhiều so với Elixir. Ecto xử lý vấn đề này qua thư viện DBConnection, duy trì một pool các kết nối database liên tục. Tinh chỉnh pool trong config/runtime.exs:

config :my_app, MyApp.Repo,
  url: System.get_env("DATABASE_URL"),
  pool_size: String.to_integer(System.get_env("POOL_SIZE") || "20"),
  queue_target: 5000,   # ms trước khi timeout khi chờ kết nối
  queue_interval: 1000  # khoảng thời gian kiểm tra sức khỏe pool

Điểm khởi đầu hợp lý: đặt pool_size bằng số vCPU × 2–4. Trên máy 4 core, bắt đầu với 10–16 rồi tăng dần dựa trên quan sát thực tế. PostgreSQL có giới hạn kết nối cứng (mặc định 100) — đừng vượt quá con số này khi chạy nhiều app instance.

Cấu hình Production Release

Mix release đóng gói ứng dụng thành một binary tự chứa. Server đích không cần cài đặt Elixir — chỉ cần copy artifact release và chạy.

# Build release
MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release

Release được đặt trong _build/prod/rel/my_app/. Copy thư mục đó lên server và khởi động:

PORT=4000 DATABASE_URL=postgres://... SECRET_KEY_BASE=... \
  _build/prod/rel/my_app/bin/my_app start

Với systemd, tạo /etc/systemd/system/my_app.service:

[Unit]
Description=MyApp Phoenix Server
After=network.target

[Service]
User=deploy
WorkingDirectory=/opt/my_app
EnvironmentFile=/opt/my_app/.env
ExecStart=/opt/my_app/bin/my_app start
ExecStop=/opt/my_app/bin/my_app stop
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Kiểm tra và Giám sát

Observability Tích hợp với LiveDashboard

Phoenix đi kèm Phoenix.LiveDashboard — giao diện giám sát real-time hiển thị số lượng process, mức sử dụng bộ nhớ, nội dung ETS table và metrics query database. Thêm vào router:

# lib/my_app_web/router.ex
if Mix.env() == :dev do
  import Phoenix.LiveDashboard.Router

  scope "/" do
    pipe_through :browser
    live_dashboard "/dashboard", metrics: MyAppWeb.Telemetry
  end
end

Đặt sau xác thực trong production — nó để lộ trạng thái nội bộ của hệ thống mà bạn không muốn công khai.

Load Testing Hệ thống

Chạy load test trước khi đưa lên production. k6 hoạt động tốt với app nặng về WebSocket và có API scripting gọn gàng:

# Cài đặt k6 (xem k6.io/docs/get-started/installation để biết phương pháp hiện tại)
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
  --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \
  | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update && sudo apt-get install k6

# Chạy load test HTTP đơn giản
k6 run --vus 1000 --duration 30s script.js

Script k6 cơ bản để test HTTP:

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  vus: 1000,
  duration: '30s',
};

export default function () {
  http.get('http://localhost:4000/api/health');
  sleep(1);
}

Giám sát Process ở Cấp độ BEAM

Hầu hết platform yêu cầu công cụ APM bên ngoài để xem điều gì đang xảy ra bên trong ứng dụng đang chạy. BEAM cho phép bạn bỏ qua điều đó. Kết nối trực tiếp vào node production đang chạy bằng IEx:

# Kết nối vào release đang chạy
_build/prod/rel/my_app/bin/my_app remote

Từ bên trong IEx shell, truy vấn hệ thống đang live:

# Đếm tất cả process đang chạy
length(Process.list())

# Kiểm tra mức sử dụng bộ nhớ
:erlang.memory()

# Xem có bao nhiêu request đang được xử lý
:sys.get_state(MyApp.Repo)

# Kiểm tra các WebSocket channel đang kết nối
Phoenix.PubSub.list_subscriptions(MyApp.PubSub)

Không cần agent, không cần sidecar. Bản thân runtime có thể kiểm tra được — điều này giúp debug các sự cố production dễ chịu hơn đáng kể.

Những Gì Cần Theo dõi trong Production

Một vài metrics đáng theo dõi sau khi app đã live:

  • Số lượng process: phải ổn định. Số lượng cứ tăng mãi là dấu hiệu process leak — ở đâu đó bạn đang tạo process nhưng không bao giờ thoát.
  • Độ dài message queue: một process có message queue dài là điểm nghẽn. LiveDashboard hiển thị điều này trực quan, nên dễ phát hiện.
  • Database pool saturation: lỗi timeout queue_target trong log nghĩa là pool của bạn quá nhỏ hoặc query quá chậm. Kiểm tra cả hai trước khi tăng pool_size.
  • Memory mỗi node: mức sử dụng bộ nhớ BEAM nhìn chung có thể dự đoán được. Spike đột ngột thường có nghĩa là một process đang tích lũy state — thường là GenServer làm state map của nó tăng không giới hạn.

Triết lý “cứ crash đi” của BEAM mang lại lợi ích ở đây. Supervisor tree tự động khởi động lại process bị lỗi — tương tự như pattern Circuit Breaker giúp ngăn chặn lỗi lan rộng trong hệ thống phân tán. Các lỗi thoáng qua vốn sẽ làm sập một process Node.js đơn giản không lan rộng trong Elixir — supervisor khởi động lại process bị ảnh hưởng trong khi phần còn lại của ứng dụng vẫn chạy bình thường.

Nếu bạn đã từng vật lộn với bug đồng thời trong Go, Python hay Node.js, Elixir thực sự đáng để đầu tư thời gian học. Mô hình actor mất một đến hai tuần để thực sự ngấm. Sau đó, vận hành một hệ thống mà tính đồng thời là mối quan tâm hàng đầu của runtime — không phải thứ chắp vá thêm vào sau — sẽ thay đổi cách bạn tư duy về việc xây dựng server.

Share: