Ticket #6199: cache_avoid_stampede.diff

File cache_avoid_stampede.diff, 9.2 KB (added by jdunck, 8 years ago)

Implementation, test, docs included.

  • django/conf/global_settings.py

     
    283283CACHE_BACKEND = 'simple://'
    284284CACHE_MIDDLEWARE_KEY_PREFIX = ''
    285285CACHE_MIDDLEWARE_SECONDS = 600
     286CACHE_AVOID_STAMPEDE = False
    286287
    287288####################
    288289# COMMENTS         #
  • django/core/cache/__init__.py

     
    1616"""
    1717
    1818from cgi import parse_qsl
     19from datetime import datetime, timedelta
     20
    1921from django.conf import settings
    20 from django.core.cache.backends.base import InvalidCacheBackendError
     22from django.core.cache.backends.base import InvalidCacheBackendError, BaseCache
    2123
    2224BACKENDS = {
    2325    # name for use in settings file --> name of module in "backends" directory
     
    2931    'dummy': 'dummy',
    3032}
    3133
     34class WrappedValueSentinel(object):
     35    pass
     36
     37class NoStampedeWrapper(BaseCache):
     38    """
     39    Avoids cache stampeding by missing on *one* request prior to actual
     40    expiration.  That single missed requestor then refills the cache prior to
     41    real expiration.  All other requestors still get hits.
     42   
     43    Adapted from MintCache from gfranxman:
     44    http://www.djangosnippets.org/snippets/155/
     45    """
     46    def __init__(self, backend, params):
     47        self.backend = backend
     48
     49        near_miss_seconds = int(params.get('near_miss_seconds', 5))
     50        self.near_miss_delta = timedelta(seconds=near_miss_seconds)
     51
     52        super(NoStampedeWrapper, self).__init__(params)
     53
     54    def get(self, key, default=None):
     55        inner_value = self.backend.get(key, None)
     56        if inner_value is None: #real miss.
     57            return default
     58       
     59        #Ensure we packed this value.
     60        #If not, return the value unaltered.
     61        if not isinstance(inner_value, tuple):
     62            return inner_value
     63           
     64        try:
     65            is_wrapped_sentinel, target_expire, value = inner_value
     66        except ValueError:
     67            return inner_value
     68               
     69        if not isinstance(is_wrapped_sentinel, WrappedValueSentinel):
     70            return inner_value
     71
     72        #We're now definitely dealing with managed value.
     73        near_miss_time = target_expire - self.near_miss_delta
     74        now = datetime.now()
     75           
     76        if now >= near_miss_time:
     77            #This is a near miss.
     78               
     79            #Push out the target_expire out past real expiration
     80            # to avoid extra misses.
     81            #Use a new timeout that agrees with the originally
     82            # requested expiration.
     83
     84            #Then return a miss to this requestor, who will then be
     85            # responsible for filling the cache while any other
     86            # requestors still get hits.
     87            new_timeout = (target_expire - now).seconds
     88            pushed_out = target_expire + timedelta(seconds=(new_timeout+1))
     89
     90            new_inner_val = (is_wrapped_sentinel,
     91                             pushed_out,
     92                             value)
     93
     94            self.backend.set(key, new_inner_val, new_timeout)
     95           
     96            return default
     97        return value
     98
     99    def set(self, key, value, timeout=0):
     100        timeout = timeout or self.default_timeout
     101        target_expire = datetime.now() + timedelta(seconds=timeout)
     102
     103        inner_value = (WrappedValueSentinel(),
     104                       target_expire,
     105                       value)
     106
     107        self.backend.set(key, inner_value,  timeout)
     108
     109    def delete(self, key):
     110        self.backend.delete(key)
     111                               
    32112def get_cache(backend_uri):
    33113    if backend_uri.find(':') == -1:
    34114        raise InvalidCacheBackendError, "Backend URI must start with scheme://"
     
    49129        host = host[:-1]
    50130
    51131    cache_class = getattr(__import__('django.core.cache.backends.%s' % BACKENDS[scheme], {}, {}, ['']), 'CacheClass')
    52     return cache_class(host, params)
     132    cache_backend = cache_class(host, params)
    53133
     134    if settings.CACHE_AVOID_STAMPEDE:
     135        cache_backend = NoStampedeWrapper(cache_backend, params)
     136    return cache_backend
     137
    54138cache = get_cache(settings.CACHE_BACKEND)
  • tests/regressiontests/cache/tests.py

     
    22
    33# Unit tests for cache framework
    44# Uses whatever cache backend is set in the test settings file.
    5 
    6 from django.core.cache import cache
    75import time, unittest
    86
     7from django.conf import settings
     8from django.core.cache import cache, get_cache, NoStampedeWrapper
     9from django.core.cache.backends.base import BaseCache
     10
    911# functions/classes for complex data type tests
    1012def f():
    1113    return 42
     
    8183            cache.set(key, value)
    8284            self.assertEqual(cache.get(key), value)
    8385
     86class StampedeCache(Cache):
     87    def setUp(self):
     88        self.prior_stampede = settings.CACHE_AVOID_STAMPEDE
     89        settings.CACHE_AVOID_STAMPEDE = True
     90
     91    def tearDown(self):
     92        settings.CACHE_AVOID_STAMPEDE = self.prior_stampede
     93
     94    def test_stampede_delta(self):
     95        from datetime import timedelta
     96        stuff = [
     97            ('simple://', timedelta(seconds=5)),
     98            ('simple://?near_miss_seconds=30', timedelta(seconds=30))
     99            ]
     100        for backend_uri, delta in stuff:
     101            c = get_cache(backend_uri)
     102            self.assertEqual(c.near_miss_delta,
     103                             delta)
     104    def test_got_stampede_wrapper(self):
     105        c = get_cache('simple://')
     106        self.assertEqual(type(c), NoStampedeWrapper)
     107        self.assert_(isinstance(c, BaseCache))
     108
     109    def test_near_miss(self):
     110        c = get_cache('simple://?near_miss_seconds=2')
     111        c.set('test_near_miss', 'actual hit', 3)
     112        time.sleep(1.1)
     113        self.assertEqual(c.get('test_near_miss'), None)
     114        self.assertEqual(c.get('test_near_miss'), 'actual hit')
     115
     116    def test_unwrapped_set(self):
     117        c = get_cache('simple://?near_miss_seconds=2')
     118
     119        test_values = [
     120            1,
     121            'unwrapped iterator',
     122            ('unwrapped', 'three', 'tuple'),
     123            ('unwrapped', 'shorter tuple'),
     124            ]
     125
     126        for test_value in test_values:
     127            c.backend.set('test_unwrapped_set', test_value, 5)
     128            self.assertEqual(c.get('test_unwrapped_set'), test_value)
     129            c.backend.set('test_unwrapped_set', test_value, 5)
     130
    84131if __name__ == '__main__':
    85132    unittest.main()
  • docs/settings.txt

     
    248248The default number of seconds to cache a page when the caching middleware or
    249249``cache_page()`` decorator is used.
    250250
     251CACHE_AVOID_STAMPEDE
     252--------------------
     253
     254Default: ``False``
     255
     256If True, the selected ``CACHE_BACKEND`` is wrapped to avoid stampeding on
     257cache misses.  See the `cache docs`_.
     258
    251259DATABASE_ENGINE
    252260---------------
    253261
  • docs/cache.txt

     
    203203        dumped when max_entries is reached. This makes culling *much* faster
    204204        at the expense of more cache misses.
    205205
     206    near_miss_seconds
     207        The estimated number of seconds needed to reproduce a cache value. 
     208        Only used when ``CACHE_AVOID_STAMPEDE`` is True.  Defaults to 5.
     209
    206210In this example, ``timeout`` is set to ``60``::
    207211
    208212    CACHE_BACKEND = "memcached://127.0.0.1:11211/?timeout=60"
     
    342346That's it. The cache has very few restrictions: You can cache any object that
    343347can be pickled safely, although keys must be strings.
    344348
     349Avoiding cache stampedes
     350========================
     351
     352Caching is intended to make your site more efficient by avoiding reproducing
     353expensive results.  Django's cache does this by storing the expensive result
     354for a specific amount of time.  This generally works very well.  However, when
     355many requests for the same cache key occur in the time that it takes to produce
     356the cached result, the goal may not be achieved.  For example, if 20 requests
     357per second are made to a key which takes 5 seconds to produce, the work to
     358produce the value corresponding to the key may be done as much as 100 (5 x 20)
     359times. 
     360
     361This condition is called stampeding (or dog-piling), and can be avoided by
     362setting ``CACHE_AVOID_STAMPEDE`` to ``True``.  Doing so will slightly slow
     363down both ``get`` and ``set`` operations.  It will also the value stored in
     364the cache backend by including values needed to manage the stampede condition.
     365
     366Recommended for high traffic sites with uniform access to keys.
     367
     368Note that ``CACHE_AVOID_STAMPEDE`` will not affect normal key expiration.
     369It will not avoid stampedes which occur just before the normal expiration of
     370the key because ``CACHE_AVOID_STAMPEDE`` will not extend the normal expiration
     371to avoid it. 
     372
    345373Upstream caches
    346374===============
    347375
Back to Top