On Cache Invalidation: Why Is It Hard? (2018)
yihui.org
yihui.org
What is hard is invalidating a cache in that situation when an item must be removed from the cache, because it no longer exists outside the cache. If that is not done, you get a stale cache entry, which is incorrect and possibly breaks the system.
So now suppose the cache is multi-threaded, distributed and whatever. Now you have edge cases and race conditions. Is it okay if the item is gone, but the cache remains for just a little window of time? If not, can we remove it from the cache first, then for real? What if in between those two, something accesses it so it reappears in the cache? Pardon me, did I say the cache? I meant seventeen different caches throughout the distributed system ...
That's basically what happens today in bleeding-edge CPU architecture development.
We can assume all the processors that are relevant are online; no processor core will try to invalidate an item while being cut off from communication, which is then re-established later. (If a processor comes online dynamically, we can assume it's coming in with a clear cache.)
Bus snooping is possible: every cache can see every memory access, and based on that, it can invalidate (or even update) entries that would be made stale due to stores. In a distributed system, no such thing is possible; you can't feasibly have 10,000 geographically distributed nodes all watching every transaction.
We can also assume that any remaining race conditions are handled by the software: that the software will use tools like memory barriers in the right places; we don't have to solve every possible concurrency problem that interacts with caching effects.
This is typically called eventual consistency [1] which is common when working with distributed systems, the same way you may write an entry and doesn’t become immediately available. This is often the case on my day to day work and you “just” have to work under the assumption that data isn’t the most recent sometimes.
I personally much prefer to start with a system that has strong consistency guarantees and then relax those guarantees exactly where necessary. Trying to work it from the other end will show you exactly why data store developers often choose EC even if it’s not the best pattern.
As an example, I build a system with exclusively commutative, restricted operations. What that means is that I can look at an item and know, regardless of when that item was written, that only certain operations will be applied in certain ways.
To serve a query, such as "Find me the item with an element X greater than 100", I don't need to find every item's consistent state of X. If an item's X from cache is, say, 50, and I know that X is restricted to only ever shrink in value, I don't need to hit the database for a consistent view of X.
Application level constraints like this are much more powerful than database transactions and radically more efficient, which is why EC systems can perform so well.
We do it to answer very correctness-sensitive questions around security, but it doesn't matter because stale answers are still valid if you know how those answers could have possibly been updated - and that's just an application invariant.
My point is that in general, EC is not a feature. Nobody sets out hoping to find a database that provides EC. They usually set out to find a database that can be globally distributed and have strong ACID guarantees. When confronted with various cost constrains they eventually settle for a system that makes trade offs where part of the price is EC. They then work around EC, usually not completely but enough that most of the time the system works fine. But EC is not in and of itself good or desirable, it’s just a less of several evils. Moreover, of the evils that it does compete with its necessarily the least, just the easiest to implement and as a result the most popular.
Your argument of “it works in this one case and it works well” is a bit of a straw man in that no cache at all also works in some cases, but that doesn’t make it a general solution. I have successfully used an EC system for a decently sized (at the time at least) dataset and it worked well but it was only because that particular workflow naturally allowed for EC semantics (streaming updates every few seconds/source). But I sure as hell wouldn’t want to build a bank on EC.
The specialized case is where you can ensure a few application level constraints about your data, which so far in my experience are extremely valuable constraints. It's maybe a less trotted road, but not a difficult one.
The benefit is massive improvements to performance and reduction in complexity - you eliminate the need for a complex consensus system.
It is far more than "it works in this one case", it is that EC removes a massive cost in databases that is often unnecessary - transactional logic, and in return it gives huge improvements in other areas. Specifically, and relevant to this thread (because the article is on caching), in the area of caching this is particularly desirable.
This is, as another user mentioned, called strong eventual consistency.
Your comment compared EC to race conditions, which I think is quite a negative way to view them, so I wanted to point out that EC is not "strictly worse" or buggy or whatever.
My only addition to that is that it’s popularity may be due to ease of developing EC databases vs ones with distributed consensus algorithms, and that personally I prefer to start with a system not based on EC, then add EC where necessary whereas it sounds like you prefer to start with EC and add constraints. I think your approach is more popular, but in my personal work experience it leads to more fragile systems which is why I advocate for at least understanding why that choice is being made.
EC systems aren’t inherently buggy. They just by themselves don’t include guarantees that you might find useful or desirable for general workloads.
I've always heard it as "There are only two hard things in Computer Science: cache invalidation, naming things and off-by-one errors."
I think off-by-one errors got a bit easier once we started normalizing loops like `for cat in cats`, but that doesn't really fly when you need to do multiple array accesses while iterating through the loop, so I suppose they're here to stay...
The first is to only cache the results of pure functions whose arguments can be perfectly equality-checked. But if your arguments aren't trivial to compare, this can incur some overhead even on cache hits. It also doesn't work for a stateful system, because not all relevant information gets passed in as arguments.
So the second way is to track every possible mutation to any data in the system, associate each piece of data with all caches that depend on it, and "push" invalidations to those caches when something changes. In the context of a front-end app, this is what MobX does. Salsa is a Rust library that does something similar and is used by rust-analyzer. Broadly this falls under the term "reactivity", though I've seen the word used to describe several different related concepts.
Most of the invalidation strategies people use in practice are imperfect and ad-hoc and depend on domain knowledge. Often you get a version of #2, but done manually instead of automatically: "I know that when X happens, we need to bust cache Y, so I'll manually code that behavior". This is the "hard" version that the adage refers to.
It's fairly easily done in the backend if you use event-sourcing. All mutations go through explicit events, so you just need an event listener that listens to all relevant events and invalidates the cache.
You can still have a race condition between the event being dispatched and the cache being invalidated, though (or the opposite, depending on when your event listeners are triggered)
If you could cache all the inputs that go into a pure function, you'd never need to invalidate the cache. Your cache becomes just a lazily-created tabulation of your function - which is fixed, as a map is just another representation of a mathematical function.
So cache invalidation happens only where your cache key isn't containing all the parameters of the cached computation - your cache isn't a mathematical function. Now the problem is, in real world, it's almost impossible to fully capture all the parameters of a computation in a way that gives you a usable cache (one that trades time/CPU for memory). So we need to track the possible changes in the "hidden state", and invalidate a cache entry if we detect such state. That's where all the complexity sits.
For instance, take a problem of a function countWords(file), that counts words in a file. How would we cache its results? If we key by file name, the cache will be wrong when the file gets changed. Keying by the hash of file contents is out of the question, because hashing is at least O(n) with respect to file size, just as countWords(), so we gain nothing. Keying by file name + modification date will fail if the clock changes, or file gets updated faster than the time resolution we're using. Not to mention, we'll have false positives if the date changes but the contents stay the same.
But assuming that the filesystem is sane and nobody messes with the clock, what else could happen? The definition of what a "word" is can change, making the entire cache bad. We may have found a bug in countWords(), updated the code, and now the cached results are also bad. The contents of the cache may have been changed externally (e.g. if shared by multiple functions, or multiple processes using countWords(), with different definition of "word" or with partially deployed bugfix). The code of the cache itself may have been updated to fix a storage bug there. Etc.
At some point you have to decide, which of those issues you care about and which you ignore - and for those you care about, how to monitor for changes, propagate them to the cache, and do it fast enough that none of the cache users will see invalid data in between detection and propagation. That's how cache invalidation is hard.
Not necessarily. The universe could be just one huge pure function.
If you nuke the cache _before_ performing the change, another request might make the changed item reappear in the cache before the change is completed.
If you nuke the cache _after_ performing the change, requests might sneak in between the change and the cache nuke and get stale data.
Actors can have their own cache, and since they queue messages and handle them in order, one at a time, the cache will never be out of sync.
It does come at several other costs though.
If your entries are simple enough (add/remove/update) and can be expressed as messages, wiring up a simple pub-sub cache is an afternoon project.
You inflict this hard problem on yourself when you try to throw away the time context of the data. Just... don't do that. Contextualize data with a commit log sequence id and all of these problems vanish. If the cache has data valid up to sequence id XYZ, include that context in the response -- that's all a cache can say anyways:
client: hey can I have /foo/bar
cache: sure, /foo/bar was 'abc' as of #XYZ
(or)
client: hey can I have /foo/bar as of at least #XYZ+7
cache: sure let me look that up...
And thus the cache invalidation problem is reduced to the naming problem, and you have at least one fewer off-by-one errors. Change my mind. :)It seems that the "correct" solution is building the cache into the database. Allow tying the cached result to the snapshot and inputs that it was generated against and use the regular controls for stale read tolerance to ensure that this cache entry is up-to-date. This even let's you use cache entries within a strongly consistent transaction (although staleness will probably hurt your hit rate)
In any system, wether poorly or well designed, one may discover that hardcoding responses to high-level/networked queries indeed improves performance. So it becomes tempting to duplicate the application response in front of it. Though without much concern wether duplicating the application outside of it is a good idea in that specific case, or not.
I only truly understood cache invalidation problems when I read Instagram’s engineering posts - then it dawned on me how hard it is for them to maintain a cached user profile database that has to spread over the entire world for a billion users (and potentially constantly be invalidated as users from around the world like their post etc).
If there was a core to core bulk messaging system, it would be memory mapped as well, and you'd need to store the messages in some sort of memory, so why add a specialized message queue, when you can just use memory?
You can do things like add a message to a mailbox in memory, and then send an interrupt to the other processor to indicate it's ready.
So one could code inter-thread communication without having to think about inducing large numbers of cache misses and degrading performance.
If you made a new CPU where there's no shared memory between cores I'm not quite sure who would use it. It's also not that clear how much simpler it makes things on the chip and for the developers.
An alternative architecture would be to say that different cores should not be using the same region of memory. If they attempt to do so, it is an error and exactly what happens is undefined. We only need to worry about cache invalidation when we transfer ownership of a region of memory between cores.
If we had a non memory method of cross core communication, we would need to pass memory between cores far less often, which would greatly simplyfy the problem of memory cache invaldiation.
Because that's what the Intel programmer's model of memory specifies. This is not necessarily true on ARM: https://community.arm.com/developer/ip-products/processors/b...
> different cores should not be using the same region of memory
Generally known as "NUMA"; this is a viable programmer's model, but it's different from what people are used to, and requires either software changes or a lot of performance-impacting compatibility layers when software accesses pre-existing global variables.
(You could certainly get NUMA multiprocessing systems back in the day - to make effective use you had to pin processes to cores, because the cost of migrating memory about was considerable otherwise)
For a very liberal definition of "communicate" that statement is true. The only reason for multiple threads to be sharing an address space is if they mutually depend on how the others are interacting with the data in there.
The reason for cross-core cache invalidation is that otherwise it becomes impossible to reason about behavior of any data structures written and read by different threads. Imagine trying to build multiple consumer queue without any cache coherence protocols.
Well, on many platforms we don't necessarily require this to be true immediately.
Were we all programming in Erlang, Rust and Go, maybe we'd see hardware follow.
- It's a big system with distributed parts. At any given time any piece of data might in fact be different in different parts of the system (before any change is properly distributed to the entire system).
- Often a single piece of data is accessed a lot in very short succession.
Since the system is kind of already built to handle the fact that the data is only eventually consistent, caching for performance gains have been very simple for us. We just implement a simple cache with a short lifetime. That's it.
Those are two characteristics that I try to look out for. Also, when I build something new, I try to build the system to be able to live in a world where data is not necessarily always the latest (as long as there is some kind of mechanism for getting notified that the data is out of sync when trying to change it, it seems to work).
For some given memory constraint, give me the same result with lookup performance for known inputs.
That’s not hard because of memory constraints (excepting very specialized use cases), that’s hard because the things that benefit from caching usually have implicit inputs. You’re trying to cache “database query” where “database” has other stuff mutating state. You’re not caching the call, you’re caching the side effects it depends on.
This use case is trivially solved if you plan for it. Use the algorithms from a history based system. Caching the state of a given object in git is easy: its cache is its hash. Either that part of the tree changed or it didn’t.
The key is to remove implicit dependencies from your cache path. Everything that you cache states its dependencies, anything that changes in them reports back.
You can get more specific than that for finer grained control but we’re already well past invalidation being the hard problem. It’s just dependency granularity.
Caching isn't always something you can pre-plan for; you're not going to always have a clean, fully encapsulated space like a git database. Quite often the need for caching only comes about as a result of profiling. I've seen plenty of crazy cache systems that turned out to be completely unnecessary and incurred much technical debt in my days because we guessed wrong about the performance profile and expected usage graph at the start.
Sometimes only a subsection of the queries need to be cached, which is important because you may be facing constraints where a full cache would saturate your storage space.
Like in the game of go, it looks simple but the devil's in the details. Caching in the real world is hard, and because of that cache invalidation is also hard.
There are plenty of simple solutions. Batching, throttling, scheduling, partitioning. Any or several could help.
> you're not going to always have a clean, fully encapsulated space like a git database
This was kinda my point: the problem isn’t cache invalidation, it’s external concurrent state.
This, and the rest of your response, comes down to the hard problems being design, analysis, and ability to adapt. Those problems show up in cache complexity because it reveals where those other considerations are suboptimal.
If there's one physical dollar that I own multiple times, that basically just means I keep depositing it at the bank and keep being paid with the same dollar. My net worth is still a million, so I'm happy with that.
* There are only two things that are hard in computer science: cache invalidation, naming things, and off-by-one errors.
1. cache expensive computation on read.
2. invalidate cache entry on update/delete.
for CRUD like applications it is trivial, since you have separate interfaces for read (GET) and update (POST).
- On write how do you know which entires need to be invalidated? Imagine data that is computed by multi-way joins. Any input that has changed may require invalidating all affected cache entries.
- Your cache is rarely strongly consistent with your database. So your update and invalidation will happen at slightly different times which can cause issues.
Re 1: cannot give you universal answer, but what I use is usually have multi-tiered cache: 1. global cache [using filesystem, or in memory db]
2. cache with scope limited to current user session [using webserver's session mechanism]
add more tiers if your app's architecture needs it (AZ level for example). Also you can use different cache eviction strategies for each cache level (manual eviction/lifetime eviction/LRU/etc).
once you have this, then it becomes easier to figure out what entries at what levels need to be invalidated. Hard thing becomes to maintain this logic, but it is a good trade-off since you get a great performance boost and scalability.
Re 2: UPDATE/DELETE to database usually is couple microseconds on most RDBMSs if using index lookup. cache invalidation is within the same time. In my use cases having this 4-5 ms window when cache entry is outdate is pretty good.