المعاملات (Transactions) من Java
في هذا الدرس 11 موضوعات
هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.
Lesson Overview
When a member borrows a book, two things must happen: a loan row is recorded and the book's copies go down by one. If only the first happens, the library thinks a copy is on the shelf that is actually in a member's bag. The two changes must succeed or fail together.
You met transactions in psql in BEG-051 (BEGIN, COMMIT, ROLLBACK). This lesson does the same from Java. In the phone-call picture, a transaction is one call in which you place several requests and say "confirm everything" at the end, or "cancel all of that" if anything went wrong.
You need the Db helper, PreparedStatement from lesson 3, the loans table (book_id, member_id, loaned_on), and the CHECK (copies >= 0) constraint on books.
Learning Objectives
By the end of this lesson you will be able to:
- Explain auto-commit and why it can leave data half-changed.
- Group statements with
setAutoCommit(false),commit(), androllback(). - Write the standard try, catch-and-rollback, rethrow pattern.
- Restore auto-commit afterwards.
- Show that other connections cannot see uncommitted changes.
- Recognise the "transaction is aborted" error in PostgreSQL.
Written Explanation
Auto-commit: every statement on its own
A new JDBC Connection starts in auto-commit mode: each statement is committed as soon as it finishes. That is convenient for one statement, and dangerous for two that belong together. If statement 2 fails, statement 1 is already permanent.
Here is the naive borrow method. It has no transaction:
public void borrowNaive(Connection conn, int bookId, int memberId) throws SQLException {
run(conn, INSERT_LOAN, bookId, memberId); // INSERT INTO loans ...
run(conn, DECREASE_COPIES, bookId, -1); // UPDATE books SET copies = copies - 1 ...
}where run is a small helper that prepares, binds, and calls executeUpdate() (the full class is in the examples). Borrow a book whose copies is already 0 and the CHECK constraint rejects the update. By then the loan row is committed.
The transaction pattern
conn.setAutoCommit(false);
try {
// statement 1
// statement 2
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
}Step by step:
setAutoCommit(false)starts a manual transaction: nothing is permanent until you say so.commit()makes all changes since the last commit permanent, together.rollback()undoes all changes since the last commit.- The
catchrolls back and rethrows, so the caller still learns the operation failed. Rolling back and then swallowing the exception would hide the failure. - The
finallyrestores auto-commit. The Java tutorial advises keeping auto-commit off only for the duration of the transaction, because a long-open transaction can hold up other users. If the connection came from somewhere else and might be used again, restore the previous setting (getAutoCommit()first), as the full example does.
The tutorial also points out that when an SQLException occurs partway through, you cannot tell from the exception which statements went through, so calling rollback() is the reliable way to leave nothing partial behind.
Other connections do not see your uncommitted work
PostgreSQL's documentation says an open transaction's updates are invisible to other transactions until it completes, and then they appear all at once. That is the "all-or-nothing" promise as seen from outside. Example 3 shows it with two Connection objects.
A failed statement poisons the transaction
In PostgreSQL, once a statement fails inside a transaction, the transaction is aborted: every later statement is refused until you roll back. In our run the next SELECT 1 got ERROR: current transaction is aborted, commands ignored until end of transaction block. So never "ignore the error and carry on" inside a transaction: roll back, then decide what to do.
Where the transaction boundary lives
A Connection holds one transaction at a time. That is why the methods in lesson 3 took the connection as a parameter: the caller (here, LoanService.borrow) owns the connection and the transaction, and the repository methods just run statements on it. The next demo lessons build on that split.
Examples
Example 1: the full service, naive and safe (simple)
public class LoanService {
private static final String INSERT_LOAN =
"INSERT INTO loans (book_id, member_id) VALUES (?, ?)";
private static final String DECREASE_COPIES =
"UPDATE books SET copies = copies - 1 WHERE id = ?";
public void borrowNaive(Connection conn, int bookId, int memberId) throws SQLException {
run(conn, INSERT_LOAN, bookId, memberId);
run(conn, DECREASE_COPIES, bookId, -1);
}
public void borrow(Connection conn, int bookId, int memberId) throws SQLException {
boolean oldAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
try {
run(conn, INSERT_LOAN, bookId, memberId);
run(conn, DECREASE_COPIES, bookId, -1);
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(oldAutoCommit);
}
}
private void run(Connection conn, String sql, int first, int second) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, first);
if (second != -1) {
ps.setInt(2, second);
}
ps.executeUpdate();
}
}
}The -1 "no second value" marker is a shortcut to keep the example short; real code would use two small methods instead.
Example 2: the same failure, two outcomes (practical)
Book 3 has 0 copies, so the update must fail. We print the loan count and the copies after each attempt:
start: loans=0, copies of book 3=0
naive failed: ERROR: new row for relation "books" violates check constraint "books_copies_check"
after naive: loans=1, copies of book 3=0
tx failed: ERROR: new row for relation "books" violates check constraint "books_copies_check"
after tx: loans=1, copies of book 3=0
tx on book 2: loans=2, copies of book 3=0After the naive call, loans=1: a ghost loan for a copy that does not exist. After the transactional call the count stayed at 1: the second loan row was rolled back with the failed update. On book 2, which has a copy, both statements committed and loans went to 2.
Example 3: who can see what (realistic)
Two connections, c and other. On c we turn auto-commit off and add 10 copies, without committing:
inside tx (same connection): loans=2, copies of book 3=10
inside tx (other connection): loans=2, copies of book 3=0
after commit (other): loans=2, copies of book 3=10The writer sees its own change at once; everyone else sees the old value until commit(). We then forced an error and ran another statement in the same transaction:
error: ERROR: new row for relation "books" violates check constraint "books_copies_check"
next statement: ERROR: current transaction is aborted, commands ignored until end of transaction block
after rollback: loans=2, copies of book 3=10rollback() cleared the aborted state and left the committed data untouched.
Backend Insight
Business operations are almost always several database changes: place an order and reduce stock, move money between accounts, create a user and their settings. Wrapping each such use case in one transaction is the core of data integrity in a backend. Later, Spring will start and end transactions with an annotation, but underneath it calls exactly setAutoCommit(false), commit(), and rollback(). When something odd happens in production (half-saved data, "transaction aborted" errors), you will now know where to look.
Two practical rules: keep transactions short (open, do the statements, commit, no waiting for user input in between), and keep them narrow, wrapping only the statements that must be together.
Common Mistakes
Mistake 1: Forgetting commit()
With auto-commit off, closing the connection without commit() throws your work away (the exact behaviour on close can vary by driver, so always commit explicitly). If changes seem to vanish, check for a missing commit.
Mistake 2: Catching the exception without rolling back
catch (SQLException e) { e.printStackTrace(); } // no rollback, no rethrowThe transaction stays open, the data is half-done, and the caller thinks it worked. Roll back, then rethrow.
Mistake 3: Leaving auto-commit off
If the connection is reused, later code runs inside an unfinished transaction. Restore the setting in finally.
Mistake 4: Opening a new connection for the second statement
A transaction belongs to one connection. Statements on a different Connection are in a different transaction, and a rollback on the first will not undo them. Pass the same connection to every statement.
Mistake 5: Continuing after an error inside the transaction
In PostgreSQL the transaction is aborted after a failed statement; later statements fail with current transaction is aborted. Roll back first.
Exercises
Level A: Foundation
- What does auto-commit mean, and what is its default?
- What do
commit()androllback()each do? - Why does the
catchblock rethrow the exception after rolling back?
Level B: Application
- Add
LoanServiceto your project and reproduce Example 2 with a book that has 0 copies. Record the loan count after the naive and the transactional attempt. - Write
returnBook(Connection conn, int loanId)that deletes the loan row and increases that book'scopiesby one in one transaction. (Hint: firstSELECT book_id FROM loans WHERE id = ?before deleting.) Test it and a missing loan id. - Prove isolation yourself: with one connection in a transaction that has updated a row, read the row from a second connection before and after
commit().
Level C: Challenge
- Make
borrowcheck availability first (SELECT copies ... WHERE id = ?) and throwIllegalStateException("No copies left")whencopiesis 0, inside the transaction. Verify that the rollback happens and that no ghost loan remains. Explain why this is still not fully safe if two members borrow the last copy at the same moment (describe the problem; you do not need to solve it). - A teammate calls
borrowinside a loop for five books and wants "all five or none". What changes in the design, and why does the currentborrownot support it?
أظهر الحلول
A1. Each statement is committed immediately when it finishes. It is the default for a new JDBC connection.
A2. commit() makes all changes since the last commit permanent together; rollback() discards them all.
A3. The caller must learn that the operation failed. Rolling back only cleans up the data; swallowing the exception would hide the failure.
B1. Expected: loans increases by 1 after the naive attempt (ghost loan) and stays unchanged after the transactional attempt.
B2.
// setAutoCommit(false); try {
// SELECT book_id FROM loans WHERE id = ? -> if no row, throw an exception
// DELETE FROM loans WHERE id = ?
// UPDATE books SET copies = copies + 1 WHERE id = ?
// commit(); } catch (SQLException e) { rollback(); throw e; } finally { restore autoCommit }For a missing loan id the select finds no row, you throw before changing anything, and the catch/rollback path leaves the data as it was. (Remember that a custom exception that is not an SQLException needs its own catch or a catch (SQLException | RuntimeException e) so that the rollback still runs.)
B3. Before commit() the second connection reads the old value; after commit() it reads the new one (our output: copies 0, then 10).
C1. The check-then-update sequence has a gap: two connections can both read copies = 1 and both continue; one of them then fails or both succeed in a way you did not intend, depending on timing and isolation level. The fix involves locking the row (for example SELECT ... FOR UPDATE) or a single conditional UPDATE ... WHERE copies > 0 that checks the row count. Those techniques come in later modules; here, just describe the race.
C2. The transaction must span all five borrows: open it once outside the loop and call a method that only runs statements (not one that commits itself). The current borrow begins and commits its own transaction each time, so a failure on the fourth book leaves the first three committed.
Mini Challenge
Write LoanDemo with a main that, in one transaction, borrows two different books for the same member, then deliberately fails on a third book that has 0 copies. After the failure, print the loans count and prove with a query that neither of the first two loans was kept. Then change the code so the two successful books are kept and only the third is rejected, and describe in two sentences how the transaction boundaries moved.
Check your work: you restore auto-commit in finally, your catch rolls back and rethrows, and the second version uses separate transactions.
Summary & Next Lesson
Key takeaways
- JDBC connections start in auto-commit: every statement is permanent at once.
setAutoCommit(false)starts a transaction;commit()saves it;rollback()undoes it.- The pattern: try, commit; catch, roll back and rethrow; finally, restore auto-commit.
- A transaction lives on one connection; pass that connection to all its statements.
- Other connections see nothing until commit, and in PostgreSQL a failed statement aborts the whole transaction until you roll back.
Next lesson
The next lesson, "Demo: building the BookRepository with JDBC", puts lessons 2 to 5 together into a repository class with save, findById, findByTitle, and delete.
Self-check
- What is the default commit mode of a JDBC connection?
- What does
rollback()undo? - Why rethrow after rolling back?
- Why must all statements of a transaction use the same
Connection? - What happens to statements after one fails inside a PostgreSQL transaction?
أظهر الإجابات
- Auto-commit: each statement commits on its own.
- All changes since the last commit.
- So the caller knows the operation failed.
- A transaction belongs to one connection; another connection is a separate transaction.
- They are refused (
current transaction is aborted) until you roll back.
References
- Oracle, Using Transactions (JDBC Tutorial). It supports auto-commit as the default,
setAutoCommit(false),commit,rollbackin acatch, and restoring auto-commit infinally. - PostgreSQL, Transactions (tutorial). It supports all-or-nothing behaviour and that an open transaction's updates are invisible to others until it completes. It also mentions the aborted-by-error state; the exact "current transaction is aborted" text and all outputs above come from real runs on PostgreSQL 18 with driver 42.7.8.
- I did not verify, from a source, what every driver does with uncommitted work when a connection closes, so the lesson says to commit explicitly. The race-condition remark in Challenge 1 is general database knowledge, not checked against a page here.
التطبيق
طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.
تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.