"Handling Concurrent Writes in a Database Using Optimistic Locking with Java and JPA"

Hey everyone, Kamran here! Welcome back to the blog. Today, I want to tackle a challenge that many of us face when building applications that handle data: concurrent writes. It's one of those problems that seems simple on the surface, but can quickly become a major headache if not handled correctly. I'm going to dive into how we can use optimistic locking with Java and JPA to gracefully manage these tricky scenarios.

In my years of working on various projects, I’ve seen the chaos that can unfold when multiple users or processes try to modify the same data simultaneously. From accidental data loss to inconsistent states, concurrency issues can wreak havoc. We need strategies to prevent this. One of the most effective tools in our arsenal is optimistic locking, and that’s what I want to explore with you today.

Understanding the Concurrency Challenge

Let's start with the basics. Imagine you have a simple e-commerce application. Two customers try to buy the last available item at almost the same time. Without proper handling, both could potentially complete their purchase, leading to overselling. This is a common concurrency problem: multiple transactions trying to modify the same resource. This is not limited to e-commerce; financial transactions, stock trading, and collaborative tools are just a few examples where concurrent writes are commonplace.

The issue arises because database operations are not instantaneous. There's a period between reading the data and writing back the modified state. If another transaction modifies the same data during this gap, you can run into problems. We need a mechanism to ensure that only one of these transactions succeeds and that the other is informed that it needs to refresh data and retry, or handle the situation some other way.

Pessimistic vs. Optimistic Locking

Before diving deeper into optimistic locking, let’s briefly compare it with its counterpart: pessimistic locking. Pessimistic locking, as the name suggests, is pessimistic about concurrent access. It works by locking a resource before it’s read. Essentially, when one transaction tries to read and modify a data record, it asks the database to lock that record, thus preventing others from reading and modifying it until the first transaction completes. This is like grabbing a physical lock on a cabinet drawer before opening it.

Pessimistic locking guarantees consistency but introduces a significant downside: it can create bottlenecks, especially in applications with high concurrent access, because database resources are locked for longer periods of time and block other operations from running. Therefore, for the vast majority of modern applications, it is not suitable.

Optimistic locking, on the other hand, assumes that concurrent access is relatively rare. Instead of locking resources, it checks to make sure that the data being updated has not been modified since it was read. Think of it like having a copy of a document, modifying it, and only checking if it’s still the same before saving it. If it has changed, the save is aborted, and the user is notified so that they can re-read the document and re-apply their changes. It allows multiple transactions to read the same data simultaneously, and only a write attempt will conflict. This offers better scalability and reduced bottlenecks, which is why I generally prefer it.

Optimistic Locking in Detail: How Does it Work?

So, how exactly does optimistic locking function? The core mechanism is a version column or a timestamp in the database table. Each time the record is updated, the version or timestamp gets incremented. Here’s a breakdown:

  1. Read Phase: When a transaction reads a record, it also reads the version or timestamp associated with it.
  2. Modification Phase: The transaction modifies the record in memory.
  3. Write Phase: When the transaction attempts to persist the changes, the database checks if the version or timestamp of the record in memory matches the one in the database.
  4. Conflict Resolution: If the versions or timestamps don’t match, it means another transaction has modified the record since the current transaction read it. The update operation is rejected, and the application is informed about this conflict. The current transaction needs to reread the data, re-apply its changes, and try to update it again.

This process ensures that the last write wins, but it’s also conflict-aware, so data loss is prevented. If multiple users are trying to update different fields of the same record concurrently, they can do so as long as they don't try and update exactly the same field. If someone does try and overwrite someone else's update, they are notified so that the process can be handled correctly.

Implementing Optimistic Locking with Java and JPA

Now, let's get hands-on. We'll explore how to implement optimistic locking using Java and JPA (Java Persistence API). JPA makes this remarkably straightforward.

First, you need to add a version column (or a timestamp column) to your entity:


import javax.persistence.*;

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private int stock;

    @Version
    private int version;

    // Constructors, getters, setters...

    public Product(String name, int stock) {
        this.name = name;
        this.stock = stock;
    }

    public Product(){
        
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getStock() {
        return stock;
    }

    public void setStock(int stock) {
        this.stock = stock;
    }

    public int getVersion() {
        return version;
    }

    public void setVersion(int version) {
        this.version = version;
    }
}

Notice the @Version annotation on the 'version' field. JPA will automatically manage this field. Each time a successful update operation is performed, JPA increments the version number. You don't need to handle this manually.

Here’s a Java code snippet showing the basic process of reading, modifying, and updating a product, which will showcase the version update:


import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import javax.persistence.OptimisticLockException;


public class OptimisticLockingExample {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("myPersistenceUnit");
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();

        try{
            tx.begin();
            // Load an existing product by id, this can be hard coded to the id of your specific product in your database
            Product product = em.find(Product.class, 1L);

            if(product != null){
                //Increase the stock
                product.setStock(product.getStock() + 10);

                 // Attempt to persist the changes
                 em.merge(product);
                tx.commit();
                System.out.println("Stock updated successfully. Updated version: " + product.getVersion());
            } else {
                System.out.println("Product not found");
            }


        } catch(OptimisticLockException e){
                tx.rollback();
                System.out.println("Optimistic lock exception, transaction rolled back. This will need to be handled properly");

                //In production this would require more robust exception handling, e.g., reloading the product, showing
                //a message to the user, and requesting the action to be tried again
        } catch (Exception e){
            System.out.println("There was a non-optimistic locking related error");
            e.printStackTrace();
        } finally {
            if (tx.isActive()) {
                tx.rollback();
            }
            em.close();
            emf.close();
        }
    }
}

If a concurrent update happens before the commit phase, this code will throw an OptimisticLockException, letting you know that a conflict has occurred and that the update could not happen.

Handling OptimisticLockException

You might be thinking, okay, so I get an exception, now what? The key part of working with optimistic locking is handling the OptimisticLockException gracefully. Here are some common strategies:

  • Retry Mechanism: In many cases, you can automatically retry the operation, the underlying data has been updated, so you need to get the latest version. By reloading the entity, applying the changes again, and attempting the save again. You may want to add a limit to the number of retries, otherwise, it could loop infinitely if there are constant updates.
  • User Notification: Inform the user that the data they are trying to update has changed. Provide an option for them to reload the latest version and reapply their changes. This approach is user-friendly.
  • Merge Strategy: Sometimes you might want to merge both changes. For example, if one user updates the name of the product and another update the stock, you may want to combine both changes. This can be complex, and you need to implement this logic manually.

The strategy you choose will depend on your specific application and requirements. In my experience, a combination of retries for non-critical updates and user notifications for critical updates tends to work best.

Practical Examples and Real-World Scenarios

Let's consider a few real-world examples where optimistic locking comes in handy:

  • E-commerce Inventory: As I mentioned earlier, avoiding overselling in an e-commerce application is a perfect case. Multiple customers attempting to buy the last available item will trigger optimistic locking conflicts, preventing overbooking.
  • Collaborative Document Editing: When multiple users are editing a document, optimistic locking can ensure that only one user's changes are saved at a time, preventing conflicts or data loss.
  • Financial Transactions: In financial systems, ensuring that account balances are consistent is crucial. Optimistic locking is a useful way to handle concurrent transactions.
  • Booking Systems: For platforms like airline or hotel bookings, optimistic locking helps ensure that only one person can successfully book a limited resource.

I’ve seen firsthand the positive impact of using optimistic locking in these scenarios. Without it, the systems would have been far less reliable and prone to errors.

Best Practices and Tips

Based on my experience, here are a few best practices and tips for implementing optimistic locking:

  • Choose the Correct Column Type: Use an integer for the version column or a timestamp. JPA supports both.
  • Implement Proper Exception Handling: Always handle OptimisticLockException properly, using the retry/user notification strategies discussed earlier.
  • Avoid Long Transactions: Long-running transactions increase the chance of concurrency conflicts, even when using optimistic locking. Ensure that transactions are as short as possible.
  • Test Thoroughly: Test your code with multiple concurrent users to ensure that optimistic locking is working as expected, especially in a production environment.
  • Consider a Merge Strategy: When possible, use a sensible merge strategy, and log potential conflicts if needed, this is often the most user-friendly approach.
  • Avoid updating version field manually: Let JPA handle the version field entirely, if you manually update this, you can run into unexpected issues.

Implementing these tips can make a significant difference in your application's robustness and reliability.

Challenges and Lessons Learned

I've had my share of challenges and learning experiences with optimistic locking. One of the biggest hurdles was when I was setting up a system for concurrent processing where, during the initial testing, several concurrent operations would always result in conflicts. This was because I had a naive retry mechanism that was triggering too quickly, and the changes were not being read before the operation was being attempted again, triggering a new conflict again. I learned that I had to add a delay between retry attempts to allow enough time for the data to be updated in the system.

Also, user experience was also initially not handled well. I found out that when a user was notified of an update conflict, they would have to redo their work again and again, which was very frustrating. I improved the UX by adding a proper merge strategy for situations where multiple users might have edited different fields of the same record, and also added a notification system that would highlight what fields have changed from the last version.

These challenges taught me the importance of not only implementing optimistic locking correctly but also focusing on making a good user experience, and having a deep understanding of how your application behaves in a concurrent environment. It's not enough to just catch exceptions; you need to think about the user journey as well.

Conclusion

Optimistic locking is a robust tool for handling concurrent writes in databases, which is extremely important in today's world. It allows you to create scalable, resilient applications that can manage simultaneous actions by multiple users. As we've explored, it’s easy to implement using Java and JPA. With its versioning system and exception handling, it gives you the control you need to avoid those difficult data-related issues that we all face in our work.

I hope this post has given you a good overview of optimistic locking and how to apply it in your applications. Do you have any specific experiences with concurrent access and conflict resolution? I'd love to hear about them! Feel free to share in the comments below.

Thanks for reading, and happy coding!