Vấn Đề Khiến Tôi Mất Kiên Nhẫn
Ba năm trước, tôi đang quản lý một cụm PostgreSQL cho một sản phẩm SaaS. Chúng tôi có các cron job nằm rải rác trên hai máy chủ ứng dụng. Một máy chạy VACUUM ANALYZE mỗi đêm. Máy kia lưu trữ các bản ghi cũ hơn 90 ngày vào một bảng cold storage.
Một máy thứ ba gửi cảnh báo khi bảng vượt ngưỡng số hàng nhất định. Mọi thứ hoạt động tốt cho đến khi chúng tôi chuyển sang kiến trúc triển khai blue-green và ai đó quên cập nhật cron job trên máy chủ mới. Job VACUUM vẫn tiếp tục chạy trên máy chủ cũ đã chết. Không ai phát hiện ra suốt hai tuần, và tình trạng table bloat đã làm hiệu suất truy vấn sụt giảm thảm hại ngay trước buổi demo quan trọng với khách hàng.
Tôi đã làm việc với MySQL, PostgreSQL và MongoDB qua nhiều dự án khác nhau. Mỗi hệ thống có điểm mạnh riêng. Nhưng điều PostgreSQL cho phép bạn làm với các extension — những thứ bạn không thể có ở các hệ thống kia — là lý do tôi cứ quay lại với nó. Ngay khi khám phá ra pg_cron, cái mô hình “cron job sống trên app server” kia trở thành một thói quen xấu mà tôi đã sẵn sàng từ bỏ.
Nguyên Nhân Gốc Rễ: Tại Sao OS Cron Là Lớp Sai Cho Việc Này
Lên lịch VACUUM hay truy vấn lưu trữ từ hệ điều hành có nghĩa là bạn đang quản lý một mối quan tâm của database ở tầng hạ tầng. Sự không khớp này tạo ra những vấn đề thực sự tích lũy theo thời gian:
- Khoảng trống về khả năng quan sát: Không có bản ghi nào bên trong PostgreSQL cho biết job đã chạy, thất bại hay bị bỏ qua. Bạn phải mò mẫm qua log máy chủ hoặc đầu ra mail của cron daemon.
- Lệch lạc khi triển khai: Chuyển máy chủ, mở rộng theo chiều ngang, hay chuyển sang container — cron job của OS sẽ âm thầm bị hỏng trừ khi ai đó nhớ chuyển chúng theo.
- Thông tin xác thực phân tán: Mỗi cron script cần một chuỗi kết nối có thông tin xác thực database nằm trong shell script hoặc file môi trường trên máy chủ.
- Không có nhận thức về giao dịch: Các script ở tầng OS kích hoạt theo bộ hẹn giờ mà không biết gì về trạng thái database — tải hiện tại, lock đang hoạt động, hay liệu lần chạy trước đã hoàn thành chưa.
Chuyển trách nhiệm này vào trong database sẽ giải quyết cả bốn vấn đề cùng lúc.
So Sánh Các Lựa Chọn
Lựa chọn 1: OS-Level Cron
Hoạt động tốt cho các thiết lập đơn giản một máy chủ. Bắt đầu có vấn đề khi bạn mở rộng, di chuyển hay containerize. PostgreSQL hoàn toàn không thấy được liệu có gì đã chạy không, và không có cách rõ ràng nào để theo dõi lịch sử job bên trong database.
Lựa chọn 2: Application Scheduler (Celery Beat, APScheduler, v.v.)
Tốt hơn OS cron — các job được quản lý trong code và được versioning cùng với ứng dụng. Nhưng bạn vẫn đang chạy bảo trì database từ bên ngoài database engine, và bạn đã thêm một component khác cần phải luôn chạy, được triển khai và được giám sát riêng.
Lựa chọn 3: pg_cron
Một extension PostgreSQL chạy các SQL job được lên lịch bên trong tiến trình database dưới dạng background worker. Các job được lưu trong một bảng, lịch sử chạy có thể truy vấn được, và không có phụ thuộc bên ngoài nào. Đối với các tác vụ đặc thù của database, đây là lớp đúng đắn.
Cài Đặt và Cấu Hình pg_cron
Trên Ubuntu/Debian với PostgreSQL 16:
sudo apt install postgresql-16-cron
Trên RHEL/Rocky/AlmaLinux:
sudo dnf install pg_cron_16
Hãy cho PostgreSQL biết cần load extension này khi khởi động bằng cách chỉnh sửa postgresql.conf:
# /etc/postgresql/16/main/postgresql.conf
shared_preload_libraries = 'pg_cron'
cron.database_name = 'tên_database_của_bạn'
Khởi động lại PostgreSQL, sau đó tạo extension trong database mục tiêu của bạn:
sudo systemctl restart postgresql
CREATE EXTENSION pg_cron;
Một background worker sẽ khởi động và schema cron trở nên khả dụng. Cài đặt hoàn tất.
Các Trường Hợp Sử Dụng Thực Tế
1. Tự Động Hóa VACUUM Trên Các Bảng Ghi Nhiều
Autovacuum của PostgreSQL xử lý hầu hết các bảng khá tốt. Các bảng ghi nhiều với bulk delete hoặc cập nhật hàng loạt lại là câu chuyện khác — chúng có thể tạo ra dead tuple nhanh hơn khả năng dọn dẹp của autovacuum với throttling dựa trên chi phí. Với những bảng đó, một lịch VACUUM tại thời điểm off-peak dự đoán được sẽ đáng tin cậy hơn.
-- Chạy VACUUM ANALYZE trên bảng orders mỗi ngày lúc 2:00 sáng
SELECT cron.schedule(
'vacuum-orders',
'0 2 * * *',
'VACUUM ANALYZE orders;'
);
Cú pháp cron là POSIX chuẩn: phút, giờ, ngày-trong-tháng, tháng, ngày-trong-tuần. Sau khi đăng ký job, kiểm tra nó và lịch sử chạy ngay từ SQL:
-- Liệt kê tất cả các job đã lên lịch
SELECT jobid, jobname, schedule, command FROM cron.job;
-- Kiểm tra lịch sử chạy gần đây
SELECT jobid, status, return_message, start_time, end_time
FROM cron.job_run_details
ORDER BY start_time DESC
LIMIT 10;
cron.job_run_details là thứ làm pg_cron thực sự hữu ích trong vận hành. Job có thành công không? Mất bao lâu? Có âm thầm báo lỗi vào thứ Ba tuần trước lúc 2 giờ sáng không? Tất cả đều là một câu truy vấn SQL. Không cần đào bới file log.
2. Lưu Trữ Dữ Liệu Cũ Theo Lịch
Giả sử bạn có bảng events và mọi bản ghi cũ hơn 90 ngày cần được chuyển sang events_archive. Bọc logic đó vào một hàm PL/pgSQL, rồi lên lịch cho nó:
CREATE OR REPLACE FUNCTION archive_old_events() RETURNS void AS $$
BEGIN
INSERT INTO events_archive
SELECT * FROM events
WHERE created_at < NOW() - INTERVAL '90 days';
DELETE FROM events
WHERE created_at < NOW() - INTERVAL '90 days';
RAISE NOTICE 'Đã lưu trữ các sự kiện cũ hơn 90 ngày lúc %', NOW();
END;
$$ LANGUAGE plpgsql;
-- Chạy mỗi Chủ nhật lúc 3:00 sáng
SELECT cron.schedule(
'archive-old-events',
'0 3 * * 0',
'SELECT archive_old_events();'
);
Đây là điều mà shell script không thể làm được: nếu INSERT thành công nhưng DELETE thất bại, không có gì được commit. Không có bảng lưu trữ nửa vời, không có bản ghi mồ côi phải tìm kiếm. Hàm chạy trong hệ thống giao dịch của PostgreSQL, vì vậy nó hoặc thành công hoàn toàn hoặc rollback hoàn toàn.
3. Gửi Thông Báo Nội Bộ Với pg_notify
PostgreSQL có hệ thống pub/sub tích hợp thông qua NOTIFY và LISTEN. Lên lịch một job kiểm tra điều kiện và gửi thông báo channel đến bất kỳ tiến trình ứng dụng nào đang lắng nghe — không cần message queue bên ngoài:
CREATE OR REPLACE FUNCTION check_pending_order_alert() RETURNS void AS $$
DECLARE
row_count bigint;
BEGIN
SELECT COUNT(*) INTO row_count
FROM orders
WHERE status = 'pending';
IF row_count > 10000 THEN
PERFORM pg_notify(
'alerts',
json_build_object(
'type', 'high_pending_orders',
'count', row_count,
'timestamp', NOW()
)::text
);
END IF;
END;
$$ LANGUAGE plpgsql;
-- Kiểm tra mỗi 15 phút
SELECT cron.schedule(
'check-pending-orders',
'*/15 * * * *',
'SELECT check_pending_order_alert();'
);
Ứng dụng của bạn đăng ký bằng LISTEN alerts; trên một kết nối liên tục và phản ứng khi thông báo đến. Hữu ích cho các cảnh báo ngưỡng cần được điều khiển bởi database mà không cần kéo Kafka hay Redis vào stack chỉ cho một trường hợp sử dụng này.
Quản Lý Job Hàng Ngày
-- Liệt kê tất cả job cùng lịch của chúng
SELECT jobid, jobname, schedule, active FROM cron.job;
-- Tắt một job mà không xóa nó
UPDATE cron.job SET active = false WHERE jobname = 'archive-old-events';
-- Xóa vĩnh viễn một job
SELECT cron.unschedule('archive-old-events');
-- Hoặc theo job ID
SELECT cron.unschedule(3);
Những Gì Thực Sự Hoạt Động Tốt Trên Production
Chạy pg_cron trên nhiều deployment PostgreSQL theo thời gian sẽ bộc lộ một số mẫu phân biệt các job âm thầm hoạt động tốt với các job âm thầm bị hỏng:
- Bọc mọi thứ trong hàm, không dùng chuỗi SQL thô. Lên lịch
SELECT my_function();thay vì SQL inline giúp job có thể kiểm thử bên ngoài scheduler, được versioning trong migration, và dễ debug hơn nhiều khi có sự cố lúc 3 giờ sáng. - Cảnh báo khi
cron.job_run_detailscó lỗi. Thêm một truy vấn giám sát đánh dấu các job thất bại hoặc chạy lâu bất thường. Xử lý lỗi pg_cron giống như xử lý lỗi ứng dụng — bằng cảnh báo, không phải kiểm tra log thủ công sau một tuần. - Đừng chống lại autovacuum, hãy bổ sung cho nó. Job VACUUM của pg_cron hoạt động tốt nhất cho các bảng có mẫu ghi hàng loạt đã biết vào những thời điểm dự đoán được. Để autovacuum xử lý bảo trì thông thường ở mọi nơi khác.
- Viết các hàm idempotent. Các hàm lưu trữ và dọn dẹp nên cho kết quả như nhau dù chạy một lần hay hai lần. Các job có thể chồng chéo nhau hoặc được thử lại khi khởi động lại.
- Sử dụng
cron.database_namemột cách cẩn thận. pg_cron chạy job trong database được chỉ định trongpostgresql.conf. Cần job trên nhiều database? Dùngcron.schedule_in_database(), được giới thiệu trong pg_cron 1.4.
Sau khi chuyển khỏi OS cron, điều nổi bật nhất rất đơn giản: việc bảo trì database cuối cùng đã nằm bên trong database. Không phải trong shell script trên một máy chủ mà ai đó có thể quên cập nhật. Không phải trong checklist triển khai bị bỏ sót trong quá trình migration. Lịch sử job là một câu truy vấn SQL, các job đi theo database, và khi có sự cố, bạn biết trong vài giây — không phải sau một giờ đào bới log.

