CodeGym /Courses /Module 5. Spring /Optimizing transactions in your application

Optimizing transactions in your application

Module 5. Spring
Level 6 , Lesson 7
Available

When someone tells you, "Our app's transactions are painfully slow" — that's just the tip of the iceberg. The issue can hide anywhere: from suboptimal locking to transactions that are way too long. Misusing transactions can create performance bottlenecks, turning a fast app into a slow-moving turtle.

The golden rule for optimizing transactions — keep them as short and focused as possible. The more operations you try to cram into one transaction, the longer it holds locks and the higher the chance of conflicts with other transactions. Remember KISS? In the world of transactions, it works 100%.


Best practices for managing transactions

Reducing transaction duration

The faster a transaction finishes, the less likely it is to encounter locks or become a bottleneck. We recommend:

  • Moving all non-database work (like validations or external service calls) outside the transaction.
  • Reducing the number of rows you need to update or delete inside a single transaction.

Example:


@Transactional
public void processOrder(Order order) {
    validateOrder(order); // Better to run outside the transaction
    updateOrderStatus(order); // Transaction should focus only on changing data
}

Using the right isolation level

Spring supports different transaction isolation levels via the isolation parameter of the @Transactional annotation. If you don't need the highest isolation, don't use it blindly. For most cases, READ_COMMITTED is the sweet spot.

Example:


@Transactional(isolation = Isolation.READ_COMMITTED)
public Order getOrderById(Long id) {
    return orderRepository.findById(id);
}

Picking the right transaction boundary

Try not to make the transaction global for the whole call chain. Wrap only the methods that actually change data state with a transaction.

Bad:


@Transactional
public void processOrder(Order order) {
    validateOrder(order);
    checkInventory(order);
    updateOrderStatus(order);
}

Better:


public void processOrder(Order order) {
    validateOrder(order);
    checkInventory(order);
    updateOrderStatusWithTransaction(order); // Transaction only around this part
}

@Transactional
private void updateOrderStatusWithTransaction(Order order) {
    orderRepository.updateStatus(order);
}

Unnecessary updates and locks

Minimize number of updates

Every time you change the DB, a lock may be acquired on the data row. That can cause conflicts between concurrent transactions. To avoid that:

  • Update data only when it's actually needed.
  • Check whether data changed before sending the SQL update.

Example:


@Transactional
public void updateUserProfile(User user) {
    User existingUser = userRepository.findById(user.getId());
    if (!existingUser.equals(user)) { // Did the data change?
        userRepository.save(user); // Send the request only if necessary
    }
}

Avoid long-running queries

The longer the query runs, the longer locks stick around. Use pagination or limited fetches (LIMIT) when working with large data sets.

Example:


@Transactional(readOnly = true)
public List<Order> fetchLargeOrderBatch() {
    return orderRepository.findAll(PageRequest.of(0, 50)); // Pagination in batches of 50 records
}

Tools for monitoring and profiling transactions

Using Spring Actuator

Spring Actuator gives you useful metrics for monitoring the app, including transaction info. Enable Actuator and access metrics via the /actuator/ endpoints.

Add the dependency to pom.xml:


<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Tracking slow queries

Enable slow-query logging on the DB. For Hibernate, you can use hibernate.show_sql and hibernate.format_sql to see queries in the logs.

Example application.properties:


logging.level.org.hibernate.SQL=DEBUG
spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true

Using readOnly transactions

Often you're just reading data, but the transaction still locks rows. To avoid unnecessary locks, use readOnly = true. This hints to Hibernate and the DB that no data changes are expected.

Example:


@Transactional(readOnly = true)
public List<Order> getAllOrders() {
    return orderRepository.findAll();
}

Transaction optimization cases

Imagine you're working on a payment system. A poorly configured transaction that processes 1000 transfers at once can lead to table locks or even outages. The fix? Use batch processing to split those operations into smaller chunks.

Example:


@Transactional
public void processPayments(List<Payment> payments) {
    for (Payment payment : payments) {
        paymentRepository.save(payment);
    }
}

This code isn't optimal. It's better to split into batches and reduce the number of transactions:


public void processPaymentsInBatches(List<Payment> payments) {
    List<List<Payment>> batches = splitIntoBatches(payments, 100);
    for (List<Payment> batch : batches) {
        processBatch(batch); // we wrap only the batch in a transaction
    }
}

@Transactional
private void processBatch(List<Payment> batch) {
    paymentRepository.saveAll(batch);
}

Risks and side effects

As developers we always want to squeeze more performance out of things, but remember: optimization isn't an end in itself. Careless changes to isolation levels, shortening transaction duration, or skimping on validation can lead to inconsistency. So always:

  • Test changes before and after optimization.
  • Profile with a real database.
  • Watch how transactions behave under high load.

So you've gone through the main aspects of transaction optimization. With this knowledge you'll save not only system resources but also your users' patience. And maybe your own. After all, nobody likes mysterious log messages like "Deadlock detected".

Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION