-
-
Notifications
You must be signed in to change notification settings - Fork 9.7k
[Cache] Improve RedisTagAwareAdapter invalidation logic & requirements #33461
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
nicolas-grekas
merged 1 commit into
symfony:4.4
from
andrerom:improved_tagaware_redis_adapter
Oct 8, 2019
+92
−97
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,33 +11,32 @@ | |
|
||
namespace Symfony\Component\Cache\Adapter; | ||
|
||
use Predis; | ||
use Predis\Connection\Aggregate\ClusterInterface; | ||
use Predis\Connection\Aggregate\PredisCluster; | ||
use Predis\Response\Status; | ||
use Symfony\Component\Cache\CacheItem; | ||
use Symfony\Component\Cache\Exception\LogicException; | ||
use Symfony\Component\Cache\Exception\InvalidArgumentException; | ||
use Symfony\Component\Cache\Marshaller\MarshallerInterface; | ||
use Symfony\Component\Cache\Traits\RedisTrait; | ||
|
||
/** | ||
* Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation using sPOP. | ||
* Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation using RENAME+SMEMBERS. | ||
* | ||
* Set (tag relation info) is stored without expiry (non-volatile), while cache always gets an expiry (volatile) even | ||
* if not set by caller. Thus if you configure redis with the right eviction policy you can be safe this tag <> cache | ||
* relationship survives eviction (cache cleanup when Redis runs out of memory). | ||
* | ||
* Requirements: | ||
* - Server: Redis 3.2+ | ||
* - Client: PHP Redis 3.1.3+ OR Predis | ||
* - Redis Server(s) configured with any `volatile-*` eviction policy, OR `noeviction` if it will NEVER fill up memory | ||
* - Client: PHP Redis or Predis | ||
* Note: Due to lack of RENAME support it is NOT recommended to use Cluster on Predis, instead use phpredis. | ||
* - Server: Redis 2.8+ | ||
* Configured with any `volatile-*` eviction policy, OR `noeviction` if it will NEVER fill up memory | ||
* | ||
* Design limitations: | ||
* - Max 2 billion cache keys per cache tag | ||
* E.g. If you use a "all" items tag for expiry instead of clear(), that limits you to 2 billion cache items as well | ||
* - Max 4 billion cache keys per cache tag as limited by Redis Set datatype. | ||
* E.g. If you use a "all" items tag for expiry instead of clear(), that limits you to 4 billion cache items also. | ||
* | ||
* @see https://redis.io/topics/lru-cache#eviction-policies Documentation for Redis eviction policies. | ||
* @see https://redis.io/topics/data-types#sets Documentation for Redis Set datatype. | ||
* @see https://redis.io/commands/spop Documentation for sPOP operation, capable of retriving AND emptying a Set at once. | ||
* | ||
* @author Nicolas Grekas <[email protected]> | ||
* @author André Rømcke <[email protected]> | ||
|
@@ -46,11 +45,6 @@ class RedisTagAwareAdapter extends AbstractTagAwareAdapter | |
{ | ||
use RedisTrait; | ||
|
||
/** | ||
* Redis "Set" can hold more than 4 billion members, here we limit ourselves to PHP's > 2 billion max int (32Bit). | ||
*/ | ||
private const POP_MAX_LIMIT = 2147483647 - 1; | ||
|
||
/** | ||
* Limits for how many keys are deleted in batch. | ||
*/ | ||
|
@@ -62,26 +56,18 @@ class RedisTagAwareAdapter extends AbstractTagAwareAdapter | |
*/ | ||
private const DEFAULT_CACHE_TTL = 8640000; | ||
|
||
/** | ||
* @var bool|null | ||
*/ | ||
private $redisServerSupportSPOP = null; | ||
|
||
/** | ||
* @param \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface $redisClient The redis client | ||
* @param string $namespace The default namespace | ||
* @param int $defaultLifetime The default lifetime | ||
* | ||
* @throws \Symfony\Component\Cache\Exception\LogicException If phpredis with version lower than 3.1.3. | ||
*/ | ||
public function __construct($redisClient, string $namespace = '', int $defaultLifetime = 0, MarshallerInterface $marshaller = null) | ||
{ | ||
$this->init($redisClient, $namespace, $defaultLifetime, $marshaller); | ||
|
||
// Make sure php-redis is 3.1.3 or higher configured for Redis classes | ||
if (!$this->redis instanceof \Predis\ClientInterface && version_compare(phpversion('redis'), '3.1.3', '<')) { | ||
throw new LogicException('RedisTagAwareAdapter requires php-redis 3.1.3 or higher, alternatively use predis/predis'); | ||
if ($redisClient instanceof \Predis\ClientInterface && $redisClient->getConnection() instanceof ClusterInterface && !$redisClient->getConnection() instanceof PredisCluster) { | ||
throw new InvalidArgumentException(sprintf('Unsupported Predis cluster connection: only "%s" is, "%s" given.', PredisCluster::class, \get_class($redisClient->getConnection()))); | ||
} | ||
|
||
$this->init($redisClient, $namespace, $defaultLifetime, $marshaller); | ||
} | ||
|
||
/** | ||
|
@@ -121,7 +107,7 @@ protected function doSave(array $values, ?int $lifetime, array $addTagData = [], | |
continue; | ||
} | ||
// setEx results | ||
if (true !== $result && (!$result instanceof Status || $result !== Status::get('OK'))) { | ||
if (true !== $result && (!$result instanceof Status || Status::get('OK') !== $result)) { | ||
$failed[] = $id; | ||
} | ||
} | ||
|
@@ -138,9 +124,10 @@ protected function doDelete(array $ids, array $tagData = []): bool | |
return true; | ||
} | ||
|
||
$predisCluster = $this->redis instanceof \Predis\ClientInterface && $this->redis->getConnection() instanceof ClusterInterface; | ||
$predisCluster = $this->redis instanceof \Predis\ClientInterface && $this->redis->getConnection() instanceof PredisCluster; | ||
$this->pipeline(static function () use ($ids, $tagData, $predisCluster) { | ||
if ($predisCluster) { | ||
// Unlike phpredis, Predis does not handle bulk calls for us against cluster | ||
foreach ($ids as $id) { | ||
yield 'del' => [$id]; | ||
} | ||
|
@@ -161,46 +148,76 @@ protected function doDelete(array $ids, array $tagData = []): bool | |
*/ | ||
protected function doInvalidate(array $tagIds): bool | ||
{ | ||
if (!$this->redisServerSupportSPOP()) { | ||
if (!$this->redis instanceof \Predis\ClientInterface || !$this->redis->getConnection() instanceof PredisCluster) { | ||
$movedTagSetIds = $this->renameKeys($this->redis, $tagIds); | ||
} else { | ||
$clusterConnection = $this->redis->getConnection(); | ||
$tagIdsByConnection = new \SplObjectStorage(); | ||
$movedTagSetIds = []; | ||
|
||
foreach ($tagIds as $id) { | ||
$connection = $clusterConnection->getConnectionByKey($id); | ||
$slot = $tagIdsByConnection[$connection] ?? $tagIdsByConnection[$connection] = new \ArrayObject(); | ||
$slot[] = $id; | ||
} | ||
|
||
foreach ($tagIdsByConnection as $connection) { | ||
$slot = $tagIdsByConnection[$connection]; | ||
$movedTagSetIds = array_merge($movedTagSetIds, $this->renameKeys(new $this->redis($connection, $this->redis->getOptions()), $slot->getArrayCopy())); | ||
nicolas-grekas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} | ||
|
||
// No Sets found | ||
if (!$movedTagSetIds) { | ||
return false; | ||
} | ||
|
||
// Pop all tag info at once to avoid race conditions | ||
$tagIdSets = $this->pipeline(static function () use ($tagIds) { | ||
foreach ($tagIds as $tagId) { | ||
// Client: Predis or PHP Redis 3.1.3+ (https://github.com/phpredis/phpredis/commit/d2e203a6) | ||
// Server: Redis 3.2 or higher (https://redis.io/commands/spop) | ||
yield 'sPop' => [$tagId, self::POP_MAX_LIMIT]; | ||
// Now safely take the time to read the keys in each set and collect ids we need to delete | ||
$tagIdSets = $this->pipeline(static function () use ($movedTagSetIds) { | ||
foreach ($movedTagSetIds as $movedTagId) { | ||
yield 'sMembers' => [$movedTagId]; | ||
} | ||
}); | ||
|
||
// Flatten generator result from pipeline, ignore keys (tag ids) | ||
$ids = array_unique(array_merge(...iterator_to_array($tagIdSets, false))); | ||
// Return combination of the temporary Tag Set ids and their values (cache ids) | ||
$ids = array_merge($movedTagSetIds, ...iterator_to_array($tagIdSets, false)); | ||
|
||
// Delete cache in chunks to avoid overloading the connection | ||
foreach (array_chunk($ids, self::BULK_DELETE_LIMIT) as $chunkIds) { | ||
foreach (array_chunk(array_unique($ids), self::BULK_DELETE_LIMIT) as $chunkIds) { | ||
$this->doDelete($chunkIds); | ||
} | ||
|
||
return true; | ||
} | ||
|
||
private function redisServerSupportSPOP(): bool | ||
/** | ||
* Renames several keys in order to be able to operate on them without risk of race conditions. | ||
* | ||
* Filters out keys that do not exist before returning new keys. | ||
* | ||
* @see https://redis.io/commands/rename | ||
* @see https://redis.io/topics/cluster-spec#keys-hash-tags | ||
* | ||
* @return array Filtered list of the valid moved keys (only those that existed) | ||
*/ | ||
private function renameKeys($redis, array $ids): array | ||
{ | ||
if (null !== $this->redisServerSupportSPOP) { | ||
return $this->redisServerSupportSPOP; | ||
} | ||
$newIds = []; | ||
$uniqueToken = bin2hex(random_bytes(10)); | ||
|
||
foreach ($this->getHosts() as $host) { | ||
$info = $host->info('Server'); | ||
$info = isset($info['Server']) ? $info['Server'] : $info; | ||
if (version_compare($info['redis_version'], '3.2', '<')) { | ||
CacheItem::log($this->logger, 'Redis server needs to be version 3.2 or higher, your Redis server was detected as '.$info['redis_version']); | ||
$results = $this->pipeline(static function () use ($ids, $uniqueToken) { | ||
foreach ($ids as $id) { | ||
yield 'rename' => [$id, '{'.$id.'}'.$uniqueToken]; | ||
} | ||
}, $redis); | ||
|
||
return $this->redisServerSupportSPOP = false; | ||
foreach ($results as $id => $result) { | ||
if (true === $result || ($result instanceof Status && Status::get('OK') === $result)) { | ||
// Only take into account if ok (key existed), will be false on phpredis if it did not exist | ||
$newIds[] = '{'.$id.'}'.$uniqueToken; | ||
} | ||
} | ||
|
||
return $this->redisServerSupportSPOP = true; | ||
return $newIds; | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
35 changes: 0 additions & 35 deletions
35
src/Symfony/Component/Cache/Tests/Adapter/PredisTagAwareRedisClusterAdapterTest.php
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.