Lập trình với Postgres đúng kiểu phiêu lưu luôn ấy: đôi khi nó thành game “Tìm lỗi của bạn”. Ở phần này tụi mình sẽ bàn về các lỗi điển hình và bẫy khi làm việc với nested transaction. Vào luôn nhé!
Dùng sai lệnh transaction trong function và procedure
Lỗi: thử dùng COMMIT, ROLLBACK hoặc SAVEPOINT bên trong FUNCTION.
Tại sao: Trong PostgreSQL, function (CREATE FUNCTION ... LANGUAGE plpgsql) luôn chạy trong một transaction bên ngoài, và mọi lệnh transaction bên trong function đều bị cấm. Thử dùng sẽ dính lỗi cú pháp liền.
Ví dụ lỗi:
CREATE OR REPLACE FUNCTION f_bad() RETURNS void AS $$
BEGIN
SAVEPOINT sp1; -- Lỗi: lệnh transaction bị cấm
END;
$$ LANGUAGE plpgsql;
Làm đúng nè:
Với các thao tác cần tính nguyên tử “hoặc tất cả hoặc không gì cả”, cứ dùng function mà không cần lệnh transaction rõ ràng. Nếu muốn chia nhỏ commit từng phần — dùng procedure nha.
Lỗi: thử dùng ROLLBACK TO SAVEPOINT trong procedure viết bằng PL/pgSQL.
Tại sao: PostgreSQL 17 chỉ cho phép COMMIT, ROLLBACK, SAVEPOINT, RELEASE SAVEPOINT bên trong procedure (CREATE PROCEDURE ... LANGUAGE plpgsql). Nhưng ROLLBACK TO SAVEPOINT trong PL/pgSQL là không chơi được! Thử là dính lỗi cú pháp ngay.
Ví dụ lỗi:
CREATE PROCEDURE p_bad()
LANGUAGE plpgsql
AS $$
BEGIN
SAVEPOINT sp1;
-- ...
ROLLBACK TO SAVEPOINT sp1; -- Lỗi! Không dùng được
END;
$$;
Làm đúng nè:
Muốn “rollback một phần” thì dùng block BEGIN ... EXCEPTION ... END — nó tự tạo savepoint; nếu có lỗi trong block thì mọi thay đổi sẽ rollback về đầu block luôn.
CREATE PROCEDURE p_good()
LANGUAGE plpgsql
AS $$
BEGIN
BEGIN
-- thao tác có thể gây lỗi
...
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'Rollback bên trong block BEGIN ... EXCEPTION ... END';
END;
END;
$$;
Gọi procedure lồng nhau: giới hạn và lỗi thường gặp
Lỗi: gọi procedure có COMMIT/ROLLBACK rõ ràng khi đang có transaction mở ở phía client.
Tại sao: procedure có transaction control chỉ chạy ngon trong chế độ autocommit (mỗi procedure là một transaction), nếu không thì khi procedure dùng COMMIT hoặc ROLLBACK sẽ lỗi: transaction đã mở ở client rồi.
Ví dụ:
# Trong Python với psycopg2 mặc định autocommit=False
cur.execute("BEGIN;")
cur.execute("CALL my_proc();") -- Lỗi khi COMMIT trong my_proc
Làm đúng nè:
- Trước khi gọi procedure, bật chế độ autocommit cho connection.
- Đừng gọi procedure qua function hoặc SELECT.
Lỗi: gọi procedure có transaction control (COMMIT, ROLLBACK) sẽ không chạy nếu gọi KHÔNG bằng lệnh CALL (ví dụ qua SELECT).
Tại sao: Chỉ gọi bằng CALL (hoặc trong DO block ẩn danh) mới điều khiển được transaction. Gọi từ function là không được nha.
Vấn đề lock và deadlock
Lock giống như khách không mời mà tới: đầu tiên phiền, sau đó gây loạn. Deadlock là khi các transaction chờ nhau mãi không xong. Ví dụ điển hình nè:
- Transaction A lock một dòng trong bảng
ordersrồi thử update dòng trong bảngproducts. - Transaction B lock một dòng trong bảng
productsrồi thử update dòng trong bảngorders.
Kết quả là không transaction nào chạy tiếp được. Kiểu như hai xe cùng chui vào khúc cua hẹp, cuối cùng tắc đường luôn.
Ví dụ:
-- Transaction A
BEGIN;
UPDATE orders SET status = 'Processing' WHERE id = 1;
-- Transaction B
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 10;
-- Giờ transaction A thử update dòng trong `products`,
-- còn transaction B thử update dòng trong `orders`.
-- Deadlock!
Làm sao né?
- Luôn update dữ liệu theo cùng thứ tự. Ví dụ, update
orderstrước, rồi tớiproducts. - Tránh transaction kéo dài quá lâu.
- Dùng
LOCKhợp lý, chọn mức lock thấp nhất có thể.
Dùng dynamic SQL (EXECUTE) sai cách
Dynamic SQL mà dùng ẩu là đau đầu liền. Lỗi phổ biến nhất là SQL injection. Ví dụ:
EXECUTE 'SELECT * FROM orders WHERE id = ' || user_input;
Nếu user_input là 1; DROP TABLE orders; thì xác định bay luôn bảng orders.
Làm sao né? Dùng prepared statement như này nè:
EXECUTE 'SELECT * FROM orders WHERE id = $1' USING user_input;
Cách này bảo vệ app khỏi SQL injection luôn.
Rollback transaction sau khi xử lý lỗi không chuẩn
Nếu không xử lý lỗi đàng hoàng, transaction có thể bị kẹt ở trạng thái lỗi. Ví dụ:
BEGIN;
INSERT INTO orders (order_id, status) VALUES (1, 'Pending');
BEGIN;
-- Một thao tác nào đó gây lỗi
INSERT INTO non_existing_table VALUES (1);
-- Lỗi, nhưng transaction chưa kết thúc
COMMIT; -- Lỗi: transaction hiện tại đã bị lỗi
Lỗi phát là code đứng luôn.
Làm sao né? Dùng block EXCEPTION để rollback cho chuẩn:
BEGIN
INSERT INTO orders (order_id, status) VALUES (1, 'Pending');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
RAISE NOTICE 'Có lỗi xảy ra, transaction sẽ rollback.';
END;
Làm sao né lỗi: tips và khuyến nghị
- Khi viết procedure phức tạp, luôn bắt đầu bằng pseudocode. Liệt kê hết các bước và điểm có thể lỗi.
- Dùng
SAVEPOINTđể rollback từng phần. Nhớ release sau khi dùng nha. - Tránh transaction kéo dài — càng lâu càng dễ bị lock.
- Khi gọi procedure lồng nhau, đảm bảo context transaction ngoài và trong đồng bộ.
- Luôn kiểm tra hiệu năng procedure bằng
EXPLAIN ANALYZE. - Log lỗi vào bảng hoặc file text — debug sẽ dễ hơn nhiều.
Ví dụ lỗi và cách sửa
Ví dụ 1: Lỗi khi gọi procedure lồng nhau
Code lỗi:
BEGIN;
CALL process_order(5);
-- Trong process_order có ROLLBACK
-- Toàn bộ transaction bị vô hiệu hóa
COMMIT; -- Lỗi
Code sửa:
BEGIN;
SAVEPOINT sp_outer;
CALL process_order(5);
-- Rollback chỉ khi có lỗi
ROLLBACK TO SAVEPOINT sp_outer;
COMMIT;
Ví dụ 2: Vấn đề Deadlock
Code lỗi:
-- Transaction A
BEGIN;
UPDATE orders SET status = 'Processing' WHERE id = 1;
-- Đang chờ `products`
-- Transaction B
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 10;
-- Đang chờ `orders`
Cách sửa:
-- Cả hai query chạy cùng thứ tự:
-- Update `products` trước, rồi tới `orders`.
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 10;
UPDATE orders SET status = 'Processing' WHERE id = 1;
COMMIT;
Những lỗi này cho thấy làm việc với transaction cần cẩn thận và có kinh nghiệm. Nhưng mà, càng thực hành nhiều thì càng ít bị ROLLBACK ngoài đời (và trong sự nghiệp) nha!
GO TO FULL VERSION