From 54543aea751a60a6ea7414e44b5c604450ea4e3c Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 23 Apr 2018 18:02:04 +0200 Subject: [PATCH] [Cache] Prevent stampede at warmup using apcu_entry() for locking --- .../Cache/Adapter/AbstractAdapter.php | 8 ++++++ .../Component/Cache/Adapter/ProxyAdapter.php | 2 +- .../Cache/Adapter/TagAwareAdapter.php | 24 ++++++++++++++++++ src/Symfony/Component/Cache/CHANGELOG.md | 1 + .../Component/Cache/Traits/GetTrait.php | 25 +++++++++++++++++-- 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php index b6e493916b860..031c206a56512 100644 --- a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php @@ -157,6 +157,14 @@ public static function createConnection($dsn, array $options = array()) throw new InvalidArgumentException(sprintf('Unsupported DSN: %s.', $dsn)); } + /** + * {@inheritdoc} + */ + public function get(string $key, callable $callback, float $beta = null) + { + return $this->doGet($this, $key, $callback, $beta ?? 1.0, '' !== $this->namespace ? $this->getId($key) : null); + } + /** * {@inheritdoc} */ diff --git a/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php b/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php index ddb533e0a95d7..4a97cbcde1356 100644 --- a/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php @@ -94,7 +94,7 @@ function (CacheItemInterface $innerItem, array $item) { public function get(string $key, callable $callback, float $beta = null) { if (!$this->pool instanceof CacheInterface) { - return $this->doGet($this, $key, $callback, $beta ?? 1.0); + return $this->doGet($this, $key, $callback, $beta ?? 1.0, $this->namespaceLen ? $this->getId($key) : null); } return $this->pool->get($this->getId($key), function ($innerItem) use ($key, $callback) { diff --git a/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php index f49e97119ad53..b408107fc1242 100644 --- a/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php @@ -35,6 +35,7 @@ class TagAwareAdapter implements CacheInterface, TagAwareAdapterInterface, Prune private $setCacheItemTags; private $getTagsByKey; private $invalidateTags; + private $getId; private $tags; private $knownTagVersions = array(); private $knownTagVersionsTtl; @@ -105,6 +106,13 @@ function (AdapterInterface $tagsAdapter, array $tags) { null, CacheItem::class ); + $this->getId = \Closure::bind( + function (AbstractAdapter $pool, $key) { + return $pool->getId($key); + }, + null, + AbstractAdapter::class + ); } /** @@ -150,6 +158,22 @@ public function invalidateTags(array $tags) return $ok; } + /** + * {@inheritdoc} + */ + public function get(string $key, callable $callback, float $beta = null) + { + if ($this->pool instanceof AbstractAdapter) { + $id = ($this->getId)($this->pool, $key); + } elseif ($this->pool instanceof ProxyAdapter) { + $id = ((array) $this->pool)["\0Symfony\\Component\\Cache\\Adapter\\ProxyAdapter\0namespace"].$key; + } else { + $id = null; + } + + return $this->doGet($this, $key, $callback, $beta ?? 1.0, $id !== $key ? $id : null); + } + /** * {@inheritdoc} */ diff --git a/src/Symfony/Component/Cache/CHANGELOG.md b/src/Symfony/Component/Cache/CHANGELOG.md index 2288db8cfebdb..1b2a02f10a12e 100644 --- a/src/Symfony/Component/Cache/CHANGELOG.md +++ b/src/Symfony/Component/Cache/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG ----- * added `CacheInterface`, which provides stampede protection via probabilistic early expiration and should become the preferred way to use a cache + * added warmup-time stampede protection using `apcu_entry()` for locking when available * added sub-second expiry accuracy for backends that support it * throw `LogicException` when `CacheItem::tag()` is called on an item coming from a non tag-aware pool * deprecated `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead diff --git a/src/Symfony/Component/Cache/Traits/GetTrait.php b/src/Symfony/Component/Cache/Traits/GetTrait.php index c2aef90c389dc..0ec04133e2cca 100644 --- a/src/Symfony/Component/Cache/Traits/GetTrait.php +++ b/src/Symfony/Component/Cache/Traits/GetTrait.php @@ -34,7 +34,7 @@ public function get(string $key, callable $callback, float $beta = null) return $this->doGet($this, $key, $callback, $beta ?? 1.0); } - private function doGet(CacheItemPoolInterface $pool, string $key, callable $callback, float $beta) + private function doGet(CacheItemPoolInterface $pool, string $key, callable $callback, float $beta, string $lockId = null) { $t = 0; $item = $pool->getItem($key); @@ -64,6 +64,7 @@ private function doGet(CacheItemPoolInterface $pool, string $key, callable $call } static $save = null; + static $useApcu = null; if (null === $save) { $save = \Closure::bind( @@ -81,6 +82,26 @@ function (CacheItemPoolInterface $pool, CacheItemInterface $item, $value, float ); } - return $save($pool, $item, $callback($item), $t); + if (null === $lockId || !$useApcu = $useApcu ?? \function_exists('apcu_entry') && ini_get('apc.enabled') && ('cli' !== \PHP_SAPI || ini_get('apc.enable_cli'))) { + return $save($pool, $item, $callback($item), $t); + } + + $t = $t ?: microtime(true); + $lockId = ':'.$lockId; + $isHit = true; + $value = apcu_entry($lockId, function () use ($callback, $item, &$isHit) { + $isHit = false; + + return $callback($item); + }, 2); + + if (!$isHit) { + $save($pool, $item, $value, $t); + // give some time to concurrent processes to get the value from APCu + usleep(min(300000, (microtime(true) - $t) * 150000)); + apcu_delete($lockId); + } + + return $value; } }