Consider this: Your web application is growing rapidly. What started as a simple caching strategy, storing a few user sessions and API responses, has evolved into a complex web of cached data. Your development team is struggling to manage cache invalidation, different teams are stepping on each other’s toes with cache keys & that one time someone accidentally cleared the entire cache still gives you nightmares.
Any of that sounds familiar? You are not alone.
As applications scale, managing cache effectively becomes increasingly challenging. So what can you do about it?
Cache Groups are an effective way to keep things a bit sane & better organized. They’re not just another layer of abstraction, they’re a powerful pattern for bringing order to cache chaos.
When building web applications at scale, caching is not just an optimization – it’s a necessity. However, as your application grows, managing cached data can become increasingly complex. This is where cache groups come in, offering a structured approach to organizing the cache.
Understanding Cache Groups
Cache groups are a way to organize cached items into logical collections. Think of them as namespaces for your cache, similar to how you might organize files into folders. For example, you might have separate groups for:
- User-related data
- Product information
- API responses
- Configuration settings
The Case for Cache Groups
Advantages
-
Organized Cache Management
- Clear separation of concerns.
- Easier debugging and monitoring.
- Better code organization and maintainability.
-
Selective Cache Invalidation
- Ability to clear specific types of cached data.
- More precise cache control.
- Reduced risk when mass clearing cache.
-
Flexible Caching Strategies
- Different TTL (Time To Live) values per group.
- Group-specific invalidation rules.
-
Improved Performance
- Ability to shard different groups across cache instances.
- Better cache hit ratios through organized access patterns.
- Efficient bulk operations on related cache entries.
Disadvantages
-
Additional Complexity
- Extra code to manage group membership.
- Need for consistent group naming conventions.
- Potential overhead in maintaining group metadata.
-
Memory Overhead
- Storage required for group tracking.
- Longer cache keys if using group names as key prefixes.
- Additional index maintenance.
-
Consistency Challenges
- Potential for stale group indexes.
- Race conditions in concurrent environments.
Implementation Approaches
Let’s look at three common approaches to implementing cache groups, each with its own trade-offs:
1. Key Prefix Approach
class GroupedCache {
protected string $prefix;
public function __construct( string $group )
{
$this->prefix = sprintf( 'group:%s:', $group );
}
public function put( string $key, mixed $value, int $ttl = 600 )
{
return Cache::put( $this->prefix . $key, $value, $ttl );
}
}
Pros:
- Simple implementation
- No additional storage overhead
- Works with any cache driver
Cons:
- Limited functionality
- Inefficient for bulk operations
- No built-in key tracking
2. Group Index Approach
class GroupedCache {
protected string $group = 'default-group';
protected string $key;
public function __construct( string $key, string $group = '' )
{
$this->key = $key;
if ( ! empty( $group ) ) {
$this->group = $group;
}
}
public function put( mixed $value, int $ttl = 600 )
{
$success = Cache::put( $this->key, $value, $ttl );
if ( true === $success ) {
$groupKeys = Cache::get( $this->getGroupKey(), [] );
$groupKeys[ $this->key ] = true;
Cache::forever( $this->getGroupKey(), $groupKeys );
}
return $success;
}
protected function getGroupKey() : string
{
return sprintf( 'cache-group:%s', $this->group );
}
}
Pros:
- Tracks all keys in a group
- Efficient bulk operations
- Works with any cache driver
Cons:
- Additional storage overhead
- Need for index maintenance
- Potential consistency issues
3. Advanced Implementation with Garbage Collection
This approach uses the group approach & adds robustness through garbage collection:
class GroupedCache {
protected string $group = 'default-group';
protected string $key;
public function __construct( string $key, string $group = '' )
{
$this->key = $key;
if ( ! empty( $group ) ) {
$this->group = $group;
}
}
public function put( mixed $value, int $ttl = 600 )
{
$success = Cache::put( $this->key, $value, $ttl );
if ( true === $success ) {
$groupKeys = Cache::get( $this->getGroupKey(), [] );
$groupKeys[ $this->key ] = true;
Cache::forever( $this->getGroupKey(), $groupKeys );
}
return $success;
}
public function get( mixed $default = null ) : mixed
{
return Cache::get( $this->key, $default );
}
protected function getGroupKey() : string
{
return sprintf( 'cache-group:%s', $this->group );
}
public function delete() : static
{
Cache::forget( $this->key );
$this->maybeCleanStaleKeys();
return $this;
}
public function maybeCleanStaleKeys() : array
{
$groupKeys = Cache::get( $this->getGroupKey(), [] );
$validKeys = [];
foreach ( $groupKeys as $key => $value ) {
if ( Cache::has( $key ) ) {
$validKeys[ $key ] = true;
}
}
if ( count( $validKeys ) !== count( $groupKeys ) ) {
Cache::forever( $this->getGroupKey(), $validKeys );
}
return $validKeys;
}
public function flush() : bool
{
$groupKeys = Cache::get( $this->getGroupKey(), [] );
foreach ( array_keys( $groupKeys ) as $key ) {
Cache::forget( $key );
}
return Cache::forget( $this->getGroupKey() );
}
}
Pros:
- Self-healing through garbage collection
- Robust against external cache modifications
- Maintains consistency automatically
Cons:
- Complex implementation
- Slight performance overhead from GC
- Requires careful tuning
Implementing Cache Groups in Laravel
Laravel had support for tags in cache earlier. Its still possible to tag your caches (the code is still there, for now) but its a feature which is not officially supported any more and references to its usage have been removed from the official documentation. The reason provided for its “removal” was that its too complex to maintain while being supported only by Memcached. From what I recall, garbage collection did not work when using it with Redis (never tried with Memcached) and a change in order of tags (when fetching via tags) produced weird results.
While I’m not really a fan of cache tagging, my usecase with them was to use tags as cache groups, with each cache key having only one tag. Since Laravel decided to move away from cache tagging, I wrote my own implementation for cache groups.
use Exception;
use Illuminate\Support\Facades\Cache as CacheFacade;
class Cache {
protected string $originalKey;
protected string $hashedKey;
protected string $group = 'default-group';
protected int $cacheExpiry = 600; // 10 minutes
protected $callback;
protected array $callbackParams = [];
public function __construct( string $key, string $group = '' )
{
$this->originalKey = $key;
$this->hashedKey = md5( $key );
if ( ! empty( $group ) ) {
$this->group = $group;
}
}
public static function make( string $key, string $group = '' ) : static
{
return new static( $key, $group );
}
public function expiresIn( int $expiry = 600 ) : static
{
$this->cacheExpiry = $expiry;
return $this;
}
public function updatesWith( callable $callback, array $args = [] ) : static
{
$this->callback = $callback;
$this->callbackParams = $args;
return $this;
}
public function delete() : static
{
CacheFacade::forget( $this->hashedKey );
$this->maybeCleanStaleKeys();
return $this;
}
public function maybeCleanStaleKeys() : array
{
$groupKeys = CacheFacade::get( $this->getGroupKey(), [] );
$validKeys = [];
foreach ( $groupKeys as $key => $value ) {
if ( CacheFacade::has( $key ) ) {
$validKeys[ $key ] = true;
}
}
if ( count( $validKeys ) !== count( $groupKeys ) ) {
CacheFacade::forever( $this->getGroupKey(), $validKeys );
}
return $validKeys;
}
public function get( mixed $default = null ) : mixed
{
if ( CacheFacade::has( $this->hashedKey ) ) {
return CacheFacade::get( $this->hashedKey );
}
try {
$data = call_user_func_array( $this->callback, $this->callbackParams );
} catch ( Exception $e ) {
$data = $default;
}
CacheFacade::put( $this->hashedKey, $data, $this->cacheExpiry );
$groups = CacheFacade::get( $this->getGroupKey(), [] );
$groups[ $this->hashedKey ] = true;
CacheFacade::forever( $this->getGroupKey(), $groups );
return $data;
}
protected function getGroupKey() : string
{
return sprintf( 'cache-group:%s', $this->group );
}
public function flush() : bool
{
$groupKeys = CacheFacade::get( $this->getGroupKey(), [] );
foreach ( array_keys( $groupKeys ) as $key ) {
CacheFacade::forget( $key );
}
return CacheFacade::forget( $this->getGroupKey() );
}
}
This is not the exact implementation I have but it gives the general idea for the API. The way I implement caches, they always have a callback specified which can be used to fetch uncached data in the case where cached data is not available. So the way this would work is:
class Product {
public function get() : array
{
return Cache::make( 'all-products', 'products' )
->expiresIn( 600 ) // 10 min TTL
->updatesWith( $this->getUncached( ... ) )
->get( [] );
}
protected function getUncached() : array
{
return ProductModel::where( 'status', 'active' )
->get()
->toArray();
}
}
Having such an API removes the need to write boilerplate code again & again which checks if cache exists or not, gets the uncached data and add to cache if it does not exist or has expired. Here the callback which provides uncached data is always specified as part of the cache call and the `Cache` class uses it to get data to store in cache if it does not already exist in the cache.
Another benefit of this over the tags feature is that this implementation is cache driver agnostic – it will work with whichever cache driver you use in Laravel – be it file or database or Redis or Memcached. Though ofcourse there will be a bit more overhead if file or database drivers are used instead of in-memory drivers like Redis or Memcached.
This can be further improved by adding weightage for garbage collection – like run it only 70% times. Or you can dispatch a job to your queue for it. Different cache connections can be specified for different cache groups allowing you to use different DBs or even servers.
Best Practices
-
Keep Groups Logical
- Create groups based on domain concepts
- Avoid too fine-grained grouping
- Document your group structure
-
Handle Edge Cases
- Implement garbage collection
- Consider race conditions
- Plan for cache driver differences
-
Monitor Performance
- Track group sizes
- Monitor garbage collection impact
- Measure cache hit rates per group
-
Maintain Consistency
- Use consistent naming conventions
- Document group usage
- Handle cache clearing properly
Parting Words
Cache groups are a powerful tool for organizing your application’s cache, but they come with their own complexities. When implemented thoughtfully, they can significantly improve the maintainability & reliability of your caching layer. The key is to choose an implementation that matches your needs, whether that’s the simplicity of key prefixes or the robustness of indexed groups with garbage collection.
Start simple if you’re just beginning & evolve to more sophisticated implementations as your needs grow. Whatever approach you choose, always keep monitoring and maintenance in mind – cache groups should make your life easier, not harder.
Remember that caching itself is a form of complexity in your application. Cache groups add another layer to this complexity, so make sure the benefits outweigh the costs for your specific use case. When they do, the improved organization and control can make your caching strategy much more manageable as your application grows.