Databases 13 min read

How I Fixed Persistent Account Balance Mismatches with Pessimistic Locks and Hibernate

After repeatedly encountering account balance discrepancies in a financial system, I traced the issue to improper use of pessimistic locks and Hibernate caching, then resolved it by correcting lock placement, optimizing SQL queries, and switching to native SQL, sharing the detailed debugging steps and lessons learned.

Senior Brother's Insights
Senior Brother's Insights
Senior Brother's Insights
How I Fixed Persistent Account Balance Mismatches with Pessimistic Locks and Hibernate

When taking over a new financial project, repeated account balance mismatches appeared. The root cause was hotspot accounts: multiple services or threads updating the same account record concurrently, causing one update to overwrite another.

Typical Solutions

Single‑service thread lock

Cluster distributed lock

Cluster database pessimistic lock

The project used a database‑level pessimistic lock, so the investigation focused on its correct usage.

What Is a Pessimistic Lock?

A pessimistic lock assumes data will be modified and locks the rows for the duration of a transaction, usually via SELECT ... FOR UPDATE. This works only with InnoDB and must be executed inside a transaction block.

set autocommit=0;
-- start transaction
BEGIN;
-- lock the row
SELECT status FROM t_goods WHERE id=1 FOR UPDATE;
-- create order
INSERT INTO t_orders (id, goods_id) VALUES (NULL, 1);
-- update goods status
UPDATE t_goods SET status=2 WHERE id=1;
-- commit
COMMIT;

Because autocommit is disabled, the transaction is explicitly managed with BEGIN and COMMIT. The FOR UPDATE statement locks the selected row so other transactions must wait.

Initial Bug: Lock After Calculation

Although most code locked the row before calculating the new balance, one place performed the balance calculation outside the lock and only locked the row before the final update. This allowed another thread to overwrite the balance, reproducing the mismatch.

Recurring Issues After a Month

Even after fixing the lock placement, occasional mismatches resurfaced. Investigation revealed two major difficulties.

Difficulty 1: Massive Data Without Indexes

The account table contained tens of millions of rows without an index on the query columns, making full‑table scans impossible. Two SQL‑optimization techniques were applied:

Limit the result set with LIMIT Use efficient pagination by filtering on the primary key

Original inefficient query:

SELECT id, name, age FROM user LIMIT 1000000,20;

Optimized version:

SELECT id, name, age FROM user WHERE id > 1000000 LIMIT 20;

Further improvement using BETWEEN:

SELECT id, name, age FROM user WHERE id BETWEEN 1000000 AND 1000020;

Difficulty 2: Excessive Log Volume

Detailed application logs generated several gigabytes per day. To locate relevant entries, grep was used: grep 123 info.log After narrowing down the timestamp, the matching lines were saved to a temporary file for offline analysis:

grep '2021-11-17 19:23:23' info.log > temp.log

Deeper Bug: Hibernate Session Caching

Even with the corrected lock order, the issue persisted in production. The suspicion turned to Hibernate’s first‑level cache. The deprecated Session.get(..., LockMode) method returns the cached instance if it already exists, bypassing the database lock.

Deprecated Method <code>@Deprecated Object get(Class clazz, Serializable id, LockMode lockMode)</code> LockMode parameter should be replaced with LockOptions

Because the entity was already present in the session, the subsequent lock call operated on a stale instance. Adding session.clear() before loading forced a fresh database fetch.

public T findAndLock(Class cls, String primaryKey) throws DataAccessException {
    Session session = getHibernateTemplate().getSessionFactory().getCurrentSession();
    session.clear();
    Object object = session.load(cls, primaryKey, LockOptions.UPGRADE);
    return (T) object;
}

After inserting the clear operation, the unit test reproduced the race condition, confirming the root cause.

Final Resolution

The production fix replaced the Hibernate‑based query with a native SQL SELECT ... FOR UPDATE that retrieves only the primary key, checks its existence, and then proceeds with the business logic. This eliminates the caching side‑effect and guarantees the lock is held during balance calculation.

Conclusion

The case demonstrates that even a seemingly simple pessimistic lock can involve many layers: transaction isolation, Hibernate caching, Spring transaction management, multithreading, Linux tooling, SQL optimization, and thorough log analysis. Understanding each layer helped locate and eradicate the elusive bug.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

concurrencymysqlSQL Optimizationpessimistic-lockHibernate
Senior Brother's Insights
Written by

Senior Brother's Insights

A public account focused on workplace, career growth, team management, and self-improvement. The author is the writer of books including 'SpringBoot Technology Insider' and 'Drools 8 Rule Engine: Core Technology and Practice'.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.