Serverless Caching with Momento and MongoDB
A lot has changed in the 8 years since I started building serverless applications. What used to be a great tool for a limited set of use cases has turned into an extremely powerful ecosystem filled with products, services, and frameworks that not only negate nearly every objection, but allows developers to build native cloud applications very quickly. Recently there have been numerous investments in "serverless databases" to bring familiar RDBMS offerings to the growing number of serverless workloads. I've seen some very promising progress in this area, but for me, I'm still a big fan of using NoSQL solutions with my serverless applications.
Don't get me wrong, I love the capabilities of MySQL and Postgres, but NoSQL databases have a combination of flexibility, scalability, and connection methods that highly complement a serverless approach. I have a lot of experience with Amazon DynamoDB and Cassandra, both excellent solutions for the right use cases, but I've always loved MongoDB and the flexibility of its query language. About a year ago, MongoDB made a serverless version of their MongoDB Atlas service generally available, which prompted me to take another look. I've been impressed so far, and I look forward to even more progress.
Even though MongoDB is widely appreciated for its flexibility and versatility, like any database system, when you scale up and usage increases, performance will likely take a hit. That's where caching comes in. Traditionally, the problem with caching in serverless applications, at least in the AWS ecosystem, is that you had to both run your Lambda functions in a VPC (which limits access to the Internet without a Managed NAT Gateway) and you had to provision an ElastiCache cluster and manage it yourself. Then late last year, I discovered Momento, a serverless cache that was truly serverless. You only pay for what you use and it instantly scales to meet your workloads. Serverless had been missing a great caching solution, but now with Momento in hand, we can do some pretty amazing things without adding all that extra overhead.
In this post, let's take a look at some of the benefits of adding a serverless cache like Momento in front of your MongoDB cluster, as well as some real world examples where caching can supercharge your serverless application backed by MongoDB.
Benefits of serverless caching
Like any database, MongoDB has its limitations, especially once traffic increases. Not only that, but spiky workloads (a place where serverless really shines) can easily overwhelm a MongoDB cluster that's underprovisioned. There are many flavors of MongoDB now, including the traditional self-hosted model, as well as MongoDB Atlas in serverless, dedicated, and shared modes. Regardless of the model you choose, adding a serverless cache like Momento can dramatically improve performance, provide a better user experience, and even help to reduce costs.
Here are several benefits of using a cache in front of MongoDB:
- Improved Application Performance: Adding a cache can significantly enhance the speed and responsiveness of your application. Since serverless compute is mostly stateless, data needs to be rehydrated on each invocation. By storing frequently accessed data in a highly-performant in-memory database service, it's much quicker than fetching it from disk-based MongoDB clusters. By reducing the time spent waiting for data, your application will respond faster, leading to a better, more responsive user experience.
- Reduced Database Load: Without a cache, every data request must be processed by your MongoDB cluster. Personally, I always recommend that you provision your database with enough capacity to handle normal load, but adding a caching layer can greatly help mitigate times with peak or spiky traffic. A cache allows you to offload a large portion of the read requests, leaving your MongoDB cluster free to deal with any increased write operations. This will reduce the load on your MongoDB cluster, leading to more stable performance, even under heavy load.
- Enhanced Scalability: Caching not only improves the performance under normal load, but it also makes your system more scalable. As traffic grows and generates more diverse data requests, your cache will also grow and store a larger collection of cacheable assets. When traffic spikes, the cache absorbs the extra load by having a higher hit rate, preventing your database from becoming overwhelmed. Depending on the type of workload, you might still need to add more resources to your MongoDB cluster, but you can use caching to help bridge gaps during scaling operations, giving your cluster the time it needs to allocate additional resources to handle increased demand.
- Cost Savings: Adding a cache to your MongoDB cluster can actually lead to substantial cost savings. First, because caching can reduce strain on your MongoDB cluster, you likely won't need to over-provision resources to handle peak loads. Instead, you can use your cache as a first line of defense to deal with increased demand. Second, depending on the hosting option you choose, reducing read requests and network traffic to your MongoDB cluster will also impact cost. As of this writing, the MongoDB Atlas Serverless offering charges you $0.10 USD per 1 million RPUs (4KB read requests).
- Consistency: In order for distributed database systems like MongoDB to be resilient, they need to replicate data across multiple nodes. We won't get into the details of the CAP theorem here, but while MongoDB generally has highly consistent reads, it is possible to change the read preference to send read operations to secondary members of the replica set. This makes reads "eventually consistent" similar to other NoSQL databases like DynamoDB and Cassandra, but can also greatly increase read throughput and minimize database load. Caching can help improve data consistency with distributed systems like this by serving as a central repository for the most up-to-date data. This is especially important with the single concurrency invocation model of many serverless compute options that need to share and synchronize data.
- Resiliency: Everything fails all the time, and MongoDB is no exception. In the event of a database failure or network issue that prevents access to MongoDB, you may still be able to access your Momento cache. It won't necessarily allow your application to be fully functional, but even in a degraded state, you'll likely still be able to successfully serve a number of customer requests, enhancing your system's resilience and overall availability.
- Offloading Computationally Intensive Queries: One of my favorite uses for a cache is storing the results of complex queries or computations performed on data. Not only can MongoDB
aggregate
queries be slow, but they can also tie up a significant amount of resources, limiting the overall scalability and performance of your cluster. By storing the results of these operations in your cache, you save processing power and improve response times. Plus, you can use your cache as a locking mechanism to prevent running the same aggregation queries multiple times.
Real-world examples
Now that we have an idea of how a serverless cache like Momento can enhance applications backed by MongoDB, let's look at some real world use cases and examples.
Frequent Read Queries
This is probably the most obvious, but also the most beneficial since it's easy to implement and can dramatically reduce load on your MongoDB cluster.
For example, let's say you have a list of recent articles on your home page. The information changes too frequently to be statically generated, but not often enough for every user to need a fresh query from the database. In this case, caching this list after the first query can save numerous subsequent database reads, especially during high demand. The "freshness" can easily be controlled by setting a lower TTL.
javascriptconst getRecentArticles = async () => { const articles = await momento.get("cache", "recentArticles"); if (articles instanceof CacheGet.Hit) { return JSON.parse(articles); } else { const findArticles = await db.articles.find().sort( { "published_date": -1 } ).limit(10); const articleList = await findArticles.toArray(); await momento.set( "cache", "recentArticles", JSON.stringify(articleList), { ttl: 60 } ); return articleList; } }
Another example might be for an e-commerce platform with product details for popular items being accessed frequently.
Complex Aggregation Queries
MongoDB's aggregation framework provides powerful capabilities for data analysis and transformation, but as we mentioned, they can be resource-intensive and sometimes slow. If you find yourself running the same aggregation query over and over again, caching the result can significantly boost performance and reduce overall load.
javascriptconst getSalesAggregates = async () => { const sales = await momento.get("cache", "salesAggregates"); if (sales instanceof CacheGet.Hit) { return JSON.parse(sales); } else { const aggregateSales = await db.sales.aggregate([ { $match: { status: "A" } }, { $group: { _id: "$cust_id", total: { $sum: "$amount" } } }, { $sort: { total: -1 } } ]) const salesList = await aggregateSales.toArray(); await momento.set( "cache", "salesAggregates", JSON.stringify(salesList), { ttl: 60 } ); return salesList; } }
Per Request User Data
If your application stores frequently used user data (like profile information, permission lists, etc.), keeping that information in a cache can make accessing it highly efficient, especially in distributed systems. Serverless applications, for example, generally need to hydrate state on each invocation, so reducing the need to access the database on every request is a huge win. Whether you need access to basic profile information, a list of user ACLs, or the ability to verify an access token on every request, caching these values can dramatically reduce load on the database.
javascript// Middleware to check cache for auth token const authMiddleware = (req, res, next) => { const authHeader = req.headers.authorization; const token = authHeader && authHeader.split(' ')[1]; const cachedToken = momento.get("cache", token); if (cachedToken instanceof CacheGet.Hit) { req.user = JSON.parse(cachedToken); return next(); } else { return res.sendStatus(403); } };
Leaderboards
Oftentimes a statically cached item (or even list of items) will serve our use case just fine. But what about when we have a group of items with rapidly changing sort information like for a leaderboard or list of "most liked" posts? We could cache the entire sorted list every few seconds or so, but that'll create a lot of database traffic and isn't very efficient. Instead, we want our cache to handle the sorting for us so we only need to worry about keeping the cache up to date with the latest scores. We can do this with Momento's "Sorted sets" data type.
We can access a Sorted set using the sortedSetFetchByRank
method like so:
javascript// Get the top 10 leaders const getLeaders = await momento.sortedSetFetchByRank( "cache", "leaderboard", { startRank: 0, endRank: 10, order: "DESC" } );
Now, to make sure the data is up-to-date, we can either update the cache every time a score is saved to MongoDB, or we could set up a Trigger that runs whenever a score is changed to offload the cache update to a separate process. Either way, we would update the Sorted set like this:
javascriptawait momento.sortedSetPutElement( "cache", "leaderboard", JSON.stringify({ name: "Parzival", avatar: "/img/avatars/parzival.png" }), 15235 // score );
This allows us to update only the cached records that have changed, while all the sorting will be done in real time using the capabilities of the cache. Very cool (and efficient)!
Geospatial Queries
Geospatial queries can also be quite resource-intensive. If you're using MongoDB to run queries like this, especially for popular points, caching the results can be very beneficial. Take a look at this MongoDB geospatial query:
javascriptdb.stores.find({ location: { $near: { $geometry: { type: "Point", coordinates: [ -73.9667, 40.78 ] }, $maxDistance: 100 } } })
We could cache this entire list in a single record using the coordinates as the key, or even better, we could save these results in a Sorted set with the distance as the score. We could then use the sortedSetFetchByScore
method to filter results by distance using the minScore
and maxScore
parameters. Even if you are caching results for a single user's GPS coordinates, any followup queries and filters will be much faster and eliminate numerous database round trips.
Caching isn't a silver bullet
It's important to remember that caching can be a double-edged sword, especially when you implement several layers of it. At a previous company, we actually had an ongoing joke that every problem was a caching issue. We had a CDN caching assets (including some dynamically generated ones), varnish in front of APIs, memcached at the data layer, and several in-memory memoizations to minimize network round trips. While this was great for performance and scalability, whenever there was an issue, tracking it down became extremely difficult.
I actually printed a sign that read, "It's a caching issue" and hung it over my desk. That way, when any of my coworkers reported a problem, I would just point to the sign! All jokes aside, it's important to remember that "caching everything" is generally not the best strategy. However, when used correctly, it is an incredibly useful tool, especially when paired with the immense power of serverless applications.
It's also important to think about when to start adding caching to your applications. For me, it always seems either too early or too late to cache, mostly because setting up and integrating traditional caching systems can be a lot of work. Then, once you do add a cache, you're obligated to keep putting bandaids on it each time you find an availability or performance risk.
Luckily, Momento makes it so easy to add (or remove a cache) that you can experiment and quickly assess its impact on your workflow. This dramatically enhances the developer experience, especially when integrated with platforms like Ampt that give each developer dedicated resources in isolated sandboxes. It also tackles common optimizations and availability best practices so you don't have to continually get distracted by your cache.
Momento has some resources on caching MongoDB that you should look at, including Seamlessly caching MongoDB Atlas (it's automagic!) and Effortless caching reducing MongoDB operations. If you're interested in getting the best performance out of your serverless application backed by MongoDB, I'd highly recommend trying out Momento.