排序实体并过滤 ListProperty 以避免索引爆炸
我正在开发一个简单的博客/书签平台,想要添加一个类似于delicious的标签浏览/深入探索功能,这样用户就可以通过指定一系列特定的标签来过滤帖子。
大概是这样的效果:

帖子在数据存储中用一个简化的模型表示:
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 个回答
这个问题听起来和以下内容很相似:
- 关于在Google App Engine上为博客标签系统的数据建模建议
- 为Google App Engine博客应用程序映射数据:
- 在App Engine Python(Bigtable)中的父子关系
正如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)
根据你期望通过标签查询返回的页面数量,排序可以在内存中进行,也可以通过将日期字符串表示作为Article
的key_name
的一部分来实现。
在Robert Kluin和Wooble在#appengine
IRC频道的评论后,更新了StringListProperty
和排序的说明。
如果你反过来考虑这个关系呢?不是一个帖子有一堆标签,而是一个标签有一堆帖子。
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:我说的是集合交集,不是并集。
涉及键的查询使用索引,就像涉及属性的查询一样。键的查询在某些情况下需要自定义索引,这和属性的情况类似,但有几个例外:如果是使用不等式过滤器或者对键进行升序排序,则不需要自定义索引;但如果是对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%可靠性,那你可能不应该使用应用引擎,但我想你可以在保存时加入处理键冲突的逻辑。