Unverified Commit c040ba1d authored by Alex Pott's avatar Alex Pott
Browse files

fix: #3526080 Reduce write contention to the fast and consistent backend in ChainedFastBackend

By: @catch
By: @kristiaanvandeneynde
By: @berdir
(cherry picked from commit e4311e61)
parent a14ca0ca
Loading
Loading
Loading
Loading
Loading
+34 −10
Original line number Diff line number Diff line
@@ -120,7 +120,19 @@ public function getMultiple(&$cids, $allow_invalid = FALSE) {
    // anything in the fast backend is valid, so don't even bother fetching
    // from there.
    $last_write_timestamp = $this->getLastWriteTimestamp();
    if ($last_write_timestamp) {

    // Don't bother to either read from or write to the fast backend if the last
    // write timestamp is in the future - it is always set with an additional
    // grace period for this reason. This reduces the likelihood of a cache
    // stampede on the fast backend when the consistent backend is being written
    // to frequently. It can also reduce the storage (usually memory)
    // requirement of the fast backend due to layered caching - e.g. when a
    // higher level cache is warm, the lower level cache items that are used to
    // build it won't be requested. Once the grace period has passed, the fast
    // backend will begin to take over from the consistent backend again.
    $compare = round(microtime(TRUE), 3);

    if ($last_write_timestamp && $compare > $last_write_timestamp) {
      // Items in the fast backend might be invalid based on their timestamp,
      // but we can't check the timestamp prior to getting the item, which
      // includes unserializing it. However, unserializing an invalid item can
@@ -166,7 +178,10 @@ public function getMultiple(&$cids, $allow_invalid = FALSE) {
    if ($cids) {
      foreach ($this->consistentBackend->getMultiple($cids, $allow_invalid) as $item) {
        $cache[$item->cid] = $item;
        if (!$allow_invalid || $item->valid) {
        // Only write back to the fast backend if the created time will be later
        // than $last_write_timestamp the next time it is retrieved, to
        // avoid wasted writes.
        if ((!$allow_invalid || $item->valid) && $compare > $last_write_timestamp) {
          $this->fastBackend->set($item->cid, $item->data, $item->expire, $item->tags);
        }
      }
@@ -179,9 +194,17 @@ public function getMultiple(&$cids, $allow_invalid = FALSE) {
   * {@inheritdoc}
   */
  public function set($cid, $data, $expire = Cache::PERMANENT, array $tags = []) {
    // Setting a cache item on the consistent backend requires invalidating the
    // fast backend. In a cold cache situation, there can be thousands of cache
    // sets. However, because each cache set invalidates every previous set,
    // only the item(s) from the last one will be valid. Therefore, don't write
    // to the fast backend, this avoids lock/write contention on the fast
    // backend, for cache items which may not be requested immediately anyway,
    // e.g. when higher level caches are warmed at the same time. The fast
    // backend will be populated via the logic in ::get() instead when cache
    // items are actually requested.
    $this->consistentBackend->set($cid, $data, $expire, $tags);
    $this->markAsOutdated();
    $this->fastBackend->set($cid, $data, $expire, $tags);
  }

  /**
@@ -190,7 +213,6 @@ public function set($cid, $data, $expire = Cache::PERMANENT, array $tags = []) {
  public function setMultiple(array $items) {
    $this->consistentBackend->setMultiple($items);
    $this->markAsOutdated();
    $this->fastBackend->setMultiple($items);
  }

  /**
@@ -292,15 +314,17 @@ protected function getLastWriteTimestamp() {
  protected function markAsOutdated() {
    // Clocks on a single server can drift. Multiple servers may have slightly
    // differing opinions about the current time. Given that, do not assume
    // 'now' on this server is always later than our stored timestamp. Add 50ms
    // to the current time each time we write it to the persistent cache, and
    // make sure it is always at least 1ms ahead of the current time. This
    // 'now' on this server is always later than our stored timestamp. Add one
    // second to the current time each time we write it to the persistent cache
    // and make sure it is always at least 1ms ahead of the current time. This
    // somewhat protects against clock drift, while also reducing the number of
    // persistent cache writes to one every 50ms if this method is called
    // multiple times during a request.
    // persistent cache writes to one every second if this method is called
    // multiple times during a request. Reads and writes from the fast cache
    // are skipped when this timestamp is in the future, which also helps to
    // avoid write contention on the fast cache.
    $compare = round(microtime(TRUE) + .001, 3);
    if ($compare > $this->getLastWriteTimestamp()) {
      $now = round(microtime(TRUE) + .050, 3);
      $now = round(microtime(TRUE) + 1, 3);
      $this->lastWriteTimestamp = $now;
      $this->consistentBackend->set(self::LAST_WRITE_TIMESTAMP_PREFIX . $this->bin, $this->lastWriteTimestamp);
    }
+88 −5
Original line number Diff line number Diff line
@@ -6,6 +6,7 @@

use Drupal\Component\Datetime\Time;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Cache\ChainedFastBackend;
use Drupal\Core\Cache\MemoryBackend;
use Drupal\Tests\UnitTestCase;
@@ -106,14 +107,45 @@ public function testSetInvalidDataFastBackend(): void {
    $this->assertEquals(NULL, $fast_cache->get($cid), 'Invalid data was not saved on the fast cache.');
  }

  /**
   * Tests that sets only get written to the consistent backend.
   */
  public function testSet(): void {
    $consistent_cache = $this->createMock(CacheBackendInterface::class);
    $fast_cache = $this->createMock(CacheBackendInterface::class);

    // The initial write to the fast backend should result in two writes to the
    // consistent backend, once to invalidate the last write timestamp and once
    // for the item itself. However subsequent writes during the same second
    // should only write to the cache item without further invalidations.

    $consistent_cache->expects($this->exactly(5))
      ->method('set');
    $fast_cache->expects($this->never())
      ->method('set');
    $fast_cache->expects($this->never())
      ->method('setMultiple');

    $chained_fast_backend = new ChainedFastBackend(
      $consistent_cache,
      $fast_cache,
      'foo'
    );
    $chained_fast_backend->set('foo', TRUE);
    $chained_fast_backend->set('bar', TRUE);
    $chained_fast_backend->set('baz', TRUE);
    $chained_fast_backend->set('zoo', TRUE);
  }

  /**
   * Tests a fast cache miss gets data from the consistent cache backend.
   */
  public function testFallThroughToConsistentCache(): void {
  public function testFastBackendGracePeriod(): void {
    $timestamp_item = (object) [
      'cid' => ChainedFastBackend::LAST_WRITE_TIMESTAMP_PREFIX . 'cache_foo',
      // Time travel is easy.
      'data' => time() + 60,
      // This is set two seconds in the future so that the grace period before
      // writing through to the fast backend is observed.
      'data' => time() + 2,
    ];
    $cache_item = (object) [
      'cid' => 'foo',
@@ -123,8 +155,59 @@ public function testFallThroughToConsistentCache(): void {
      'tags' => ['tag'],
    ];

    $consistent_cache = $this->createMock('Drupal\Core\Cache\CacheBackendInterface');
    $fast_cache = $this->createMock('Drupal\Core\Cache\CacheBackendInterface');
    $consistent_cache = $this->createMock(CacheBackendInterface::class);
    $fast_cache = $this->createMock(CacheBackendInterface::class);

    // We should get a call for the timestamp on the consistent backend.
    $consistent_cache->expects($this->once())
      ->method('get')
      ->with($timestamp_item->cid)
      ->willReturn($timestamp_item);

    // We should get a call for the cache item on the consistent backend.
    $consistent_cache->expects($this->once())
      ->method('getMultiple')
      ->with([$cache_item->cid])
      ->willReturn([$cache_item->cid => $cache_item]);

    // We should not get a call for the cache item on the fast backend.
    $fast_cache->expects($this->never())
      ->method('getMultiple');

    // We should not get a call to set the cache item on the fast backend.
    $fast_cache->expects($this->never())
      ->method('set');

    $chained_fast_backend = new ChainedFastBackend(
      $consistent_cache,
      $fast_cache,
      'foo'
    );
    $this->assertEquals('baz', $chained_fast_backend->get('foo')->data);
  }

  /**
   * Tests a fast cache miss gets data from the consistent cache backend.
   */
  public function testFallThroughToConsistentCache(): void {
    // Make the last_write_timestamp two seconds in the past so that everything
    // is written back to the fast backend.
    $timestamp_item = (object) [
      'cid' => ChainedFastBackend::LAST_WRITE_TIMESTAMP_PREFIX . 'cache_foo',
      'data' => time() - 2,
    ];
    $cache_item = (object) [
      'cid' => 'foo',
      'data' => 'baz',
      // The created time is set one minute in the past, e.g. before the
      // consistent timestamp.
      'created' => time() - 60,
      'expire' => time() + 3600,
      'tags' => ['tag'],
    ];

    $consistent_cache = $this->createMock(CacheBackendInterface::class);
    $fast_cache = $this->createMock(CacheBackendInterface::class);

    // We should get a call for the timestamp on the consistent backend.
    $consistent_cache->expects($this->once())