I recently wanted to build a high-performance caching mechanism. There are multiple caching strategies you can design, but it really depends on your business requirements. In this blog, I’ll share my thought process on how I decided to implement a workerpool to handle caching.
The Requirement
My requirement was straightforward, get data from the cache if it exists, otherwise fetch the missing data from the database and return the result. While returning the result, the missing data should be stored in the cache to avoid hitting the database again on subsequent requests.
This is essentially a combination of Lazy Loading + Async Cache Population.
The Development Challenge
As a backend engineer, my main challenge was building an async cache population mechanism inside a high-load server. Each request needs to be handled concurrently, so without a second thought, I started designing a workerpool.
The Design Challenge
Initially, I built a workerpool to handle cache population, and it satisfied my immediate requirement. But then I thought what if I need to design a similar workerpool for another concurrent caching operation?
I took a step back. Instead of jumping straight into a raw implementation, I wanted to build a Workerpool Design Pattern. That way, next time I need a workerpool, I wouldn’t have to design it from scratch,by “scratch” I mean setting up workers, configuring channels for specific operations, and managing wait groups.
The Design
After implementing this design, I only need to follow three simple steps:
- Initialize the pool
- Start the workers
- Create a job and pass it to the workerpool
Here’s how the workerpool behaves at a high level.
Implementation Approach
The code approach looks like this:
1. Initialize the Workerpool
type Pool struct {
workersCount int
jobChanel chan Job
wg *sync.WaitGroup
quit chan struct{}
}
func InitPool(workersCount int, chanelSize int) *Pool {
return &Pool{
workersCount: workersCount,
jobChanel: make(chan Job, chanelSize),
quit: make(chan struct{}),
wg: &sync.WaitGroup{},
}
}
func (p *Pool) InitWorkers(ctx context.Context) {
for i := 0; i < p.workersCount; i++ {
p.wg.Add(1)
go p.worker(ctx)
}
}
Then you can initialize the workerpool like this:
// Build Worker Pool (Workers + Pool)
redisSettingPool := workerpool.InitPool(10, 100)
redisSettingPool.InitWorkers(ctx)
2. Making the Workerpool Flexible for Different Tasks
Here’s where I used a neat trick: I defined the channel input type as an interface. This means any job I design can implement that interface, and the worker can execute the interface method.
type Job interface {
Execute(ctx context.Context) error
}
This simple interface allows you to create any job type that implements this method. Here’s an example:
type foodSaveJob struct {
foodRecommendedCache cache.FoodRecommendedCache
locationIndex, passenderId, foodIdsStr string
}
func NewFoodRecommendedJob(
foodRecommendedCache cache.FoodRecommendedCache,
passenderId, foodIdsStr string,
) *foodSaveJob {
return &foodSaveJob{
foodRecommendedCache: foodRecommendedCache,
passenderId: passenderId,
foodIdsStr: foodIdsStr,
}
}
func (fsj *foodSaveJob) Execute(ctx context.Context) error {
keySuffix := fmt.Sprintf("%s",fsj.passenderId)
err := fsj.foodRecommendedCache.SetFood(ctx, keySuffix, fsj.foodIdsStr)
if err != nil {
return err
}
return nil
}
3. Submitting Jobs to the Pool
Using this simple technique, you can create your own workerpool to perform dedicated tasks with just a few lines of code. Here’s how to pass created jobs to the channel:
foodMap := make(map[string]string)
foodMap["1234"] = "3423,3242,4564,3423"
foodMap["4567"] = "0808,3657,4957,0089"
for passengerId, foodList := range foodMap {
job := workerpool.NewFoodRecommendedJob(
foodRecommendedCache,
passengerId,
foodList,
)
redisSettingPool.AddJob(job)
}
Impact
After implementing this pattern Now? I just spin up a workerpool, define a job struct, and I’m done. What I love most is that this approach scales naturally.
Source Code
The complete implementation of the async cache population worker pool is available here:
Conclusion
When you need to design highly available, concurrent-handling operations, setting up a workerpool is essential. Building it as a reusable blueprint like this makes your codebase much more maintainable and scalable. More importantly, it saves you and your team from reinventing the wheel every time you need concurrent processing.