This module adequately addresses database cache stampedes for expired cache data but does not address the stampede issue when the caches are completely empty. This is generally only an issue for very large sites and it's only an issue when the site needs to be started during high traffic times. When this happens, often the flood of database connections all requesting the same data to fill cache can cause the database server to be overwhelmed. This is a well known issue described here.

Attached is a patch that addresses this issue by using the new locking mechanism now in D6 and D7.

CommentFileSizeAuthor
#44 memcache-fix-syntax-error-962422-44.diff1.63 KBdas-peter
#40 962422.patch9.54 KBcatch
#39 962422.patch9.49 KBcatch
#37 962422_locking.patch3.57 KBcatch
#35 962422_stampede_protection.patch2.91 KBcatch
#23 memcache_11.patch3.69 KBdreed47
#20 memcache.patch3.5 KBAnonymous (not verified)
#18 memcache.patch3.27 KBAnonymous (not verified)
#14 stampede.patch3.28 KBAnonymous (not verified)
#13 stampede.patch3.08 KBAnonymous (not verified)
#10 memcache.patch2.98 KBAnonymous (not verified)
#9 memcache.patch2.8 KBAnonymous (not verified)
#3 dmemcache_stampede_mcprefix.patch1.33 KBmgriego
dmemcache_stampede.patch1.35 KBdreed47
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

HongPong’s picture

Issue tags: +memcache

nice - * sub

mgriego’s picture

sub

mgriego’s picture

I like the way this works, will be testing it. The only comment I have right now is that, since locks are global, in order to avoid any possible namespace collisions, I think it makes sense to prefix $full_key with memcache_. Like the attached patch.

eaton’s picture

Chx mentioned on Twitter that previous work on the stampede problem had been done here: #295738: Cache stampede protection and the code appears to be attempting to handle the stampede case in the dmemcache_get() function. Do we have a good handle on why the existing code would be failing to catch some of the stampedes? Is it a problem of requests taking longer than the default 15 second semaphore?

dreed47’s picture

The #295738: Cache stampede protection patch addresses the issue with expired data in the caches. It doesn't address the issue with no data at all in the caches. This is typically only an issue if memcache needs to be restarted for some reason.

mgriego’s picture

Right, if memcache needs to be restarted or forcefully flushed for any reason (or, even if the key just doesn't exist yet, such as an aged key that was removed by memcache to make room for something else, etc), then that situation is not covered by the existing memcache code. In that case, all threads will rebuild the value to be cached without regard for each other.

dreed47’s picture

Also, ideally, this patch for a memcache implementation of the locking mechanism should be used with this patch.

Anonymous’s picture

i really, really like this idea.

i have a couple of concerns about the implementation.

1. what happens if i want to use a non-volatile backend for my locking code?

in that case, i think i'd be paying for expensive operations (relative to a memcache set/get) in the "ZOMG, my cache went away" case. lock_wait() is not cheap, so if memcache is flushed, then we'll be hammering the db.

2. lock implementations are pluggable, so recursion that relies on lock_wait() is bad, mmmkay

if a site chooses to use a non-core lock implementation (which will happen more for big sites), then the site could still work ok if the lock backend goes away. with that code, if the lock backend is dead, and there's a single cache miss, then *all* requests that require that item will just wait....

Anonymous’s picture

FileSize
2.8 KB

ok, so here's a first cut at taking an approach with zero dependencies on anything other than a working memcache setup. very lightly tested.

Anonymous’s picture

FileSize
2.98 KB

ok, this time without the semaphore typos.

dreed47’s picture

I like the idea of removing the dependency on the locking mechanism. Patch looks like it should do the trick. How much testing do we feel this would need before it could be committed?

mgriego’s picture

I do agree that making the semaphore work directly from the memcache is appropriate for this. Overall, I agree this patch looks good. I'm not sure that making the number of retries be the same as the number of seconds for the semaphore TTL is appropriate, though.

Anonymous’s picture

Version: 6.x-1.7 » 6.x-1.x-dev
FileSize
3.08 KB

@mgriego - fair enough.

updated the patch to use a configurable number of retries.

Anonymous’s picture

FileSize
3.28 KB

updated after a chat with chx in #drupal.

1. sleep for a shorter period

2. make the stampede code configurable

this approach is generally less useful than a) prewarming caches where possible and b) locking around at a layer above the cache.

dreed47’s picture

I'm running some tests with this patch now and I noticed a typo with the variable $stampede_protection_enabled

Anonymous’s picture

@der - oh, yep, i'll reroll unless you beat me to it.

dreed47’s picture

@justinrandell - I can reroll once I'm done with my tests. I'm having trouble getting this code to work for me. It seems like the dmemcache_delete isn't actually deleting the semaphore and the ttl isn't being respected either. Does this seem to work for you?

Also, I noticed, that dmemcache_add and dmemcache_delete call dmemcache_key to construct the full key so we probably wouldn't want to build the semaphore key using $full_key . '_semaphore', instead it should be $key . '_semaphore'.

Anonymous’s picture

FileSize
3.27 KB

attached patch fixes both of the issues in #17.

Anonymous’s picture

ahem, yes, usleep() doesn't return a value, so the $mc->get() part of this line was always failing.

+            if (usleep(100000) && ($result = $mc->get($full_key))) {
Anonymous’s picture

FileSize
3.5 KB

ok, attached patch fixes the usleep() error. also, wrapped the semaphore deletion in a check to see if the stampede protection is enabled.

actually tested the code this time, with a couple of shells running:

ab -c 3 -n 300000 http://sitename/

then restarting memcache a couple of times during the runs. i see processes getting the semaphore, and others waiting, then successfully fetching the item from cache. its clearly a winner when the whole cache goes away.

what still needs testing is the non-pathological case. there's definitely overhead in all this checking, and i haven't profiled to see how big it is.

dreed47’s picture

Ahh, yes...the usleep() issue fixed it for me as well. Thanks! My unit test are looking pretty good. I'll try to run some load tests in the next day or two. The only thing I can think of from a code review standpoint is that the comments in dmemcache_get() probably need tweaked (they still reference the old semaphore variable) and I was thinking it might be a good idea to log something in the statistics array for semaphore wait times (or loops). I can do another patch if we think that makes sense.

mgriego’s picture

@der I think that would indeed be a useful statistic.

dreed47’s picture

FileSize
3.69 KB

Patch with a statistic for semaphore waits.

Anonymous’s picture

#23 looks ready to me.

now we just need some benchmarks on the normal case.

Nick Lewis’s picture

Status: Needs review » Needs work

The patch does a great job in preventing great server killing stampedes. Unfortunately, the benchmark on a production site suffering from stampedes was bad enough that I reverted it.

Benchmark: 20 concurrent admin users hitting a page for 4 minutes.

siege -i -c20 -H "Cookie:[sessioncookie]" --time=4M example.com/taxonomy/term/%

BEFORE PATCH
Transactions: 1766 hits
Availability: 99.94 %
Elapsed time: 239.49 secs
Data transferred: 47.16 MB
Response time: 2.18 secs
Transaction rate: 7.37 trans/sec
Throughput: 0.20 MB/sec
Concurrency: 16.11
Successful transactions: 1766
Failed transactions: 1
Longest transaction: 22.49
Shortest transaction: 1.27

AFTER PATCH
Transactions: 1432 hits
Availability: 100.00 %
Elapsed time: 239.77 secs
Data transferred: 37.62 MB
Response time: 2.84 secs
Transaction rate: 5.97 trans/sec
Throughput: 0.16 MB/sec
Concurrency: 16.94
Successful transactions: 1432
Failed transactions: 0
Longest transaction: 5.87
Shortest transaction: 1.

These numbers did surprise me, as I didn't see anything in the patch that looked particularly naughty. I'll try profiling the changes and let you know what I find out.

Anonymous’s picture

nick, thanks for benchmarks, i'll get some tomorrow and do some profiling.

q0rban’s picture

subscribe

robertDouglass’s picture

Sub. Waiting for more benchmarks.

Anonymous’s picture

here are some benchmarks for anonymous requests, hitting the front page with 3 nodes, each node with a couple of comments. all runs are with APC, ab -n 1000 -c3:

without patch:

Requests per second:    495.00 [#/sec] (mean)
Requests per second:    497.67 [#/sec] (mean)
Requests per second:    497.03 [#/sec] (mean)
Requests per second:    490.24 [#/sec] (mean)

with patch, $stampede_protection_enabled = TRUE:

Requests per second:    494.77 [#/sec] (mean)
Requests per second:    493.22 [#/sec] (mean)
Requests per second:    491.32 [#/sec] (mean)
Requests per second:    493.34 [#/sec] (mean)

with patch, $stampede_protection_enabled = FALSE:

Requests per second:    491.58 [#/sec] (mean)
Requests per second:    490.91 [#/sec] (mean)
Requests per second:    491.46 [#/sec] (mean)
Requests per second:    494.20 [#/sec] (mean)

so, very little impact for this case. will work up some authenticated benchmarks next.

Anonymous’s picture

authenticated benchmarks, hitting 'admin/content' with uid auth cookie on the same site, ab -n 700 -c2:

without patch:

Requests per second:    73.92 [#/sec] (mean)
Requests per second:    73.54 [#/sec] (mean)
Requests per second:    73.95 [#/sec] (mean)

with patch, $stampede_protection_enabled = TRUE:

Requests per second:    73.76 [#/sec] (mean)
Requests per second:    73.65 [#/sec] (mean)
Requests per second:    73.66 [#/sec] (mean)

with patch, $stampede_protection_enabled = FALSE:

Requests per second:    73.96 [#/sec] (mean)
Requests per second:    73.92 [#/sec] (mean)
Requests per second:    73.80 [#/sec] (mean)

very little impact i can see in this setup. perhaps we need some more benchmarks? profiling is next.

Nick Lewis’s picture

Status: Needs work » Needs review

There were a number of issues at play on that benchmark. The site had been running 1.7 on a lower version of memcache (1.2) which we believe results an abysmal hit rates. So the unusually high miss rate + high number of memcached requests are a reasonable explanation for the difference in results. Since this feature can be turned on and off, this is not a blocker.

The documentation for this feature should maybe make a note about the overhead in cases of high request rate and high miss rate in addition to recommending protecting against stampedes at a higher level if possible (which is what we ended up doing).

catch’s picture

So I agree that relying on the core locking inc would be rough, but if we make this configurable and disabled by default, then I'm not sure why we couldn't use the lock API and just recommend using the memcache lock inc in docs - if people don't read the docs, they won't know this is there either.

Jeremy’s picture

Status: Needs review » Needs work

I'd like to see this functionality merged in, but as cache notes above I think we should use the locking API. Implementing the locking logic twice (once in memcache-lock.inc and now again in your patch in dmemcache.inc) seems wasteful and increases the possibility of introducing a bug. The patch should include documentation that explains what it is, how to enable it, and why memcache-lock.inc should be enabled at the same time.

Anonymous’s picture

ok, seems fair. i'll try to get somewhere tomorrow on this.

catch’s picture

Here's a re-roll using lock.inc, I didn't include the statistics changes here, since I think we'd be better off adding statistics support to memcache-lock.inc if that's desired. I haven't tested this yet, just wanted to refresh this and there's a few discussion points below.

Looking at this again I had a couple more concerns, which are hopefully valid (and if so should be addressed by the patch).

First, dmemcache_set() is always going to try to delete the lock, even if it's never been acquired. This is going to add overhead to every dmemcache_set() even though we only need to release the lock when it's been acquired. To avoid this, I checked the $lock global to see if the cid is in there - if it is, we release it, if not, while it's theoretically possible one process might be able to release a lock held by another one, that's sufficiently rare I don't think we need to account for it.

Also I don't have specific examples to show, I have concerns that leaving apache threads hanging waiting on locks could cause its own problems - as requests could mount up, leading to either max clients being exceeded or swapping. As well as this, if a particular cache item is extremely volatile for some reason, it's possible that we will get race conditions with the locking itself.
For example:

Process 1 acquires a lock.
Process 2 waits.
Process 1 releases the lock.
Process 3 acquires the lock.
Process 2 continues waiting.

To avoid this situation from getting too bad, I removed the maximum tries logic. There are now two variables, memcache_stampede_semaphore and memcache_stampede_max_wait. If neither are set, the values default to 15 and 15 / 3 = 5 respectively.

The idea is that the process acquiring the lock can get that lock for 15 seconds, but the maximum time any individual process will wait for the lock to be released is 5 seconds. This allows those other processes to pass through after they've waited around for a bit, while still allowing the lock to reduce overall load on the server while it's held. If for some reason a lock_release() failed, then again there is still the fallback of a maximum 5 second wait.

catch’s picture

Status: Needs work » Needs review

Marking CNR.

Also we should get #969998: Shorter lock_wait() polling time in before this or at a minimum very close together.

catch’s picture

FileSize
3.57 KB

Thought about this more and realised all of this logic is better handled in cache_get()/cache_set() - we don't want dmemcache.inc to be dependent on the locking framework - also locking and wildcards use dmemcache_set() etc. and those don't need stampede protection (in fact that could add bugs itself).

catch’s picture

Assigned: Unassigned » catch
catch’s picture

FileSize
9.49 KB

This no longer applied after recent commits to memcache.inc.

While re-rolling I realised it (and the original patch here) only applied to items that don't exist at all in the memcache bin, however that is an extremely rare case (more or less limited to memcache server restarts and new cache objects), with most invalidation of cache items happening in memcache.inc

So a summary of the latest patch:

- since this now uses lock.inc, I made the handling of expired items also depend on the new memcache_stampede_protection variable.

- any item that's invalid, for whatever reason, has stampede protection now - not just items that are completely missing from memcache.

- we only return expired items if that's the only reason they're invalid, otherwise we have to use lock_wait() to avoid the stampede. I feel like we could possibly do something here with items that are cache misses due to cache_clear_all() (i.e. when cache_lifetime doesn't kick in) - i.e. return stale entries for those too if this setting is enabled, but don't want to introduce that with the initial patch.

- it adds three new variables - memcache_stampede_protection, memcache_stampede_wait_time and memcache_stampede_wait_limit - these are documented in comments in memcache.inc and README.txt. Short version is it tries to balance the need for stampede protection vs. the need to not have individual requests waiting too long, and the possibility for lock_wait() to exit immediately before another process acquires a lock on a cache item.

- there is a lot of code changed here, 90% of this is the move of the original stampede protection from dmemcache.inc to memcache.inc, and the conversion of the return FALSE; in cache_get() to a long if/elseif and a single return at the end.

Patch passes tests with and without the stampede protection enabled.

I'm about to run our load tests with the setting enabled and disabled to confirm it's neutral or better. If that comes back OK then I'll go ahead and commit this, while it's new code, it's disabled by default. I'd be concerned about putting 1.9 out without this available, since http://drupal.org/node/1134242 removes minimum cache lifetime handling for cache_clear_all('*', $table, TRUE); - while that's good for correctness, it could mean more cold start issues.

catch’s picture

FileSize
9.54 KB

Minor update - added back 'memcache_' prefix to lock names.

catch’s picture

Version: 6.x-1.x-dev » 7.x-1.x-dev
Status: Needs review » Patch (to be ported)

Here's load test output with and without the stampede protection enabled:

Before:

Patch with no stampede protection (this is the same as my baseline too).

STAT total_connections 12058
STAT cmd_get 121686
STAT cmd_set 57798
STAT get_hits 63775
STAT get_misses 57911
STAT incr_hits 0
STAT bytes_read 67898312
STAT bytes_written 169266388
STAT evictions 6220
STAT total_items 57798

Hit rate: 52.4000%
Miss rate: 47.5900%

HTTP return codes:  20x(12045) 30x(1331) 40x(2)  50x(0)
 200: 12045
 302: 1331

Pages per second: 21.16

Patch with stampede protection enabled:

STAT total_connections 11687
STAT cmd_get 231828
STAT cmd_set 113131
STAT get_hits 118542
STAT get_misses 113286
STAT incr_hits 0
STAT bytes_read 82905912
STAT bytes_written 171613850
STAT evictions 6715
STAT total_items 113084

Hit rate: 51.1300%
Miss rate: 48.8600%

HTTP return codes:  20x(11674) 30x(1297) 40x(2)  50x(0)
 200: 11674
 302: 1297

Pages per second: 20.52

You can see that for each get_miss, we've added additional sets - that's the lock being acquired (and why you don't want to use this with core's lock.inc). However, pages per second stays around the same, and bytes read/bytes written only increases a little bit, same with overall hit rate. Since the load tests have a very high cache miss rate, this looks about right to me - we've got more memcache activity, but about the same in terms of actual performance.

Since this is optional and disabled by default, I'm going to go ahead and commit it to dev, it's one thing that would be good to test with the rc on a real site before we release a full 6.x-1.9 though.

I think looking at these memcache stats there is a slight optimization we can make in memcache-lock.inc, will open an issue for this.

Committed with http://drupalcode.org/project/memcache.git/commit/a01034d, moving this to 7.x to be forward ported.

catch’s picture

Title: DB Cache stampede for empty caches » Empty cache stampede protection
Category: bug » task
Priority: Normal » Critical

Bumping. This is the last big patch apart from memcache_admin changes that hasn't made it into 7.x yet. Major since I'd like to get this in before a 7.x-1.0.

catch’s picture

Status: Patch (to be ported) » Fixed

Forward ported this to 7.x.

das-peter’s picture

Status: Fixed » Needs review
FileSize
1.63 KB

The comit of the 7.x patch introduces a new syntax error.
http://drupalcode.org/project/memcache.git/blobdiff/0de438f34fd72eba20bb... :

+      // Finally, check for wildcard clears against this cid.
+      else {
+        if (!$this->wildcard_valid($cid, $cache){
+          $cache = FALSE;
+        }
       }

Closing parenthesis of the if is missing.
Attached patch adds the missing parenthesis and removes two trailing whitespaces.

catch’s picture

Status: Needs review » Fixed

Thanks, committed to 7.x.

Status: Fixed » Closed (fixed)
Issue tags: -memcache

Automatically closed -- issue fixed for 2 weeks with no activity.