DEV Community

Vivek
Vivek

Posted on

Using a Read-through / Write-through Cache in Java Applications with NCache

(Disclaimer: Views expressed in this article are solely my own and do not reflect those of my current or former employers.)

Introduction

In the ever-evolving landscape of web development, performance remains a cornerstone of success. For seasoned software engineers, optimizing the speed and efficiency of Java web applications is not merely a goal but a necessity in today's competitive digital sphere. Among the myriad techniques employed to enhance performance, caching stands out as a fundamental strategy. In this article, we delve into the importance of caching in Java web applications, exploring its implementation strategies, and the tangible benefits it brings to the table.

Understanding Caching

Caching is a technique employed to temporarily store data in a location for swift retrieval when required. In the context of web applications, caching involves storing frequently accessed data in a cache, to reduce the time it takes to retrieve the data from its original source. In web applications, it plays a pivotal role in enhancing performance and scalability.

Several key reasons underscore the importance of caching in web applications. Some of them are:

1. Faster Response Times: By caching frequently accessed data, web applications can swiftly retrieve and deliver this data to users, thereby facilitating faster response times and augmenting user experience.
2. Improved Availability: Caching can improve the availability of web applications by reducing the dependency on external services or resources. Cached data remains available even if the original source becomes temporarily unavailable, ensuring continuous access to critical information.
3. Reduced Server Load: Caching alleviates the burden on web servers by directly serving cached data to users.

There are two major types of caching.

Client-side Caching
Client-side caching involves storing data in the client's browser or device cache. This can include caching web pages, images, scripts, and other resources locally on the client side. Common mechanisms for client-side caching include browser caching and HTML5 local storage.

Server-side Caching
Server-side caching involves storing data in a cache that is managed by the web server or an intermediary server, such as a reverse proxy or caching server. This can include caching database query results, API responses, and dynamically generated content.

Common mechanisms for server-side caching include in-memory caching systems like NCache, Redis, Memcached, and HTTP caching using HTTP headers.

NCache Overview

NCache is an open-source distributed, in-memory caching solution that provides high-performance caching for Java and .NET applications. NCache offers a feature-rich caching platform designed to enhance the performance, scalability, and reliability of applications by caching frequently accessed data in memory.

Key features of NCache include:

1. Distributed Caching: NCache provides distributed caching capabilities which enables horizontal scalability and fault tolerance by distributing data across multiple nodes.

2. In-Memory Data Storage: NCache stores data in-memory, which significantly reduces data access latency compared to traditional disk-based storage solutions, making it ideal for caching frequently accessed data.

3. High Availability: NCache adopts peer-to-peer architecture which ensures high availability by replicating data across multiple nodes. Additionally, it offers diverse caching topologies like Mirrored Topology, Replicated Topology, and Partition-Replica Topology to cater to varied application requirements.

NCache offers several features and benefits specifically tailored for Java web applications.It seamlessly integrates with prevalent Java frameworks and technologies, encompassing, but not restricted to, the Spring Framework and Hibernate. This enables engineers to easily incorporate caching into their Java web applications without extensive modifications to existing code.

Caching Strategies

Caching strategies play a crucial role in optimizing the performance and scalability of web applications. Different caching patterns and techniques can be applied based on the requirements of the application. Let's briefly explore some common caching strategies:

Cache-Aside
The Cache-Aside pattern, also known as Lazy Loading, is a popular caching pattern where the application is responsible for managing the cache. In this pattern, the application first checks the cache for the requested data. If the data is found in the cache (cache hit), it is returned to the user. If the data is not found in the cache (cache miss), the application fetches the data from the original source (database), stores it in the cache, and then returns it to the client.

Read-Through
In this strategy, the cache automatically retrieves data from the underlying data source when a cache miss occurs. In other words, when a requested item is not found in the cache, the cache fetches the data from the data source, caches it, and then returns it to the client. This approach is well-suited for scenarios with a high volume of read operations and instances where data undergoes infrequent updates.

Write-Through
In this approach, write operations first update the cache and then propagate to the underlying database. When an application performs a write operation, it first updates the cache with the data. The cache then ensures that the data is synchronized with the database by propagating the write operation to the database. This ensures that the data remains consistent between the cache and the database.

Write-Behind
In Write-Behind, write operations are performed on the cache and then asynchronously propagated to the underlying database. When an application executes a write operation, it updates the cache with the new data. The cache acknowledges the write immediately, allowing the application to proceed without waiting for the data to be persisted to the database. The cache then asynchronously flushes the updated data to the database in the background. This approach can improve write performance and reduce latency as the application does not have to wait for database writes to complete. However, it introduces the risk of potential data loss if the system fails before the changes are persisted to the database.

Write-Around
In this method, data is promptly written to the storage system, usually a database, bypassing initial caching. Only data that is subsequently read is cached. This approach is commonly paired with read-through or cache-aside strategies.

Refresh-Ahead
In this pattern, the caching system proactively refreshes the data in the cache before it expires. The caching system periodically refreshes the data in the cache to ensure that it remains up-to-date.

Techniques for Implementing Caching with NCache

Read-Through
NCache offers robust functionalities for integrating caching into Java web applications. Let's delve into implementing Read-Through caching using NCache in Java. Initially, we need to configure a Read-Through provider, which retrieves data from the data source whenever required. To accomplish this, we must implement the ReadThruProvider interface in Java to specify how data is retrieved from the external source.

The loadFromSource and loadDataStructureFromSource methods encompass the logic for retrieving an object or data structure, respectively, from the specified data source in the event of a cache miss. Additionally, the getConnectionString method is responsible for providing the connection string tailored to the configured data source.

Below is an example of implementing the ReadThruProvider interface in Java:

import com.alachisoft.ncache.runtime.datasourceprovider.DistributedDataStructureType;
import com.alachisoft.ncache.runtime.datasourceprovider.ProviderCacheItem;
import com.alachisoft.ncache.runtime.datasourceprovider.ProviderDataStructureItem;
import com.alachisoft.ncache.runtime.datasourceprovider.ReadThruProvider;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;

public class SampleReadThruProvider implements ReadThruProvider
{
    private Connection connection;

    // Perform tasks like allocating resources or acquiring connections
    public void init(Map<String, String> parameters, String cacheName) throws Exception {
        try {
            String server = parameters.get("server");
            String userId = parameters.get("username");
            String password = parameters.get("password");
            String database = parameters.get("database");
            String connectionString = getConnectionString(server, database, userId, password);
            try {
                connection = DriverManager.getConnection(connectionString);
        } catch (Exception ex){
            // Handle connection initialization failure
        }
    }

    // Responsible for loading an item from the external data source
    @Override
    public ProviderCacheItem loadFromSource(String key) throws Exception {
        try {
            ProviderCacheItem cacheItem = new ProviderCacheItem(loadEmployee(key));
            return cacheItem;
        }catch (Exception ex)
        {
            return null;
        }
    }

    // Responsible for loading bulk of items from the external data source
    @Override
    public Map<String, ProviderCacheItem> loadFromSource(Collection<String> collection) throws Exception {
        try {
            Map<String, ProviderCacheItem> providerItems = new HashMap<>();
            for (String key : collection) {
                Object data = loadEmployee(key);
                ProviderCacheItem cacheItem = new ProviderCacheItem(data);
                cacheItem.setGroup("employees");
                providerItems.put(key, cacheItem);
            }
            return providerItems;
        }catch (Exception ex)
        {
            //Handle exception
            return null;
        }
    }


// Adds ProviderDataStructureItem with distributed data structure type such as List, Set, Counter, etc.,
    @Override
    public ProviderDataStructureItem loadDataStructureFromSource(String key,  DistributedDataStructureType distributedDataStructureType) throws Exception {
        // Implement the logic if it's necessary
    }

    @Override
    public void close() throws Exception {
        if (connection != null) {
            try {
                connection.close();
            } catch (SQLException ex) {
                // Handle connection closing failure
            }
        }
    }

    // This method runs the SQL query to fetch employee data from the data source
    private Object loadEmployee(String employeeKey) throws Exception {
        String selectQuery = "Select * from Employees WHERE EmployeeID = ?";
        PreparedStatement statement = connection.prepareStatement(selectQuery);
        statement.setString(1, employeeKey);
        ResultSet resultSet = (ResultSet) statement.executeQuery(selectQuery);
        if(resultSet.next()){
            Employee employee = fetchEmployee(resultSet);
            resultSet.close();
            return employee;
        }
        return null;
    }

    private Employee fetchEmployee(ResultSet rs) throws SQLException {
        Employee employee = new Employee();
        employee.setId(rs.getInt(1));
        employee.setName(rs.getString(2));
        employee.setEmailId(rs.getInt(3));
        return employee;
    }

    private String getConnectionString(String server, String database, String userName, String password) {
        // Construct and return the connection string
        // Example: "jdbc:mysql://server:port/database?user=username&password=password"
        return "jdbc:mysql://" + server + ":3306/" + database + "?user=" + userName + "&password=" + password;
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's now explore how to retrieve employee data on the client side. NCache facilitates this process through the ReadThruOptions class, allowing for the specification of Read-Through options via the API. Within the ReadThruOptions class lies the ReadMode enum, offering two modes: ReadThru and ReadThruForced.

In the ReadThru mode, the system verifies the presence of an item in the cache, and if absent, retrieves the data from the designated data source. Conversely, the ReadThruForced mode mandates data retrieval from the data source, regardless of its presence in the cache.

import com.alachisoft.ncache.client.Cache;
import com.alachisoft.ncache.client.CacheManager;
import com.alachisoft.ncache.runtime.caching.ReadMode;
import com.alachisoft.ncache.runtime.caching.ReadThruOptions;

public class EmployeeReadThruCacheExample {
    public static void main(String[] args) {
        // Connect to the cache
        String cacheName = "employeeCache";
        Cache cache = CacheManager.getCache(cacheName);

        // Specify the key for the employee (e.g., employee ID)
        String employeeKey = "emp123";

         // Fetching employee details without Read-Through. This will return null if cache doesn't have this employee key.
        Employee employee = cache.get(employeeKey, Employee.class);
        print(employee);

         // Fetching employee details with ReadThru mode. This will return the employee details even if it doesn't exist in cache by fetching from the underlying data source.
        employee = cache.get(employeeKey, new ReadThruOptions(ReadMode.ReadThru), Employee.class);
        print(employee);

         // Fetching employee details with ReadThruForced mode. This will fetch the employee details from the backend source (database) even if it's available in cache.
        employee = cache.get(employeeKey, new ReadThruOptions(ReadMode.ReadThruForced), Employee.class);
        print(employee);
    }

     public static void print(Employee employee) {
        if(employee != null){
            System.out.println("EmployeeID, Name, Emaild");
            System.out.println(employee.getId() + ", " + employee.getName() + ", " + employee.getEmailId() + "\n");
        }
        else
            System.out.println("Employee doesn't exist in the cache.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Write-Through
NCache offers Write-Through caching support, facilitating direct write operations on the data source via the cache. This mechanism ensures synchronization between the cache and the underlying data source. In Write-Through caching, NCache first updates the cache store and subsequently applies the operation to the data source.

To leverage Write-Through Caching in our application, initial implementation of the WriteThruProvider interface is required. Presently, NCache provides two modes for Write-Through caching:
Write-Through (Updates data source synchronously)
Write-Behind (Updates data source asynchronously)

The following code illustrates a sample implementation for the WriteThruProvider interface. The writeToDataSource method encapsulates the logic for executing write operations on the specified data source. This functionality facilitates writing objects, bulk objects, and data types from the cache into the data source.

import com.alachisoft.ncache.runtime.caching.ProviderCacheItem;
import com.alachisoft.ncache.runtime.datasourceprovider.*;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Map;

public class SampleWriteThruProvider implements WriteThruProvider {

    private Connection connection;

    // Perform tasks like allocating resources or acquiring connections
    @Override
    public void init(Map<String, String> parameters, String cacheId) throws Exception {
        try {
            String server = parameters.get("server");
            String userId = parameters.get("username");
            String password = parameters.get("password");
            String database = parameters.get("database");
            String connectionString = getConnectionString(server, database, userId, password);
            try {
                connection = DriverManager.getConnection(connectionString);
        } catch (Exception ex){
            // Handle connection initialization failure
        }
    }

    @Override
    public void dispose() {
        if (connection != null) {
            try {
                connection.close();
            } catch (SQLException ex) {
            }
        }
    }

    @Override
    public OperationResult writeToDataSource(WriteOperation operation) {
        ProviderCacheItem cacheItem = operation.getProviderItem();
        Employee employee = cacheItem.getValue(Employee.class);

        switch (operation.getOperationType()) {
            case WriteOperationType.Add:
                return new OperationResult(operation, insertEmployee(employee));
            case WriteOperationType.Update:
                return new OperationResult(operation, updateEmployee(employee));
            // Handle other operation types (DELETE, etc.) as needed
            default:
                return new OperationResult(operation, OperationResult.Status.Failure);
        }
    }

     @Override
    public OperationResult writeToDataSource(Collection<WriteOperation> operations) {
        List<OperationResult> operationResults = new ArrayList<OperationResult>();
        for (WriteOperation operation : operations) {
            ProviderCacheItem cacheItem = operation.getProviderItem();
            Employee employee = cacheItem.getValue(Employee.class);

            switch (operation.getOperationType()) {
                case WriteOperationType.ADD:
                    operationResults.add(operation, insertEmployee(employee));
                    break;
                case WriteOperationType.UPDATE:
                    operationResults.add(operation, updateEmployee(employee));
                    break;
                // Handle other operation types (DELETE, etc.) as needed
                default:
                    // Log or handle unsupported operation
                    operationResults.add(operation, OperationResult.Status.Failure);
                    break;
            }
        }
        return operationResults;
    }

     @Override
    public Collection<OperationResult> writeDataStructureToDataSource(Collection<DataStructureWriteOperation> collection) throws Exception {
        // Implement this if necessary
    }

    private OperationResult insertEmployee(Employee employee) {
        try {
            PreparedStatement statement = connection.prepareStatement(
                    "INSERT INTO Employee (Id, Name, EmailId) VALUES (?, ?, ?)");
            statement.setInt(1, employee.getId());
            statement.setString(2, employee.getName());
            statement.setString(3, employee.getEmailId());
            statement.executeUpdate();
            return OperationResult.Status.Success;
        } catch (SQLException ex) {
            // Handle exception
            return OperationResult.Status.Failure;
        }
    }

    private OperationResult updateEmployee(Employee employee) {
        try {
            PreparedStatement statement = connection.prepareStatement(
                    "UPDATE Employee SET Name = ?, EmailId = ? WHERE Id = ?");
            statement.setString(1, employee.getName());
            statement.setString(2, employee.getEmailId());
            statement.setInt(3, employee.getId());
            statement.executeUpdate();
            return OperationResult.Status.Success;
        } catch (SQLException ex) {
            // Handle exception
            return OperationResult.Status.Failure;
        }
    }

    private String getConnectionString(String server, String database, String userName, String password) {
        // Construct and return the connection string
        // Example: "jdbc:mysql://server:port/database?user=username&password=password"
        return "jdbc:mysql://" + server + ":3306/" + database + "?user=" + userName + "&password=" + password;
    }
}
Enter fullscreen mode Exit fullscreen mode

NCache offers the WriteThruOptions class for defining Write-Thru options within APIs. Within this class, the WriteMode enum provides two options: WriteThru and WriteBehind.

In WriteThru mode, updates to the cache-store are immediately synchronized with the data source, ensuring synchronous data updates. Conversely, WriteBehind mode facilitates asynchronous updates to the data source following updates to the cache-store.

import com.alachisoft.ncache.client.Cache;
import com.alachisoft.ncache.client.CacheItem;
import com.alachisoft.ncache.client.CacheManager;
import com.alachisoft.ncache.runtime.caching.WriteMode;
import com.alachisoft.ncache.runtime.caching.WriteThruOptions;

public class EmployeeWriteThruCacheExample
{
    public static void main(String[] args) throws Exception {
        String cacheName = "employeeCache";

         // Connect to the cache and return a cache handle
        Cache cache = CacheManager.getCache(cacheName);

        Employee employee = new Employee("sdfl123", "Ron", "ron@xyz.com");
        String employeeKey = "Emp" + employee.getId();
        CacheItem employeeCacheItem = new CacheItem(employee);

        // Add item to the cache with Write Through
        cache.insert(employeeKey, employeeCacheItem, new WriteThruOptions(WriteMode.WriteThru));

        print(employee);

         // Getting item from the cache. If not found, a null object is returned
        Employee retrievedEmployee = cache.get(employeeKey, Employee.class);

        if (retrievedEmployee != null)
        {
            print(retrievedEmployee);

             // Update employee details
            retrievedEmployee.setEmailId("ronupd@xyz.com");
            employeeCacheItem = new CacheItem(retrievedEmployee);
            // Update employee in the cache using Write Through operation
            cache.insert(employeeKey, employeeCacheItem, new WriteThruOptions(WriteMode.WriteThru));
        }
        cache.close();
    }

    public static void print(Employee employee) {
        if(employee != null){
            System.out.println("EmployeeID, Name, EmailId");
            System.out.println(employee.getId() + ", " + employee.getName() + ", " + employee.getEmailId() + "\n");
        }
        else
            System.out.println("Employee doesn't exist in the cache.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this article, we have explored the various aspects of optimizing Java web application performance using NCache, focusing on caching strategies and techniques.

First, we discussed the importance of performance optimization in Java web applications and the role of caching in improving response times, reducing server loads, and enhancing user experience. We then introduced NCache, a distributed in-memory caching solution, and highlighted its features and benefits.

Next, we delved into caching strategies for Java web applications, covering patterns such as Cache-Aside, Read-Through, Write-Through, Refresh-Ahead, and more, and how they can be implemented with NCache.

In conclusion, NCache offers a robust and feature-rich caching solution for optimizing Java web application performance. By leveraging caching strategies, best practices, and features provided by NCache, we can achieve significant improvements in application performance, scalability, reliability, and user satisfaction.

The sample code for this article can be found on Github.

Top comments (0)