0

Queue: cơ bản là căn bản

Queue là một cấu trúc phổ biến và tồn tại trong hầu hết các loại phần mềm. Ý tưởng ban đầu khá giống việc bạn xếp hàng chờ thanh toán ở siêu thị, ai đến trước thì được xử lý trước. Nó giải quyết rất nhiều vấn đề từ nhỏ nhặt tới phức tạp trong hệ thống phần mềm.

1. Stack và queue

Hồi thực tập ở SVMC, mình cũng được học Queue ở những buổi đầu tiên. Hai cấu trúc này thường được dạy ở lớp intern vỡ lòng, và gần như ngôn ngữ nào cũng có sẵn.

Trong Java, Deque xử lý:

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Queue;

public class StackVsQueue {
    public static void main(String[] args) {
        // Stack: LIFO
        Deque<String> stack = new ArrayDeque<>();
        stack.push("write");
        stack.push("review");
        stack.push("publish");
        stack.pop(); // pop -> "publish"

        // Queue: FIFO
        Queue<String> queue = new ArrayDeque<>();
        queue.add("mail-1");    // enqueue
        queue.add("mail-2");
        queue.poll();            // dequeue -> "mail-1"
    }
}

Python sử dụng collections.deque (double-ended queue):

from collections import deque

# Stack: LIFO
stack = deque()
stack.append("write")
stack.append("review")
stack.append("publish")
stack.pop()  # pop -> "publish"

# Queue: FIFO
queue = deque()
queue.append("mail-1")  # enqueue
queue.append("mail-2")
queue.popleft()         # dequeue -> "mail-1"

Go sử dụng Slice kết hợp với slicing:

package main

import "fmt"

func main() {
    // Stack: LIFO
    var stack []string
    stack = append(stack, "write")
    stack = append(stack, "review")
    stack = append(stack, "publish")
    
    _ = stack[len(stack)-1] // "publish"
    stack = stack[:len(stack)-1]

    // Queue: FIFO
    var queue []string
    queue = append(queue, "mail-1") // enqueue
    queue = append(queue, "mail-2")
    
    _ = queue[0] // "mail-1"
    queue = queue[1:]
}

Rust sử dụng VecDeque:

use std::collections::VecDeque;

fn main() {
    // Stack: LIFO
    let mut stack: VecDeque<&str> = VecDeque::new();
    stack.push_back("write");
    stack.push_back("review");
    stack.push_back("publish");
    stack.pop_back(); // pop -> Some("publish")

    // Queue: FIFO
    let mut queue: VecDeque<&str> = VecDeque::new();
    queue.push_back("mail-1"); // enqueue
    queue.push_back("mail-2");
    queue.pop_front();         // dequeue -> Some("mail-1")
}

Nói chung là ta có thể tìm thấy Queue ở mọi nơi. Hai cấu trúc này nằm trong framework bạn dùng. Web framework: request vào một đầu, response ra một đầu. TTY của Linux: keyboard buffer là một queue. Event loop trong NodeJS hay asyncio: hàng đợi sự kiện. Bạn không nhìn thấy queue, nhưng nó chạy khắp nơi.

Một ví dụ cụ thể mình hay dùng khi giải thích là: bạn upload 1 file lớn qua HTTP. Server không đọc hết file rồi mới xử lý. Nó đọc từng chunk, đẩy chunk vào buffer (queue), consumer đọc chunk ra xử lý. Đó chính là stream trong NodeJS, generator trong Python, channel trong Go. Tất cả đều là queue ở tầng thấp. Khi bạn hiểu stack/queue, bạn đọc source code framework dễ hơn nhiều vì hầu hết các đường dẫn xử lý đều đi qua một trong hai cấu trúc này.

Python_stack_versus_queue_infogr…_202608062325.jpeg

Nhanh thật đấy, bẵng đi cũng phải 7 năm kể từ lần đầu tiếp xúc với queue. Giờ nhớ lại mới thấy đúng thật cứ cái gì càng cơ bản thì nó càng là căn bản; càng là yếu quyết võ công, phải nhớ kỹ để dùng đi dùng lại.

2. Queue và hệ thống

Ví dụ: Ngày xưa mình hay suy nghĩ theo kiểu tuần tự, vì mình học điện tử ra mà (lập trình tuần tự). Hồi đó mình làm 1 webinar landing page với flow người dùng gửi form và nhận email xác nhận, sau đó mới ghi lead vào CRM, rồi CRM xử lý các việc còn lại. Do từng bước trong flow được handle bởi service khác nhau nên cứ ông này chờ ông kia. CRM phải chờ mail provider xong mới đi tiếp. Hệ quả là khi traffic từ Ads, Social đổ về đồng thời làm mail provider chạm rate limit, dẫn tới hệ thống từ đoạn đó đổ về sau bị tắc bởi chờ bước gửi mail. Bời vì mail process có chịu nhả ra đâu mà đi tiếp.

Mặc dù web tối ưu tốt, nhưng mình (hồi đó) và phần lớn dev mình gặp chỉ chăm chút cho phần mình làm mà "mặc kệ" service bên thứ ba. Họ hay có câu là "thư viện nó thế rồi", " được thế thôi", "thế là được rồi".

Nếu như chịu suy nghĩ thêm thì điều quan trọng nhất là business phải chạy được chứ không phải là code. Có nghĩa là đoạn từ CRM -> đến marketing -> kinh doanh ... phải chạy tốt mà không bị tắc ở đoạn technical phía trước. Nếu nghĩ theo cách đó, chúng ta cùng design lại hệ thống. Bây giờ mỗi service trong hệ thống vẫn nên tách nhỏ để dễ maintain nhưng cũng vẫn nên giảm phụ thuộc lẫn nhau. Vậy bước gửi mail xác nhận trong flow không nhất thiết phải xong thì mới trigger new lead, chỉ cần user send form là đủ để tạo thành lead mới rồi. Chúng ta sẽ có 1 service đứng ở giữa các luồng, để đi thông báo cho các service khác về sự kiện "user đã gửi form rồi đấy". Đó gọi là Message Queue.

Message queue là queue nằm ngoài process, nằm ở một service riêng. Có 3 đặc tính cốt lõi:

  1. Durable: tin nhắn được lưu xuống disk. Producer gửi đi rồi restart máy vẫn không mất. Consumer xử lý xong mới đánh dấu đã xong (ack). Nếu consumer crash trước khi ack, message được đẩy lại.
  2. Async: producer gửi là xong việc, không cần đợi consumer. Request của user nhanh hơn nhiều.
  3. Fan-out: một message có thể đi tới nhiều consumer. Ví dụ sự kiện vừa phải gửi mail, vừa phải update CRM, vừa phải log analytics, tất cả cùng nhận.

3. Ba pattern phổ biến

Trong thực tế có 3 pattern tổ chức message queue mà bạn sẽ gặp:

Point-to-point: một message đi tới một consumer. Ví dụ worker xử lý đơn hàng. Có 3 đơn, 3 worker, mỗi đơn đi đúng một worker. Nếu worker đó crash, đơn được đẩy sang worker khác. Đây là pattern kinh điển nhất.

Pub/Sub: một message được broadcast tới nhiều subscriber. Ví dụ publish event user.signup, hệ thống email xác nhận, hệ thống CRM, hệ thống analytics đều nhận được. Mỗi bên xử lý độc lập.

Request-reply: producer gửi message kèm theo một correlation ID, consumer xử lý xong reply vào queue khác. Pattern này dùng khi bạn cần RPC-style nhưng muốn tách rời luồng. Ví dụ service A gọi service B qua message queue thay vì HTTP.

Mỗi pattern có use case riêng. Point-to-point cho work queue, pub/sub cho event broadcast, request-reply cho async RPC. Đừng ép cả ba vào một hệ thống, mỗi bài toán chọn pattern phù hợp.

Producer chỉ quan tâm lưu DB + publish. Consumer (một hoặc nhiều) lo phần còn lại. Mail provider chậm, request vẫn nhanh. Mail provider down, queue giữ message, khi nào provider sống lại xử lý tiếp.

Before_After_Request_Flow_Compar…_202608081645.jpeg

4. Ví dụ triển khai thực tế: Redis Streams

Có nhiều lựa chọn phổ biến: RabbitMQ, Kafka, NATS, AWS SQS, Google Pub/Sub. Nhưng để demo thì mình hay chọn Redis Streams vì:

  • Cài nhanh
  • Redis nhiều use case
  • API đơn giản
  • Hỗ trợ consumer group (nhiều consumer chia tải)

Giả sử chúng ta đang xây dựng hệ thống với kiến trúc sau, scenario: landing page POST form → publish 1 message → 3 consumer groups (email / CRM / analytics) each group reads independently.

Browser (form POST /leads)
   │
   ▼
LeadController
   │
   │ xadd "leads" stream
   ▼
┌──────────────┐
│ Redis Stream │
│   "leads"    │
└──────┬───────┘
       │ 
       │ 3 consumer groups, mỗi group đọc mọi message (fanout)
       ├──────────────────────┬──────────────────────┐
       ▼                      ▼                      ▼
     Email                   Crm                 Analytics
    (SMTP)               (HTTP POST)              (JDBC)
       │                      │                      │
       ▼                      ▼                      ▼
    MailHog              json-server              Postgres
     :1025                  :3000                  :5432

Cài đặt:

# Java 11, Maven 3.9+, Docker
git clone https://github.com/paulpham157/queue
cd queue
cd queue-1
cp .env.example .env
docker compose up --build
# 5 service: redis, mailhog (SMTP mock), mock-crm (json-server), postgres, app

image.png

docker exec -it postgres-analytics psql -U analytics -d analytics -c "select * from leads;"
  • Logs app: docker compose logs -f app → thấy "Lead queued", "Email sent", "Lead added to CRM", "Lead inserted"

Mình cũng đã thêm AGENTS.md để bạn có thể hỏi đáp ngay trong codebase. Clone repo về tại https://github.com/paulpham157/queue

Các điểm mấu chốt:

Producer (LeadController.java):

Hoạt động giống như bưu cục, nó nhận messages từ các interface như frontend rồi sắp xếp, ghi mã đánh dấu, quản lý; rồi đợi cho bưu tá đến lấy để chuyển đi.

// queue-1/src/main/java/com/example/leads/LeadController.java:49-104
@PostMapping("/leads")
public ResponseEntity<Map<String, String>> submit(
        @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
        @Valid @RequestBody LeadRequest req) {

    String leadId = UUID.randomUUID().toString();
    String idemKey = idempotencyKey != null && !idempotencyKey.isBlank()
        ? "idem:lead:" + idempotencyKey
        : null;

    try (Jedis jedis = pool.getResource()) {
        // Bưu cục nhận thư, đóng dấu "đã xử lý" lên phong bì trước khi cho vào hộp.
        // Nếu thấy dấu cũ → 409, biết khách đã gửi lần trước.
        if (idemKey != null) {
            String set = jedis.set(idemKey, leadId,
                redis.clients.jedis.params.SetParams.setParams().nx().ex(IDEMPOTENCY_TTL.toSeconds()));
            if (!"OK".equals(set)) {
                String existingId = jedis.get(idemKey);
                log.info("Duplicate lead submission rejected: key={} existingId={}", idempotencyKey, existingId);
                return ResponseEntity.status(HttpStatus.CONFLICT)
                    .body(Map.of("error", "duplicate_request", "id", existingId == null ? "" : existingId));
            }
        }

        // Sắp xếp: ghi nhãn, đánh số thư tự, niêm phong thành phong bì chuẩn.
        Map<String, String> entry = new HashMap<>();
        entry.put("event", "lead.created");
        entry.put("id", leadId);
        entry.put("name", nullToEmpty(req.getName()));
        entry.put("email", req.getEmail());
        entry.put("company", nullToEmpty(req.getCompany()));
        entry.put("message", nullToEmpty(req.getMessage()));
        entry.put("source", req.getSource() == null || req.getSource().isBlank() ? "landing" : req.getSource());

        try {
            // Cho thư vào hộp thư. Hộp ~10k phong bì, đầy thì Redis tự dọn bớt.
            // Bưu tá (3 workers) sẽ tới lấy theo ca, mỗi ca đọc batch 10.
            XAddParams params = XAddParams.xAddParams().maxLen(STREAM_MAX_LEN).approximateTrimming();
            jedis.xadd(props.getStream().getName(), entry, params);
        } catch (Exception e) {
            // Hộp thư hỏng / Redis chết / cháy kho → xé dấu "đã xử lý" đi,
            // không thì khách retry sẽ bị 409 oan 24h.
            releaseIdemKey(jedis, idemKey);
            log.error("Failed to enqueue lead: email={}", req.getEmail(), e);
            return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                .body(Map.of("error", "queue_unavailable"));
        }

        log.info("Lead queued: id={} email={}", leadId, req.getEmail());
        return ResponseEntity.ok(Map.of("id", leadId, "status", "queued"));

    } catch (Exception e) {
        // Không lấy được Jedis từ pool (pool exhausted / Redis down ngay từ đầu).
        log.error("Failed to enqueue lead: email={}", req.getEmail(), e);
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(Map.of("error", "queue_unavailable"));
    }
}

Email Consumer (EmailWorker.java):

Bưu tá gửi email: cứ mỗi phút xử lý xong 1 mail, mỗi lần xử lý thì bưu tá mang lên 1 sấp lấy tối đa 10 thư mới, gửi rồi đóng dấu "xack". Nếu gửi lỗi → giữ lại, lần sau thử lại.

@Component
public class EmailWorker {
    private static final Logger log = LoggerFactory.getLogger(EmailWorker.class);

    private final AppProperties props;
    private final JedisPool pool;
    private final AtomicBoolean running = new AtomicBoolean(true);
    private static final String GROUP = "email_svc";

    public EmailWorker(AppProperties props, JedisPool pool) {
        this.props = props;
        this.pool = pool;
    }

    @PostConstruct
    public void start() {
        Thread t = new Thread(this::run, "email-worker");
        t.setDaemon(true);
        t.start();
    }

    @PreDestroy
    public void stop() { running.set(false); }

    public void run() {
        try (Jedis jedis = pool.getResource()) {
            ensureGroup(jedis);

            // block=5s: chờ tối đa 5s nếu rỗng, đỡ spam Redis khi không có việc.
            // count=10: mỗi lần lấy batch 10, đủ cho SMTP xử lý trong 1 round.
            XReadGroupParams params = XReadGroupParams.xReadGroupParams().block(5000).count(10);

            // ">": chỉ lấy thư CHƯA ĐỌC, không replay lịch sử mỗi lần restart.
            Map<String, StreamEntryID> streams = Map.of(props.getStream().getName(), new StreamEntryID(">"));

            log.info("EmailWorker started. Waiting for leads...");
            while (running.get()) {
                List<Map.Entry<String, List<StreamEntry>>> result;
                try {
                    result = jedis.xreadGroup(GROUP, "email-worker-1", params, streams);
                } catch (Exception e) {
                    // Redis chết giữa chừng → backoff 1s, không loop đốt CPU,
                    // không crash thread (nếu crash thì app không tự restart được).
                    log.error("EmailWorker xreadGroup failed; backing off", e);
                    sleepQuiet(1000);
                    continue;
                }
                if (result == null) continue;
                for (var streamEntry : result) {
                    for (StreamEntry entry : streamEntry.getValue()) {
                        Map<String, String> data = entry.getFields();
                        if (!"lead.created".equals(data.get("event"))) {
                            // Sai nhãn → vẫn xack để Redis khỏi tracking, lần sau mình không gặp lại.
                            jedis.xack(props.getStream().getName(), GROUP, entry.getID());
                            continue;
                        }
                        try {
                            sendWelcomeEmail(data.get("name"), data.get("email"));
                            jedis.xack(props.getStream().getName(), GROUP, entry.getID());
                            log.info("Email sent to {}", data.get("email"));
                        } catch (Exception e) {
                            // CỐ TÌNH không xack: giữ entry trong pending list, lần sau retry.
                            // Nếu xack khi fail → lead đó sẽ KHÔNG BAO GIỜ được gửi welcome.
                            log.error("Failed to send email for {}", data.get("id"), e);
                        }
                    }
                }
            }
            log.info("EmailWorker stopped.");
        } catch (Exception e) {
            log.error("EmailWorker fatal — pool resource could not be acquired", e);
        }
    }

    private void ensureGroup(Jedis jedis) {
        try {
            jedis.xgroupCreate(props.getStream().getName(), GROUP, new StreamEntryID("0-0"), true);
        } catch (JedisDataException e) {
            // group exists — không phải lỗi, restart nhiều lần sẽ gặp case này.
        }
    }

    private void sendWelcomeEmail(String name, String email) throws Exception {
        Properties p = new Properties();
        p.put("mail.smtp.host", props.getSmtp().getHost());
        p.put("mail.smtp.port", String.valueOf(props.getSmtp().getPort()));
        p.put("mail.smtp.auth", "false");
        p.put("mail.smtp.starttls.enable", "false");

        Session session = Session.getInstance(p, new Authenticator() {
            protected PasswordAuthentication getPasswordAuthentication() {
                return new PasswordAuthentication("", "");
            }
        });

        MimeMessage msg = new MimeMessage(session);
        msg.setFrom(new InternetAddress(props.getSmtp().getFrom()));
        msg.setRecipients(Message.RecipientType.TO, InternetAddress.parse(email));
        msg.setSubject("Welcome to Acme!");
        msg.setText("Hi " + name + ",\n\nThanks for signing up. We'll be in touch shortly.\n\n— Acme");
        Transport.send(msg);
    }

    private static void sleepQuiet(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
    }
}

CRM Consumer (CrmWorker.java):

Cũng hoạt động tương tự Email Consumer nhưng khác một số thông tin, một số field cần thiết cho CRM.

// queue-1/src/main/java/com/example/leads/CrmWorker.java:98-105
private void addLeadToCrm(Map<String, String> data) throws Exception {
    String body = "{"
        + "\"id\":\"" + esc(data.get("id")) + "\","
        + "\"name\":\"" + esc(data.get("name")) + "\","
        + "\"email\":\"" + esc(data.get("email")) + "\","
        + "\"company\":\"" + esc(data.get("company")) + "\","
        + "\"source\":\"" + esc(data.get("source")) + "\""
        + "}";

// queue-1/src/main/java/com/example/leads/CrmWorker.java:118-120
private static String esc(String s) {
    return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
}

Analytics Consumer (AnalyticsWorker.java):

Bưu tá chuyển vào kho dữ liệu.

// queue-1/src/main/java/com/example/leads/AnalyticsWorker.java:95-111
private void insertLead(Map<String, String> data) throws Exception {
    // ON CONFLICT (id) DO NOTHING: nếu id đã tồn tại → bỏ qua, không lỗi.
    // Quan trọng vì retry từ pending list có thể push lại cùng 1 lead nhiều lần.
    // Đây là idempotency ở tầng DB — chính sách cuối cùng, dù phía trên lỡ xack nhầm.
    String sql = "INSERT INTO leads (id, name, email, company, source, message, ingested_at) "
               + "VALUES (?, ?, ?, ?, ?, ?, NOW()) "
               + "ON CONFLICT (id) DO NOTHING";

    try (Connection conn = DriverManager.getConnection(
            props.getDb().getUrl(), props.getDb().getUser(), props.getDb().getPass());
         PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setObject(1, data.getOrDefault("id", UUID.randomUUID().toString()));
        ps.setString(2, data.getOrDefault("name", ""));
        ps.setString(3, data.getOrDefault("email", ""));
        ps.setString(4, data.getOrDefault("company", ""));
        ps.setString(5, data.getOrDefault("source", ""));
        ps.setString(6, data.getOrDefault("message", ""));
        ps.executeUpdate();
    }
}

Cách triển khai trên giúp cho 3 group email_svc / crm_svc / analytics_svc đọc cùng stream độc lập. Một lead được gửi đến cả 3 worker. CRM lỗi không ảnh hưởng email và ngược lại. Mỗi group xử lý lỗi riêng.

Flowchart_data_stream_to_consumers_202608081824.jpeg

Trong production bạn sẽ cần thêm:

  • Retry policy khi external call fail (Spring Retry hoặc Resilience4j) — hiện tại chỉ log + bỏ lại trong PEL
  • Dead-letter queue cho message poison — xpending quét entries cũ, đẩy sang group dead_letters
  • Monitoring lag (PEL size) + throughput qua Micrometer + Prometheus
  • TLS + connection pool cho SMTP/HTTP/JDBC
  • Schema migration (Flyway) thay SQL init hardcoded
  • Scale horizontal: tách mỗi worker thành deployable riêng, chạy nhiều replica cùng GROUP name với CONSUMER khác nhau → competing consumers

5. Các sai lầm phổ biến thường gặp

Mình tổng hợp một số lỗi phổ biến nhất.

1: Quên ACK

Consumer nhận message nhưng quên không ack. Khi restart, message được đẩy lại, xử lý 2 lần. Khách hàng nhận 2 mail, kho bị trừ 2 lần, đặc biệt nguy hiểm là nếu bạn làm FinTech cộng/trừ tiền 2 lần.

=> Cách tránh: ack ngay sau khi xử lý xong, hoặc đặt logic trong try/finally để chắc chắn ack.

2: Không giới hạn retry

Khi một message fail, bạn retry. Retry hoài, queue đầy, lag.

=> Cách tránh: đặt max retry. Sau N lần fail thì đẩy vào dead-letter queue để xử lý tay.

3: Queue thành chỗ chứa rác

Team mới dùng queue hay có tâm lý cứ bỏ vào queue, xử lý sau. Nhưng "sau" có thể là mãi mãi.

=> Cách tránh: monitor lag (độ trễ từ lúc publish đến lúc xử lý). Lag > 1 phút: cảnh báo. Lag > 5 phút: page on-call.

4: Không xử lý idempotency

Cùng một message có thể đến consumer 2 lần (do retry, do crash, do network...). Nếu consumer không idempotent, dữ liệu bị ghi hai lần. Ví dụ consumer là "trừ tiền tài khoản", bạn sẽ trừ hai lần.

=> Cách tránh: mỗi message có một ID duy nhất, consumer check "đã xử lý ID này chưa" trước khi làm. Hoặc dùng operation idempotent ở tầng DB (INSERT ... ON CONFLICT DO NOTHING).

Four_system_pitfalls_infographic…_2K_202608081805.jpeg

6. Khi nào KHÔNG dùng message queue

Message queue không phải đũa thần. Có những trường hợp nên tránh:

  • Yêu cầu real-time: nếu user cần response ngay lập tức (payment, login), thêm queue là thêm latency. Trừ khi response trước, message gửi sau.
  • Logic phức tạp giữa các bước: nếu bước B phụ thuộc kết quả bước A, việc tách ra queue chỉ làm tăng độ phức tạp, không tăng reliability.
  • Dữ liệu quá nhỏ: queue có overhead (network, serialization, ack). Vài chục message một ngày thì DB transaction thẳng còn nhanh hơn.

Tạm kết

Quay lại bài học vỡ lòng: stack và queue là căn bản. Message queue chính là áp dụng queue ở qui mô lớn hơn, trải rộng ra giữa các process và giữa các máy. Khi một process đơn lẻ không đủ (cần durability, async, fan-out), message queue trở thành lựa chọn tự nhiên. Mỗi kiểu dữ liệu, thuật toán, kiến trúc hệ thống, triết lý thiết kế đều được sinh ra nhằm mục đích để giải quyết một việc cụ thể nào đó. Giống như chúng ta viết ra các phần mềm để giải quyết nhu cầu tự động hoá công việc. Điều quan trọng ở đây là "nó phục vụ điều gì" chứ không phải "code nó hoành tráng" ra sao.

Nếu bạn đang chạy một hệ thống mà request phải làm 5-10 việc trước khi trả response, đó là dấu hiệu rõ ràng để tách ra queue. Bắt đầu từ Redis Stream cho project nhỏ, cân nhắc Kafka/RabbitMQ khi scale.

Bạn đã gặp vấn đề nào với message queue chưa? Comment cho mình biết nhé.

Nguồn tham khảo

  1. Redis Streams documentation: https://redis.io/docs/data-types/streams/
  2. "Designing Data-Intensive Applications" - Martin Kleppmann, chương 11 (Stream Processing)
  3. AWS SQS vs SNS: https://aws.amazon.com/sqs/

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí