Paginated Cache using Memcached and Go

Whenever we need to load lots of data stored on the backend, we split it into chunks and then load them one at a time through Pagination. Reasons why this approach is used:

  1. Improves user experience - Loading small-size data will take less time, hence will be available to the user faster.
  2. Reduces backend load - Processing small data is faster than processing large data at once. Usually, users also do not require all the data at once.
  3. Reduces load on the network - A single small page is light in terms of bandwidth.

This operation of serving data in chunks can be optimised if we cache the paginated list of data.

Here, we will illustrate how we cached the paginated list, which can optimise the loading time. However, if even a single item in a list is modified, the whole list will be considered modified, so caching pagination for a shorter duration makes more sense.

To make pagination cacheable, we store an additional version in the cache with a unique string for maintaining the version.

Using this version in the pagination pages cache key, we cache all pages of pagination by keeping the identifier of pages in the key itself i.e. paginationId.

We use this version while storing the pages of pagination. While we store the pages of the paginated list in cache, we use version and paginationId to identify the page in the paginated list. So to get to the next page, we will have to provide a paginationId obtained from the last page.

Let's see the following example to explain further,

cacheKey = "paginated_users_cache"

versionKey = "paginated_users_cache_v"
// We will store cache for versionKey with uuid as value, paginated_users_cache_v -> adb1c064-02e0-4a74-af70-0f803b2a4054

// It will look like "paginated_users_cache_adb1c064-02e0-4a74-af70-0f803b2a4054_0", for first page paginationId = 0
paginatedPageCacheKey = "paginated_users_cache" + "_" + "adb1c064-02e0-4a74-af70-0f803b2a4054" + "_" + paginationId

// We will fetch results based on paginatedPageCacheKey; if cache miss, then fetch from DB and set the cache.
PageResult = getCache(paginatedPageCacheKey)

Things to note here are,
By changing the value of paginationId, we can set the cache for each page.
For clearing cache, we can clear cache against only versionKey, as the rest pages caches will be unreachable and expired (as we are keeping expiry for a shorter duration).

Let us see an example of how we have implemented a paginated cache:

Consider we have a list of userIds and want to display them in pagination with a page size of 5.

Initialising all the required variables and memcached,

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"github.com/bradfitz/gomemcache/memcache"
	"github.com/google/uuid"
)

var memcachedInstance *memcache.Client

var userIds = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
var pageSize = 5

func init() {
	memcachedInstance = memcache.New("127.0.0.1:11211")
}

Let's implement a function to handle our versionKey needs; this function will simply try to fetch cache on key, if missed, it will create uuid and set the cache against key. It will assure us that we always get our unique uuid.

func getVersionCache(versionCacheKey string) string {

    item, cacheErr := memcachedInstance.Get(versionCacheKey)

    if cacheErr != nil {
        versionUuid := uuid.NewString()
        memcachedInstance.Set(&memcache.Item{
            Key:        versionCacheKey,
            Value:      []byte(versionUuid),
            Expiration: 100,
        })

        return versionUuid
    }

    return string(item.Value)
}

The function PaginatedCache will be our pagination cache which will take paginationId as an argument and will return the next page results based upon paginationId.

// paginationId here represents an id which will be used for creating a unique key for pages, here it is userId.
func PaginatedCache(paginationId int) []int {
    paginationBaseCacheKey := "paginated_users_cache"

    versionCacheKey := paginationBaseCacheKey + "_v"
    versionUuid := getVersionCache(versionCacheKey)

    paginationKey := paginationBaseCacheKey + "_" + fmt.Sprint(versionUuid) + "_" + fmt.Sprint(paginationId)

    cacheResponse, cacheErr := memcachedInstance.Get(paginationKey)
    if cacheErr != nil {
        // If we are unable to get data from cache, we will try to fetch and set it in cache.
        fetchedUserIds := fetchUserIds(paginationId)
        if len(fetchedUserIds) == 0 {
            return []int{}
        }

        fetchedUserIdsInBytes := new(bytes.Buffer)
        json.NewEncoder(fetchedUserIdsInBytes).Encode(fetchedUserIds)

        memcachedInstance.Set(&memcache.Item{
            Key:        paginationKey,
            Value:      fetchedUserIdsInBytes.Bytes(),
            Expiration: 100,
        })
        return fetchedUserIds
    }

    var userIdsFromCache []int
    json.Unmarshal(cacheResponse.Value, &userIdsFromCache)

    return userIdsFromCache
}

In case there is a cache miss, we fetch the data from the database/source for page results. But for this example, we have written a function which returns userIds from paginationId.

func fetchUserIds(paginationId int) []int {
    var userIdsForPage []int
    if paginationId >= len(userIds) {
        return userIdsForPage
    }

    count := 0
    for paginationId < len(userIds) && count < pageSize {
        userIdsForPage = append(userIdsForPage, userIds[paginationId])
        paginationId++
        count++
    }

    return userIdsForPage
}

So, when we run the main function.

func main() {
	paginationId := 0
	// Requesting user ids for first page -> paginationId = 0
	firstPageResultIds := PaginatedCache(paginationId)
	fmt.Println("UserIds displayed on first page -- ", firstPageResultIds)

	nextPagePaginationId := 0
	for _, userId := range firstPageResultIds {
		nextPagePaginationId = userId
	}
	// Requesting user ids for second page -> paginationId = 5
	secondPageResultIds2 := PaginatedCache(nextPagePaginationId)
	fmt.Println("UserIds displayed on second page -- ", secondPageResultIds2)
}

The output will be:

UserIds displayed on first page --  [1 2 3 4 5]

UserIds displayed on second page --  [6 7 8 9 10]

So, this way we made a paginated list cacheable which will reduce loading time of pages whenever cache is hit.

Isha Bansod

Isha Bansod

Vindeep Chaudhari

Vindeep Chaudhari

Senior Software Engineer in Backend at True Sparrow