Data Caching: Improving System Performance and Efficiency

Kacper Bąk
12 min readFeb 9, 2023
Photo by Koushik Pal on Unsplash

Data caching is a powerful tool that can be used to improve the performance of a system and reduce the number of resources consumed. By caching data and reducing the number of requests to another service, you can improve the response time of your system and provide a better user experience. In this article, we’ll discuss how data caching can be used to improve system performance and efficiency.

First, let’s take a look at what data caching is and how it can be used to improve system performance. Data caching is a process of storing data in a cache in order to reduce the number of requests that need to be made to another service. This can save both time and resources, as the cached data can be retrieved quickly and easily without having to query another service for the data each time. Additionally, data caching can also reduce the response time of the system, which can improve the user experience.

When implementing data caching, it is important to consider how the data will be cached and how often it should be refreshed. Setting an expiration time for the data will ensure that the data in the cache is not stale and that it is updated regularly.

As a Backend Developer, I recently faced a situation at work where I had to call up a query that was consuming a lot of resources. This query was necessary for our system to function properly, but running it all the time was not only wasting a lot of resources and time but was also slowing down the performance of our system.

The solution for this is to set up a scheduled query and a cached version of the query. This can be done by creating a cron job to run the query at predetermined intervals and caching the data from the query. This way, the query can be run once every 15 minutes and the system can simply retrieve the cached data from the cache instead of running the query again each time it is requested.

func main() {
// Create a ticker to run the query every 15 minutes
ticker := time.NewTicker(15 * time.Minute)

// Start the ticker
go func() {
for {
select {
case <-ticker.C:
runQuery()
}
}
}()

// Call the cachedQuery function
fmt.Println(cachedQuery())
}
// This function will return a cached version of the query
func cachedQuery() string {
// Code to retrieve cached data
return "Cached Data"
}
// This function will run the query every 15 minutes
func runQuery() {
// Code to run query
fmt.Println("Running Query")
}

To make the code an infinite cronjob, you can remove the “quit” channel and the close statement. This will allow the ticker to run indefinitely and execute the query every 15 minutes.

... 
// Start the ticker
go func() {
for {
select {
case <-ticker.C:
runQuery()
}
}
}()
// Call the cachedQuery function
fmt.Println(cachedQuery())
}

To cache the response from a query, you can use a caching library such as Redis, Memcached, or Couchbase. These libraries allow you to store the response from a query in a cache, which can then be retrieved quickly and easily when the query is requested again.

When caching the response from a query, it is important to consider the expiration of the data. Setting an expiration time will ensure that the data in the cache is not stale and that it is updated regularly. Additionally, caching libraries typically allow you to specify how often the data should be refreshed. This can help ensure that the data in the cache is up-to-date.

Additionally, it is possible to allow the user to query an endpoint that returns data to them whenever they want, but every 15 minutes they will receive the same data that will be in the cache. This way, the user will be able to query the endpoint and receive the same data without having to worry about the cache, and the system will be able to save resources and time.

The solution I came up with was to set up a schedule for the query. Instead of running the query every time the user requested it, I configured the system to run it once every 15 minutes. This way, the query was still available when needed, but it was not running all the time and consuming unnecessary resources.

To make this work, I had to set up a cron job to run the query at predetermined intervals. This cron job was coded in such a way that it would query the necessary data and store it in a cache. Then, any time a user requested the query, the system would simply retrieve the data from the cache instead of running the query again.

This solution was effective in solving the problem. Not only did it save us a lot of resources, but it also improved the performance of our system. Now, instead of having to query another service for data every time a user requested the query, the system was able to use the cached data to respond faster.

Overall, the solution I came up with was effective in solving the problem and improved the performance of our system. By implementing a scheduled query and caching the data, I was able to reduce the number of resources consumed and improve the response time of the system.

However, just after designing the solution and leaving it for the weekend, I sat down to do this with a fresh mind after some time off and came to the conclusion that it would be possible to simply allow the user to query an endpoint that returns data to them whenever they want, but every 15 minutes they will receive the same data that will be in the cache. This way, the user will be able to query the endpoint and receive the same data without having to worry about the cache, and the system will be able to save resources and time.

I also decided to have the endpoint fire every 5 minutes, load the data into a cache and serve from there.

Having the endpoint fire every 5 minutes and loading the data into a cache is a good option because it allows the system to retrieve the data quickly without having to query another service for the data each time. This can improve the performance of the system and reduce the number of resources consumed. Additionally, it can also reduce the response time of the system, which can improve the user experience.

It is ultimately up to the developer to decide how often the endpoint should fire, as this decision will depend on the specific needs of the system. For example, if the data is updated frequently, then a 5-minute interval may be more appropriate than a 15-minute interval. On the other hand, if the data is not updated frequently, then a 15-minute interval may be more appropriate.

The most important thing, however, was to create a work that would query one endpoint every 15 minutes that returns data. My initial idea was to create a whole structure with methods and data for logging errors, although according to good programming principles, I should not overwrite them with nil, i.e. an empty value, and I should keep the data in the cache all the time and it should be overwritten with a concurrent value.

My solution was to create a separate DataManager class that stores the data from the endpoint in a singleton instance. This way, the data is always stored in memory and can be accessed and updated often. The DataManager class also contains a method to make the request to the endpoint every 15 minutes and update the stored data.

This way, the data is always updated and is always available to the rest of the codebase without needing to make additional requests. This makes the code more efficient and reliable.

The structure of DataManager looks like this

type DataManager struct {
data map[string]interface{}
timer *time.Timer
}

Create a new DataManager instance

func NewDataManager() *DataManager {
dm := &DataManager{
data: make(map[string]interface{}),
}
dm.timer = time.NewTimer(15 * time.Minute)
go dm.fetchData()
return dm
}

This method makes a request to the endpoint every 15 minutes and stores the data.

I used NewTimer instead of NewTicker because it allows me to have more control over the timing of the requests. With NewTicker, the requests would be made at a fixed interval, whereas with NewTimer, I can reset the timer each time I make a request, allowing me to make the requests every 15 minutes, but not necessarily at exact 15-minute intervals. This allows for more flexibility and better control over when the requests are made.

func (dm *DataManager) fetchData() {
for {
<-dm.timer.C
resp, err := http.Get(endpoint)
if err != nil {
// Log the error
continue
}
// Parse the response and store the data
dm.data = parseResponse(resp)
// Reset the timer
dm.timer.Reset(15 * time.Minute)
}
}

And you can simply create that DataManager just simply by declaring some variable, and constructor method:

 dm := NewDataManager()

While communicating on multiple channels with different services — which is quite common in microservices I also encountered an error when making a Post request. The error message was host + endpoint and also the content “context canceled”, this is quite a common problem with concurrent programming in Golang.

The issue is likely related to the context of the request. The context of a request determines how long the request should take, and if the request takes longer than the context timeout, the request will be canceled. To fix this issue, you should set the context timeout for each request to a reasonable amount of time, so that it won’t be canceled prematurely. You can do this by using the WithTimeout() function when creating the context for the request. This will ensure that the request won’t be canceled due to timing out.

There’s a sample of handled timeout.

 // Create a context with a timeout of 10 seconds
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

// Make the request
req, _ := http.NewRequest("POST", endpoint, nil)
req = req.WithContext(ctx)
resp, err := http.DefaultClient.Do(req)
if err != nil {
// Handle error
}

After some time figuring it out and testing it, I finally got it right. The best solution turned out to be to create a goroutine that was created when I created a new client-specific problem.

At that point, a new routine was created that had the appropriate methods in it, which all it did was lock and unlock the mutex, and had its own local storage for data inside.

The mutex protected the shared data from being accessed by multiple clients at the same time, and the local storage ensured that data could be stored and retrieved quickly.

This way, I was able to prevent race conditions and ensure the data was safe and secure.

Interestingly, with this implementation of goroutine, I don’t even have to reload the endpoint for it to refresh the data that is received from the traffic distribution itself.

I can just call the goroutine, and it will automatically reload the data for me. This saves me time and makes my code much more efficient. Additionally, it also makes my code much more resilient, as it can handle any unexpected changes in traffic distribution.

When designing a system, it is important to consider how the system will handle a graceful shutdown. A graceful shutdown is a process that allows the system to stop running in an orderly fashion, ensuring that all tasks and requests are fulfilled before shutting down.

In my system, I implemented a graceful shutdown by using Go’s context package to manage the lifecycle of the system. The context package provides a signal that can be used to initiate a shutdown, and it also allows for the cancellation of tasks. This way, when a shutdown signal is sent, all tasks in progress can be canceled and the system can close in an orderly fashion.

Additionally, I also implemented a way to handle any requests that were in progress at the time of the shutdown. This was done by creating a queue that stores all requests that were sent to the system. Before the system is shut down, the queue can be processed and all requests can be fulfilled. Once all requests have been processed, the system can be shut down gracefully.

By using the context package and a request queue, I was able to ensure that my system could handle a graceful shutdown. This way, all requests and tasks can be fulfilled before the system is shut down, and users can rest assured that their requests will not be lost in the process.

When designing a system, it is important to consider how the different components of the system can be separated and organized. In my system, I chose to separate the system into three layers: the adapter layer, the transport layer, and the service layer.

The adapter layer is responsible for translating requests and responses between the system and the outside world. This layer is responsible for handling requests from the user and translating them into a format that the system can understand. It is also responsible for translating the responses from the system into a format that the user can understand.

The transport layer is responsible for handling the communication between the system and the outside world. This layer is responsible for sending requests to the system and for receiving responses from the system. This layer is also responsible for handling any errors that may occur during communication.

The service layer is responsible for handling the business logic of the system. This layer is responsible for handling the requests from the user and for processing the data that is received from the outside world. It is also responsible for storing and retrieving data from the system’s database.

By separating the system into these three layers, I was able to ensure that each layer was responsible for a specific set of tasks. This separation of concerns makes it easier to maintain and debug the system, as each layer can be worked on independently. Additionally, this separation allows for the system to be easily scaled, as each layer can be scaled separately.

When creating a new client for my system, I chose to base it on the general client that was already being used internally. To do this, I created a copy of the existing client’s reference and then modified a few key values, such as the Timeout. This was necessary because the service that the requests were being sent to needed 4 seconds to process the Big Data, rather than the 30 seconds that was set in the general client.

By creating a copy of the existing client, I was able to ensure that the new client had the same functionality as the existing client, while also allowing for the Timeout to be set to a different value. This allowed me to ensure that the requests sent to the Big Data service were not timing out prematurely, while also allowing me to keep the existing client unchanged.

Additionally, I also created a new context for the new client, as this allowed me to set the Timeout to a different value, while also allowing me to control the cancellation of tasks. This way, the new client can be used to send requests to the Big Data service while allowing for the Timeout to be set to a different value.

Overall, by creating a copy of the existing client and modifying a few key values, I was able to create a new client that can be used for the Big Data service, while also ensuring that the existing client remains unchanged.

The following is an example of code that can be used to edit the reference to a new client, an internal HTTP client, without modifying its internal structure.

// Create a new client with a different timeout
client := &http.Client{
Timeout: 4 * time.Second,
}

// Set the reference to the new client
internalHttpClient = client

Example usage of context timeout:

// Create a context with a timeout of 10 seconds
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

// Make the request
req, _ := http.NewRequest("POST", endpoint, nil)
req = req.WithContext(ctx)
resp, err := http.DefaultClient.Do(req)
if err != nil {
// Handle error
}

To accomplish this, I set up a cron job to run the query at predetermined intervals and cached the data from the query. This way, the query can be run once every 15 minutes and the system can simply retrieve the cached data from the cache instead of running the query again each time it is requested. I also implemented a way to handle requests that were in progress at the time of the shutdown, by creating a queue that stores all requests that were sent to the system.

Furthermore, I also implemented a data caching system, by creating two most important functions, saveDataToCache, and getDataFromCache. This allowed me to store and retrieve data from the cache quickly and easily. Additionally, I also set a context timeout for each request to ensure that the request won’t be canceled prematurely.

Data caching is an important tool for every developer. That can be used to improve the performance of a system. By caching data, developers can reduce the number of resources consumed and the response time of the system. Caching data can also eliminate the need to query another service for data every time, which can save both time and resources.

Furthermore, data caching can also help improve the user experience, as cached data can be retrieved quickly and easily without having to wait for a response from a remote server. By implementing data caching, web developers can ensure that their systems are running efficiently and providing a good user experience.

Overall, by setting up a scheduled query and caching the data, I was able to reduce the number of resources consumed and improve the response time of the system. Additionally, by implementing a data caching system and a context timeout, I was able to ensure that my system had the best performance and was resilient to any unexpected changes.

In the end, my solution was successful and it improved the overall performance and efficiency of the system. It was a great learning experience and I’m glad I was able to come up with a solution that worked.

--

--