排序实体并过滤 ListProperty 以避免索引爆炸

5 投票
4 回答
787 浏览
提问于 2025-04-16 18:14

我正在开发一个简单的博客/书签平台,想要添加一个类似于delicious的标签浏览/深入探索功能,这样用户就可以通过指定一系列特定的标签来过滤帖子。

大概是这样的效果:

enter image description here

帖子在数据存储中用一个简化的模型表示:

class Post(db.Model):
    title = db.StringProperty(required = True)
    link = db.LinkProperty(required = True)
    description = db.StringProperty(required = True)
    tags = db.ListProperty(str)
    created = db.DateTimeProperty(required = True, auto_now_add = True)

帖子的标签存储在一个ListProperty中。为了获取带有特定标签的帖子列表,Post模型提供了一个静态方法:

@staticmethod
def get_posts(limit, offset, tags_filter = []):
        posts = Post.all()
        for tag in tags_filter:
          if tag:
              posts.filter('tags', tag)
        return posts.fetch(limit = limit, offset = offset)

这个方法运行得不错,虽然我还没有对它进行太多压力测试。

问题出现在我尝试为get_posts方法添加一个“排序”功能,以便结果按"-created"日期排序时:

@staticmethod
def get_posts(limit, offset, tags_filter = []):
        posts = Post.all()
        for tag in tags_filter:
          if tag:
              posts.filter('tags', tag)
        posts.order("-created")
        return posts.fetch(limit = limit, offset = offset)

排序功能会为每个要过滤的标签添加一个索引,这就导致了令人头疼的索引爆炸问题。
还有一个让事情变得更复杂的地方是,get_posts方法还需要提供一些分页机制。

你知道有什么策略、想法、解决方法或者技巧可以解决这个问题吗?

4 个回答

2

这个问题听起来和以下内容很相似:

正如Robert Kluin在最后一个链接中提到的,你也可以考虑使用类似“关系索引”的模式,具体可以参考这份Google I/O演示文稿

# Model definitions
class Article(db.Model):
  title = db.StringProperty()
  content = db.StringProperty()

class TagIndex(db.Model):
  tags = db.StringListProperty()

# Tags are child entities of Articles
article1 = Article(title="foo", content="foo content")
article1.put()
TagIndex(parent=article1, tags=["hop"]).put()

# Get all articles for a given tag
tags = db.GqlQuery("SELECT __key__ FROM Tag where tags = :1", "hop")
keys = (t.parent() for t in tags)
articles = db.get(keys)

根据你期望通过标签查询返回的页面数量,排序可以在内存中进行,也可以通过将日期字符串表示作为Articlekey_name的一部分来实现。

Robert KluinWooble#appengine IRC频道的评论后,更新了StringListProperty和排序的说明。

3

如果你反过来考虑这个关系呢?不是一个帖子有一堆标签,而是一个标签有一堆帖子。

class Tag(db.Model):
  tag = db.StringProperty()
  posts = db.ListProperty(db.Key, indexed=False)

要搜索标签,你可以这样写:tags = Tag.all().filter('tag IN', ['python','blog','async'])

这样你应该能得到三个或更多的标签,每个标签都有一堆使用这个标签的帖子。接下来,你可以用post_union = set(tags[0].posts).intersection(tags[1].posts, tags[2].posts)来找到同时包含所有标签的帖子。

然后你可以获取这些帖子,并按创建时间排序(我觉得是这样)。Posts.all().filter('__key__ IN', post_union).order("-created")

注意:这段代码是我随便想的,我不太记得是否可以这样操作集合。

补充:@Yasser 提到你只能对少于30个项目做IN查询。

你可以让每个帖子的键名以创建时间开头。这样你就可以对第一次查询得到的键进行排序,然后直接用Posts.get(sorted_posts)来获取帖子。

我不知道这样在有数百万个帖子和/或标签的系统中会表现如何。

补充2:我说的是集合交集,不是并集。

3

涉及键的查询使用索引,就像涉及属性的查询一样。键的查询在某些情况下需要自定义索引,这和属性的情况类似,但有几个例外:如果是使用不等式过滤器或者对进行升序排序,则不需要自定义索引;但如果是对Entity.KEY_RESERVED_PROPERTY_key_进行降序排序,则需要。

所以,建议使用可排序的日期字符串作为实体的主键:

class Post(db.Model):
    title = db.StringProperty(required = True)
    link = db.LinkProperty(required = True)
    description = db.StringProperty(required = True)
    tags = db.ListProperty(str)
    created = db.DateTimeProperty(required = True, auto_now_add = True)

    @classmethod
    def create(*args, **kw):
         kw.update(dict(key_name=inverse_millisecond_str() + disambig_chars()))
         return Post(*args, **kw)

...

def inverse_microsecond_str(): #gives string of 8 characters from ascii 23 to 'z' which sorts in reverse temporal order
    t = datetime.datetime.now()
    inv_us = int(1e16 - (time.mktime(t.timetuple()) * 1e6 + t.microsecond)) #no y2k for >100 yrs
    base_100_chars = []
    while inv_us:
        digit, inv_us = inv_us % 100, inv_us / 100
        base_100_str = [chr(23 + digit)] + base_100_chars
    return "".join(base_100_chars)

现在,你甚至不需要在查询中包含排序顺序,虽然明确按键排序也没坏处。

需要记住的事项:

  • 除非你在这里为所有的帖子使用“create”,否则这个方法是行不通的。
  • 你需要迁移旧数据。
  • 不允许有祖先。
  • 每个索引只存储一次键,所以保持键的简短是值得的;这就是我上面使用基数100编码的原因。
  • 这个方法并不是100%可靠,因为可能会出现键冲突。上面的代码,如果没有disambig_chars,理论上在交易之间的微秒数提供了可靠性,所以如果在高峰期每秒有10个帖子,它的失败率是1/100,000。不过,我会考虑到应用引擎时钟的滴答问题,实际上我只会信任到1/1000。如果这还不够好,可以添加disambig_chars;如果你需要100%可靠性,那你可能不应该使用应用引擎,但我想你可以在保存时加入处理键冲突的逻辑。

撰写回答