为appengine

时间:2016-08-13 08:49:13

标签: google-app-engine

我需要一个访问记录的访问统计模块,它跟踪一些请求处理程序并收集统计信息给bigtable。我没有在github上找到任何现成的解决方案,谷歌的例子要么过于简单(带有cron的memcached首页计数器)或矫枉过正(精确的分片计数器)。但最重要的是,其他地方讨论的没有引入计数器的解决方案包括统计所需的时间成分(每小时,每日计数)。

要求:系统不需要100%准确,只能忽略内存缓存丢失(如果不经常)。这应该大大简化了事情。我们的想法是只使用内存缓存并按时间间隔累积统计数据。

UseCase :系统上的用户创建内容(例如网页)。你想跟踪约。用户浏览页面的频率每小时或每天。有些页面经常查看,有些页面从不查看。您想按用户和时间范围进行查询。子页面可能具有固定的ID(查询主页上大多数匹配的用户)。您可能希望删除旧条目(查询年份= xxxx的条目)。

class StatisticsDB(ndb.Model):
    # key.id() = something like YYYY-MM-DD-HH_groupId_countableID ... contains date
    # timeframeId = ndb.StringProperty() YYYY-MM-DD-HH needed for cleanup if counter uses ancestors
    countableId = ndb.StringProperty(required=True) # name of counter within group
    groupId = ndb.StringProperty() # counter group (allows single DB query with timeframe prefix inequality)
    count = ndb.Integerproperty() # count per specified timeframe

    @classmethod
    def increment(class, groupID, countableID):
        # increment memcache
        # save hourly to DB (see below)

注意:必须使用groupId和countableId索引来避免查询中出现2个不等式。 (查询groupId / userId和chart / highcount-query的所有可数:具有最高计数的countableId派生groupId / user),使用DB中的祖先可能不支持图表查询。

问题是如何最好地将memcached计数器保存到DB:

  1. cron:示例文档(example front-page counter)中提到了这种方法,但使用了在cron-handler中硬编码的固定计数器ID。由于现有的memcache密钥没有前缀查询,因此确定在上一个时间间隔内在memcache中创建了哪些反向ID并且需要保存可能是瓶颈。
  2. 任务队列:如果创建了计数器,则计划任务以收集它并将其写入DB。 COST :当队列处理程序保存数据时,每个使用的计数器有1个任务队列条目,每个时间粒度有一个ndb.put(例如1小时)。似乎是最有希望的方法,也可以准确捕捉不常见的事件。
  3. 执行增量(id)时,
  4. 不经常:如果新的时间帧开始,请保存前一个。这需要每个增量至少2个memcache访问(获取日期,incr计数器)。一个用于跟踪时间范围,一个用于计数器。缺点:陈旧时间较长的突发计数器可能会丢失缓存。
  5. 在递增(id)执行时不经常:概率:如果随机%100 == 0然后保存到DB,但计数器应该具有均匀分布的计数事件
  6. 当增量(id)执行时,
  7. 不经常:如果计数器达到例如100然后保存到DB
  8. 有没有人解决这个问题,设计这个有什么好办法? 每种方法的弱点和优势是什么? 这里缺少其他方法吗?

    假设:计数可能稍微不准确(缓存丢失),counterID空间很大,counterIDs略有增加(每天一些,每天一些)

    更新:1)我认为cron可以类似于任务队列使用。只需要用memcached = True创建计数器的DB模型,然后在cron中为所有标记的计数器运行查询。成本:1放在第1个增量,在cron查询,1放在更新计数器。如果不完全考虑它,这似乎比任务方法更加昂贵/复杂。

    其他地方讨论:

3 个答案:

答案 0 :(得分:1)

是的,您的#2想法似乎最能满足您的要求。

要实现它,您需要执行具有指定延迟的任务。

我使用deferred.defer()的{​​{1}}参数将the deferred library用于此目的。在此期间,我了解到标准队列库具有类似的支持,通过为Task构造函数指定countdown参数(我还没有使用这种方法)。

因此,每当您创建一个内存缓存计数器时,也会将延迟执行任务(传入其有效负载的计数器的memcache密钥)排入队列,这将是:

  • 使用任务有效负载中的密钥
  • 获取内存缓存计数器值
  • 将值添加到相应的数据库计数器
  • 在db update成功时删除memcache计数器

在任务执行中读取memcache计数器和删除memcache计数器之间,您可能会丢失并发请求的增量。您可以通过在读取后立即删除内存缓存计数器来减少此类丢失,但如果数据库更新因任何原因失败,您将面临丢失整个计数的风险 - 重新尝试该任务将不再找到内存缓存计数器。如果这些都不令人满意,您可以进一步完善解决方案:

延迟任务:

  • 读取内存缓存计数器值
  • 将另一个(事务性)任务(没有延迟)排入队列以将值添加到db计数器
  • 删除内存缓存计数器

非延迟任务现在是幂等的,可以安全地重新尝试直到成功。

并发请求丢失增量的风险仍然存在,但我猜它会更小。

<强>更新

Task Queues优于延迟库,使用taskqueue.add()的可选countdowncountdown参数可以使用延迟功能:

  
      
  • 倒计时 - 此任务应运行或租用的未来秒数。默认为零。如果,请不要指定此参数   你指定了一个eta。

  •   
  • eta - 一个eta,指定任务运行的绝对最早时间。如果,则无法指定此参数   指定了倒计时参数。这个论点可以是时间   区域感知或时区天真,或设置为过去的时间。如果   参数设置为None,默认值为now。对于拉动任务,没有   工人可以在eta指示的时间之前租用任务   参数。

  •   

答案 1 :(得分:0)

在分布式系统中计算内容是一个难题。从App Engine的早期开始,这个问题有一些很好的信息。我从Sharding Counter开始,在2008年写的,仍然是相关的。

答案 2 :(得分:0)

以下是使用每小时时间帧实施任务队列方法的代码。有趣的是,它没有交易和其他互斥魔法。 (为了便于阅读,方法的python缩进是错误的。)

支持priorities for memcache可以提高此解决方案的准确性。

TASK_URL = '/h/statistics/collect/' # Example: '/h/statistics/collect/{counter-id}"?groupId=" + groupId + "&countableId=" + countableId'
MEMCACHE_PREFIX = "StatisticsDB_"

class StatisticsDB(ndb.Model):
"""
Memcached counting saved each hour to DB.
"""
    # key.id() = 2016-01-31-17_groupId_countableId
    countableId = ndb.StringProperty(required=True) # unique name of counter within group
    groupId = ndb.StringProperty() # couter group (allows single DB query for group of counters)
    count = ndb.IntegerProperty(default=0) # count per timeframe


@classmethod
def increment(cls, groupId, countableId):  # throws InvalidTaskNameError
    """
    Increment a counter. countableId is the unique id of the countable
    throws InvalidTaskNameError if ids do not match: [a-zA-Z0-9-_]{1,500}
    """
    # Calculate memcache key and db_key at this time
    # the counting timeframe is 1h, determined by %H, MUST MATCH ETA calculation in _add_task()
    counter_key = datetime.datetime.utcnow().strftime("%Y-%m-%d-%H") + "_" + groupId +"_"+ countableId;
    client = memcache.Client()

    n = client.incr(MEMCACHE_PREFIX + counter_key)
    if n is None:
        cls._add_task(counter_key, groupId, countableId)
        client.incr(MEMCACHE_PREFIX + counter_key, initial_value=0)


@classmethod
def _add_task(cls, counter_key, groupId, countableId):
    taskurl = TASK_URL + counter_key + "?groupId=" + groupId + "&countableId=" + countableId
    now = datetime.datetime.now()
    # the counting timeframe is 1h, determined by counter_key, MUST MATCH ETA calculation
    eta = now + datetime.timedelta(minutes = (61-now.minute)) # at most 1h later, randomized over 1 minute, throttled by queue parameters
    task = taskqueue.Task(url=taskurl, method='GET', name=MEMCACHE_PREFIX + counter_key, eta=eta)
    queue = taskqueue.Queue(name='StatisticsDB')
    try:
        queue.add(task)
    except taskqueue.TaskAlreadyExistsError: # may also occur if 2 increments are done simultaneously
        logging.warning("StatisticsDB TaskAlreadyExistsError lost memcache for %s", counter_key)
    except taskqueue.TombstonedTaskError: # task name is locked for ...
        logging.warning("StatisticsDB TombstonedTaskError some bad guy ran this task premature manually %s", counter_key)


@classmethod
def save2db_task_handler(cls, counter_key, countableId, groupId):
    """
    Save counter from memcache to DB. Idempotent method.
    At the time this executes no more increments to this counter occur.
    """
    dbkey = ndb.Key(StatisticsDB, counter_key)

    n = memcache.get(MEMCACHE_PREFIX + counter_key)        
    if n is None:
        logging.warning("StatisticsDB lost count for %s", counter_key)
        return

    stats = StatisticsDB(key=dbkey, count=n, countableId=countableId, groupId=groupId)
    stats.put()
    memcache.delete(MEMCACHE_PREFIX + counter_key) # delete if put succeeded
    logging.info("StatisticsDB saved %s n = %i", counter_key, n)