如何在GAE数据库里实现对模型属性的唯一约束

标签:Google App Engine, Python

之前也曾提到过唯一索引这个问题,今天再次记录一些想法。

在关系数据库里,唯一约束可以用主键或唯一索引来做到;而在bigtable里,唯一约束只能用key来做到,为什么不提供唯一索引呢?
实际上bigtable主要是解决数据的可靠性和扩展性问题,而将易用性和功能性稍微降低了。
先考虑可靠性:如果遇到突发性故障,比如系统崩溃、机器掉电、硬件故障等,而我只有一台数据库,或者数据是存储在内存中,那么当前未完成的事务就会有一定风险丢失数据——因此必须让数据在多台服务器里都有备份,且必须保存到持久性介质上(如硬盘)。
再考虑扩展性:如果我的用户只有1000人,我可以用1台服务器来完成服务;如果我的用户上升到1百万,我可能需要几十台服务器来完成服务;如果我的用户上升到10亿,我可能需要几千台服务器来完成服务。但无论如何,如果我增加服务器可以让用户感受到高效的服务,那么就是可行的方案。
将这2点需求总结一下,我的服务器必须是分布式的,对用户请求的处理必须是并行的(这样才能保证随着用户增长,增加服务器就不会受到性能限制)。
假设我有一个资源需要加锁,而同时有很多用户请求该资源,那么这些请求就必须串行化。而串行化会带来什么限制呢?磁盘的寻道时间是10ms,这代表着1秒最多寻道100次,最多进行100次写操作,最多100个对同一资源进行串行修改的请求——就算我增加磁盘数和服务器数量,这个限制也无法突破。
因此关键点就是将加锁的资源最小化,让它不会同时被多个用户请求。而一个用户与自己发生竞争这种事是很少存在的,所以可以忽略不计,于是只需要将加锁的资源只针对一个用户即可达到这个需求。

说了这么多,它和唯一索引有什么关系呢?
试想我有个User表,它有id和name这2个字段,其中id是主键,name是唯一的。当用户需要修改用户名时(假设从'a'改成'b'),数据库需要对name对应的唯一索引上锁,保证其他用户不会同时改成相同的name。
此处就要注意了,索引本身也是资源,它是存在于硬盘中的,并且改写它是有硬件限制的。
假如我对整个唯一索引上锁,也就是所有访问name列的请求都被阻挡,那么由于整个表是可以被多个用户请求的,这些请求就必须被串行化,而受到磁盘的硬件限制。
而如果我只对唯一索引上name='a'和name='b'这2行进行加锁(注意name='b'的行可能不存在,但也要求能被虚拟地加锁),其余用户只要不是改名为'a'或'b',那么请求就可以并行执行。只对某几行进行加锁,这种行为在某些关系型数据库上可以做到;但与此同时还得处理分布式的问题,如何保证每台服务器都正确和及时地被加锁了…

不过我并不想考虑这些复杂的关系数据库的实现,于是回到datastore。
前面的观点很明显了,我必须只对name='a'和name='b'这2行进行加锁,这在GAE里怎么办到呢?

最初我想到的是用memcache来配合实现。因为name='a'的实体存在,所以我只要放在事务中处理,就会自动加锁了;而只要在memcache里标记name='b'正在被使用,其他访问者一旦获取到这个标记,就会放弃进行这个事务了。
实现代码如下:
if not user or user.name != name: # 改变用户名才进行设置
  def rename(id, name):
    from random import random
    rand = random()
    if memcache.add(name, rand, time=30, namespace='username'): # 防止同时更改
      user = User.get_by_key_name(id)
      if not user:
        user = User(key_name=id, name=name)
      else:
        user.name = name
      user.put()
      if memcache.get(name, namespace='username') != rand: # 不等代表在set生效前有人更改过了
        raise Rollback
      else:
        memcache.delete(name, namespace='username') # 实际上不删除也无所谓,因为30秒自动删除,也很少有人连续改名
        return name
    else:
      return ''
  name = db.run_in_transaction(rename, id, name) if not User.all().filter('name =', name).count(1) else ''
  if name:
    # 更改成功
  else:
    # 更改失败
这种办法确实可行,因为memcache是事务性的,而且如果内存带宽不够,可以通过增加并行的内存数目来解决。
此外,delete还有个seconds参数,所以可以先添加,添加成功再删除,并设置30秒内不能添加,来阻挡其他请求。不过文档里并没给出实现机制,如果发生故障,能否保证30秒内一定不会被添加。

而如果不依赖memcache的话,我如何同时对name='a'和name='b'加锁呢?
答案是没有必要,因为我根本不是在用关系型数据库!我只要像memcache里做的一样,先申请将name='b'加锁,然后再更改用户名,这2步根本无需在一个事务里完成。
要完成这个任务,很明显我需要一个Name模型,其主键本身用于存储姓名,以保证不会出现重复。
实现如下:
class User(db.Model):
	# id
	# name reference
	def getName(self):
		name = self.name.order('-time').get()
		if name:
			return name.Key().name()
		return None
		
	def removeOldNames(self):
		oldNames = self.name.order('-time').fetch(999, offset=1)
		if oldNames:
			db.delete(oldNames)

class Name(db.Model):
	# key_name
	user = db.ReferenceProperty(required=True)
	time = db.DateTimeProperty(required=True, auto_now_add=True)

def rename(name, user):
	if Name.get_by_key_name(name):
		return None
	else:
		return Name(key_name=name, user=user).put()

newName = db.run_in_transaction(rename, name, user)

if newName:
	try:
		user.removeOldNames()
	except:
		pass
	# 更改成功
else:
	# 更改失败
注意我没有将Name与User放入一个实体组,而是直接使用引用。因为一旦放入实体组,get_by_key_name就要提供祖先路径,name在各个实体组之间就可以不唯一了。

此外还得注意一点:user.removeOldNames()这个函数是在事务之外运行的,有可能改名事务成功了,但删除并没成功,而我直接pass掉了。
这会导致什么后果呢?
显然'a'这个名字没有被删除,于是所有人都不能再叫'a'了。不过这好办,只要用计划任务或任务队列不断重试,总会将多余的名字删掉的;即便这之前没人能叫'a',影响也不大。但还得注意一下,任务队列得在事务成功执行完前调用,否则万一事务执行成功后马上系统崩溃了,任务队列就永远不会被调用;而如果是用计划任务,我是不知道用户id的,于是可以每10分钟、1小时、1天找出所有这段时间内更改过名字的用户,对他们遍历执行removeOldNames即可(如果改名的人太多,还得分成几个任务来执行,所以直接用任务队列更为方便)。
而另一方面,我查找用户名为'b'的用户,可以找到该用户;而查找'a'也能找到他,这就不应该了,于是需要改改查询函数:
# User类的类方法
@classmethod
def getUserByName(cls, name):
	user = Name.get_by_key_name('b').user
	return user if name == user.getName() else None

似乎达到目的了,可是好像还缺了什么。对了,我们假设了时间不会重合。而如果用户同时通过2个浏览器分别改名为'b'和'c',而数据库也同时接受了这个更改,会发生什么事呢?
在添加order('-time')这个排序时,如果2行的时间相同,那么返回这2行的顺序在文档中没有定义。不过索引的顺序应该是不会变的,所以可以假设多次查询都返回相同的顺序,因此user.getName()是安全的。当然,如果你觉得这样很冒险的话,将time属性改成'时间|UUID'格式的字符串就能避免。
而在执行user.removeOldNames()时,时间是否相同根本就无需考虑,只要删除了过期的名字,保证有一个最新的名字就行了。

现在来比较一下这2种方案:
前者一切都在一个事务函数里执行,显得更为方便和安全;但同时也依赖于memcache服务,如果这个服务变得不可靠,事务就不能正确执行。
后者分离成了2个事务,必须手动去定制各个事务的执行顺序,并确保整个事务链的完成(roll forward)。
此外,假如我要求更改用户名后,需要发送给所有关注(follow)该用户的人一封站内短信,告知他们这一情况,我该怎么做?显然我需要一个短信模型,它有发信人、收信人(或收信人列表)、短信内容和发信时间这几个属性。
先考虑前者:因为短信还不存在,所以我必须在事务内创建,于是我的短信和用户必须处于同一个实体组,而这就让这个实体组被多个用户共同访问了。假设该用户有100万关注者,我必须在一个事务中创建100万个短信实体或200个包含5000个收信人条目的短信实体,这对目前的GAE服务器来说是做不到的(但增加服务器仍能做到)。重点是短信允许被删除,如果他们同时要删除这条短信,那么就遇到问题了:删除请求是每个用户分别发送的,必须为每个请求分别创建一个事务,而这些事务都要求访问同一个实体组,便只能串行化访问了。
再考虑后者:发送短信这个要求不是一个必须立即达到的要求,只要最终完成即可。所以我在改名完成后,用任务队列尝试发送即可。而由于这些短信实体不处于同一个实体组,所以创建和删除都是可以并行执行的。
可见在扩展方面,后者更为有效,这也是分布式事务里最核心的思想。

最后总结一下:
GAE的数据库是很注重扩展性的,而实现业务逻辑时,应该尽量避免对跨用户的实体或实体组进行写操作,否则这些写操作就会受到扩展性限制。(最常见的例子就是计数器,它是所有用户共享的,而又经常需要改写。)
要做到这点,一方面是降低写的频率,保证每秒内不会有多次写操作(例如使用memcache、任务队列);另一方面是将其分离,不要让所有用户都改写同一个实体或实体组(例如使用共享计数器)。
而本文采取的第2种方法就是分离,不要让大的实体组限制了自己,很多时候并不需要一次执行一个完整的事务,只需要分成几个事务依次执行即可。这样各个事务中用到的实体可以不在同一个实体组,也就避免了对跨用户的实体组进行写操作。

3条评论 你不来一发么↓ 顺序排列 倒序排列

    向下滚动可载入更多评论,或者点这里禁止自动加载

    想说点什么呢?