8000 [Cache] Improve RedisTagAwareAdapter invalidation logic & requirements · dontub/symfony@3d38c58 · GitHub
[go: up one dir, main page]

Skip to content

Commit 3d38c58

Browse files
andreromnicolas-grekas
authored andcommitted
[Cache] Improve RedisTagAwareAdapter invalidation logic & requirements
1 parent a0bbae7 commit 3d38c58

File tree

4 files changed

+92
-97
lines changed

4 files changed

+92
-97
lines changed

src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php

Lines changed: 67 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -11,33 +11,32 @@
1111

1212
namespace Symfony\Component\Cache\Adapter;
1313

14-
use Predis;
1514
use Predis\Connection\Aggregate\ClusterInterface;
15+
use Predis\Connection\Aggregate\PredisCluster;
1616
use Predis\Response\Status;
17-
use Symfony\Component\Cache\CacheItem;
18-
use Symfony\Component\Cache\Exception\LogicException;
17+
use Symfony\Component\Cache\Exception\InvalidArgumentException;
1918
use Symfony\ 8000 Component\Cache\Marshaller\MarshallerInterface;
2019
use Symfony\Component\Cache\Traits\RedisTrait;
2120

2221
/**
23-
* Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation using sPOP.
22+
* Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation using RENAME+SMEMBERS.
2423
*
2524
* Set (tag relation info) is stored without expiry (non-volatile), while cache always gets an expiry (volatile) even
2625
* if not set by caller. Thus if you configure redis with the right eviction policy you can be safe this tag <> cache
2726
* relationship survives eviction (cache cleanup when Redis runs out of memory).
2827
*
2928
* Requirements:
30-
* - Server: Redis 3.2+
31-
* - Client: PHP Redis 3.1.3+ OR Predis
32-
* - Redis Server(s) configured with any `volatile-*` eviction policy, OR `noeviction` if it will NEVER fill up memory
29+
* - Client: PHP Redis or Predis
30+
* Note: Due to lack of RENAME support it is NOT recommended to use Cluster on Predis, instead use phpredis.
31+
* - Server: Redis 2.8+
32+
* Configured with any `volatile-*` eviction policy, OR `noeviction` if it will NEVER fill up memory
3333
*
3434
* Design limitations:
35-
* - Max 2 billion cache keys per cache tag
36-
* E.g. If you use a "all" items tag for expiry instead of clear(), that limits you to 2 billion cache items as well
35+
* - Max 4 billion cache keys per cache tag as limited by Redis Set datatype.
36+
* E.g. If you use a "all" items tag for expiry instead of clear(), that limits you to 4 billion cache items also.
3737
*
3838
* @see https://redis.io/topics/lru-cache#eviction-policies Documentation for Redis eviction policies.
3939
* @see https://redis.io/topics/data-types#sets Documentation for Redis Set datatype.
40-
* @see https://redis.io/commands/spop Documentation for sPOP operation, capable of retriving AND emptying a Set at once.
4140
*
4241
* @author Nicolas Grekas <p@tchwork.com>
4342
* @author André Rømcke <andre.romcke+symfony@gmail.com>
@@ -46,11 +45,6 @@ class RedisTagAwareAdapter extends AbstractTagAwareAdapter
4645
{
4746
use RedisTrait;
4847

49-
/**
50-
* Redis "Set" can hold more than 4 billion members, here we limit ourselves to PHP's > 2 billion max int (32Bit).
51-
*/
52-
private const POP_MAX_LIMIT = 2147483647 - 1;
53-
5448
/**
5549
* Limits for how many keys are deleted in batch.
5650
*/
@@ -62,26 +56,18 @@ class RedisTagAwareAdapter extends AbstractTagAwareAdapter
6256
*/
6357
private const DEFAULT_CACHE_TTL = 8640000;
6458

65-
/**
66-
* @var bool|null
67-
*/
68-
private $redisServerSupportSPOP = null;
69-
7059
/**
7160
* @param \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface $redisClient The redis client
7261
* @param string $namespace The default namespace
7362
* @param int $defaultLifetime The default lifetime
74-
*
75-
* @throws \Symfony\Component\Cache\Exception\LogicException If phpredis with version lower than 3.1.3.
7663
*/
7764
public function __construct($redisClient, string $namespace = '', int $defaultLifetime = 0, MarshallerInterface $marshaller = null)
7865
{
79-
$this->init($redisClient, $namespace, $defaultLifetime, $marshaller);
80-
81-
// Make sure php-redis is 3.1.3 or higher configured for Redis classes
82-
if (!$this->redis instanceof \Predis\ClientInterface && version_compare(phpversion('redis'), '3.1.3', '<')) {
83-
throw new LogicException('RedisTagAwareAdapter requires php-redis 3.1.3 or higher, alternatively use predis/predis');
66+
if ($redisClient instanceof \Predis\ClientInterface && $redisClient->getConnection() instanceof ClusterInterface && !$redisClient->getConnection() instanceof PredisCluster) {
67+
throw new InvalidArgumentException(sprintf('Unsupported Predis cluster connection: only "%s" is, "%s" given.', PredisCluster::class, \get_class($redisClient->getConnection())));
8468
}
69+
70+
$this->init($redisClient, $namespace, $defaultLifetime, $marshaller);
8571
}
8672

8773
/**
@@ -121,7 +107,7 @@ protected function doSave(array $values, ?int $lifetime, array $addTagData = [],
121107
continue;
122108
}
123109
// setEx results
124-
if (true !== $result && (!$result instanceof Status || $result !== Status::get('OK'))) {
110+
if (true !== $result && (!$result instanceof Status || Status::get('OK') !== $result)) {
125111
$failed[] = $id;
126112
}
127113
}
@@ -138,9 +124,10 @@ protected function doDelete(array $ids, array $tagData = []): bool
138124
return true;
139125
}
140126

141-
$predisCluster = $this->redis instanceof \Predis\ClientInterface && $this->redis->getConnection() instanceof ClusterInterface;
127+
$predisCluster = $this->redis instanceof \Predis\ClientInterface && $this->redis->getConnection() instanceof PredisCluster;
142128
$this->pipeline(static function () use ($ids, $tagData, $predisCluster) {
143129
if ($predisCluster) {
130+
// Unlike phpredis, Predis does not handle bulk calls for us against cluster
144131
foreach ($ids as $id) {
145132
yield 'del' => [$id];
146133
}
@@ -161,46 +148,76 @@ protected function doDelete(array $ids, array $tagData = []): bool
161148
*/
162149
protected function doInvalidate(array $tagIds): bool
163150
{
164-
if (!$this->redisServerSupportSPOP()) {
151+
if (!$this->redis instanceof \Predis\ClientInterface || !$this->redis->getConnection() instanceof PredisCluster) {
152+
$movedTagSetIds = $this->renameKeys($this->redis, $tagIds);
153+
} else {
154+
$clusterConnection = $this->redis->getConnection();
155+
$tagIdsByConnection = new \SplObjectStorage();
156+
$movedTagSetIds = [];
157+
158+
foreach ($tagIds as $id) {
159+
$connection = $clusterConnection->getConnectionByKey($id);
160+
$slot = $tagIdsByConnection[$connection] ?? $tagIdsByConnection[$connection] = new \ArrayObject();
161+
$slot[] = $id;
162 10000 +
}
163+
164+
foreach ($tagIdsByConnection as $connection) {
165+
$slot = $tagIdsByConnection[$connection];
166+
$movedTagSetIds = array_merge($movedTagSetIds, $this->renameKeys(new $this->redis($connection, $this->redis->getOptions()), $slot->getArrayCopy()));
167+
}
168+
}
169+
170+
// No Sets found
171+
if (!$movedTagSetIds) {
165172
return false;
166173
}
167174

168-
// Pop all tag info at once to avoid race conditions
169-
$tagIdSets = $this->pipeline(static function () use ($tagIds) {
170-
foreach ($tagIds as $tagId) {
171-
// Client: Predis or PHP Redis 3.1.3+ (https://github.com/phpredis/phpredis/commit/d2e203a6)
172-
// Server: Redis 3.2 or higher (https://redis.io/commands/spop)
173-
yield 'sPop' => [$tagId, self::POP_MAX_LIMIT];
175+
// Now safely take the time to read the keys in each set and collect ids we need to delete
176+
$tagIdSets = $this->pipeline(static function () use ($movedTagSetIds) {
177+
foreach ($movedTagSetIds as $movedTagId) {
178+
yield 'sMembers' => [$movedTagId];
174179
}
175180
});
176181

177-
// Flatten generator result from pipeline, ignore keys (tag ids)
178-
$ids = array_unique(array_merge(...iterator_to_array($tagIdSets, false)));
182+
// Return combination of the temporary Tag Set ids and their values (cache ids)
183+
$ids = array_merge($movedTagSetIds, ...iterator_to_array($tagIdSets, false));
179184

180185
// Delete cache in chunks to avoid overloading the connection
181-
foreach (array_chunk($ids, self::BULK_DELETE_LIMIT) as $chunkIds) {
186+
foreach (array_chunk(array_unique($ids), self::BULK_DELETE_LIMIT) as $chunkIds) {
182187
$this->doDelete($chunkIds);
183188
}
184189

185190
return true;
186191
}
187192

188-
private function redisServerSupportSPOP(): bool
193+
/**
194+
* Renames several keys in order to be able to operate on them without risk of race conditions.
195+
*
196+
* Filters out keys that do not exist before returning new keys.
197+
*
198+
* @see https://redis.io/commands/rename
199+
* @see https://redis.io/topics/cluster-spec#keys-hash-tags
200+
*
201+
* @return array Filtered list of the valid moved keys (only those that existed)
202+
*/
203+
private function renameKeys($redis, array $ids): array
189204
{
190-
if (null !== $this->redisServerSupportSPOP) {
191-
return $this->redisServerSupportSPOP;
192-
}
205+
$newIds = [];
206+
$uniqueToken = bin2hex(random_bytes(10));
193207

194-
foreach ($this->getHosts() as $host) {
195-
$info = $host->info('Server');
196-
$info = isset($info['Server']) ? $info['Server'] : $info;
197-
if (version_compare($info['redis_version'], '3.2', '<')) {
198-
CacheItem::log($this->logger, 'Redis server needs to be version 3.2 or higher, your Redis server was detected as '.$info['redis_version']);
208+
$results = $this->pipeline(static function () use ($ids, $uniqueToken) {
209+
foreach ($ids as $id) {
210+
yield 'rename' => [$id, '{'.$id.'}'.$uniqueToken];
211+
}
212+
}, $redis);
199213

200-
return $this->redisServerSupportSPOP = false;
214+
foreach ($results as $id => $result) {
215+
if (true === $result || ($result instanceof Status && Status::get('OK') === $result)) {
216+
// Only take into account if ok (key existed), will be false on phpredis if it did not exist
217+
$newIds[] = '{'.$id.'}'.$uniqueToken;
201218
}
202219
}
203220

204-
return $this->redisServerSupportSPOP = true;
221+
return $newIds;
205222
}
206223
}

src/Symfony/Component/Cache/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ CHANGELOG
66

77
* added support for connecting to Redis Sentinel clusters
88
* added argument `$prefix` to `AdapterInterface::clear()`
9+
* improved `RedisTagAwareAdapter` to support Redis server >= 2.8 and up to 4B items per tag
10+
* [BC BREAK] `RedisTagAwareAdapter` is not compatible with `RedisCluster` from `Predis` anymore, use `phpredis` instead
911

1012
4.3.0
1113
-----

src/Symfony/Component/Cache/Tests/Adapter/PredisTagAwareRedisClusterAdapterTest.php

Lines changed: 0 additions & 35 deletions
This file was deleted.

src/Symfony/Component/Cache/Traits/RedisTrait.php

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,17 @@ private function init($redisClient, string $namespace, int $defaultLifetime, ?Ma
5555
if (preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match)) {
5656
throw new InvalidArgumentException(sprintf('RedisAdapter namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0]));
5757
}
58+
5859
if (!$redisClient instanceof \Redis && !$redisClient instanceof \RedisArray && !$redisClient instanceof \RedisCluster && !$redisClient instanceof \Predis\ClientInterface && !$redisClient instanceof RedisProxy && !$redisClient instanceof RedisClusterProxy) {
5960
throw new InvalidArgumentException(sprintf('%s() expects parameter 1 to be Redis, RedisArray, RedisCluster or Predis\ClientInterface, %s given.', __METHOD__, \is_object($redisClient) ? \get_class($redisClient) : \gettype($redisClient)));
6061
}
62+
63+
if ($redisClient instanceof \Predis\ClientInterface && $redisClient->getOptions()->exceptions) {
64+
$options = clone $redisClient->getOptions();
65+
\Closure::bind(function () { $this->options['exceptions'] = false; }, $options, $options)();
66+
$redisClient = new $redisClient($redisClient->getConnection(), $options);
67+
}
68+
6169
$this->redis = $redisClient;
6270
$this->marshaller = $marshaller ?? new DefaultMarshaller();
6371
}
@@ -277,6 +285,7 @@ public static function createConnection($dsn, array $options = [])
277285
$params['replication'] = true;
278286
$hosts[0] += ['alias' => 'master'];
279287
}
288+
$params['exceptions'] = false;
280289

281290
$redis = new $class($hosts, array_diff_key($params, self::$defaultConnectionOptions));
282291
if (isset($params['redis_sentinel'])) {
@@ -414,40 +423,42 @@ protected function doSave(array $values, $lifetime)
414423
}
415424
}
416425
});
426+
417427
foreach ($results as $id => $result) {
418-
if (true !== $result && (!$result instanceof Status || $result !== Status::get('OK'))) {
428+
if (true !== $result && (!$result instanceof Status || Status::get('OK') !== $result)) {
419429
$failed[] = $id;
420430
}
421431
}
422432

423433
return $failed;
424434
}
425435

426-
private function pipeline(\Closure $generator): \Generator
436+
private function pipeline(\Closure $generator, $redis = null): \Generator
427437
{
428438
$ids = [];
439+
$redis = $redis ?? $this->redis;
429440

430-
if ($this->redis instanceof RedisClusterProxy || $this->redis instanceof \RedisCluster || ($this->redis instanceof \Predis\ClientInterface && $this->redis->getConnection() instanceof RedisCluster)) {
441+
if ($redis instanceof RedisClusterProxy || $redis instanceof \RedisCluster || ($redis instanceof \Predis\ClientInterface && $redis->getConnection() instanceof RedisCluster)) {
431442
// phpredis & predis don't support pipelining with RedisCluster
432443
// see https://github.com/phpredis/phpredis/blob/develop/cluster.markdown#pipelining
433444
// see https://github.com/nrk/predis/issues/267#issuecomment-123781423
434445
$results = [];
435446
foreach ($generator() as $command => $args) {
436-
$results[] = $this->redis->{$command}(...$args);
447+
$results[] = $redis->{$command}(...$args);
437448
$ids[] = $args[0];
438449
}
439-
} elseif ($this->redis instanceof \Predis\ClientInterface) {
440-
$results = $this->redis->pipeline(function ($redis) use ($generator, &$ids) {
450+
} elseif ($redis instanceof \Predis\ClientInterface) {
451+
$results = $redis->pipeline(static function ($redis) use ($generator, &$ids) {
441452
foreach ($generator() as $command => $args) {
442453
$redis->{$command}(...$args);
443454
$ids[] = $args[0];
444455
}
445456
});
446-
} elseif ($this->redis instanceof \RedisArray) {
457+
} elseif ($redis instanceof \RedisArray) {
447458
$connections = $results = $ids = [];
448459
foreach ($generator() as $command => $args) {
449-
if (!isset($connections[$h = $this->redis->_target($args[0])])) {
450-
$connections[$h] = [$this->redis->_instance($h), -1];
460+
if (!isset($connections[$h = $redis->_target($args[0])])) {
461+
$connections[$h] = [$redis->_instance($h), -1];
451462
$connections[$h][0]->multi(\Redis::PIPELINE);
452463
}
453464
$connections[$h][0]->{$command}(...$args);
@@ -461,12 +472,12 @@ private function pipeline(\Closure $generator): \Generator
461472
$results[$k] = $connections[$h][$c];
462473
}
463474
} else {
464-
$this->redis->multi(\Redis::PIPELINE);
475+
$redis->multi(\Redis::PIPELINE);
465476
foreach ($generator() as $command => $args) {
466-
$this->redis->{$command}(...$args);
477+
$redis->{$command}(...$args);
467478
$ids[] = $args[0];
468479
}
469-
$results = $this->redis->exec();
480+
$results = $redis->exec();
470481
}
471482

472483
foreach ($ids as $k => $id) {

0 commit comments

Comments
 (0)
0