取得原谅比获得许可容易

Posted by FridayLi on December 17, 2017

取得原谅比获得许可容易——EAFP(easier to ask for forgiveness than permission)。这是一种常见的Python 编程风格, 先假定存在有效的键或属性, 如果假定不成立, 那么捕获异常。 这种风格简单明快, 特点是代码中有很多try和except语句。这种风格的对立面是三思而后行——LBYL(look before you leap)

EAFP和LBYL

例如, 我们要从数据库中查找一位名字为friday的person, 用LBYL风格是:

1
2
3
4
5
6
from model import People

if People.objects.filter(name='friday').exists():
    person = People.objects.get(name='firday')
else:
	return 

很显然, 上面的代码对与每一次常规的查询(person存在)都要执行两条数据库IO操作。

如果换成EAFP风格:

1
2
3
4
5
6
from model import People

try:
	person = People.objects.get(name='friday')
except People.DoesNotExist:
	return 

觉得干净利落了很多, 而且对于没有异常的情况只执行了一条SQL语句, 节省了时间。

另外, 在并发性安全上来讲, EAFP也更加安全, 在LBYL风格的代码中,如果在if判断语句返回结果后, 其它进程对数据库做了操作, 这时候执行下一句的get操作就会抛出异常。

Mysql 中的CAS操作

很多业务下我们会用到对数据库(如myslq)的CAS(check and set) 操作。 比如, 消费者A在网上买B的商品(价值1元), 支付后, 需要在数据库里给B加钱。最先想到的做法是:

1
2
3
4
5
6
7
8
9
10
from model import People

try:
	person = People.objects.get(name='B')
except People.DoesNotExist:
	return 
	
balance = person.balance + 1
person.balance = balance
person.save()

上面的逻辑很简单, 先从数据库里找到B用户, 查看B的余额, 加一元, 然后存到数据库。

但这种做法在并发下会出现问题, 如果在A支付的同时, C也在给B支付, A和C对应的后台操作同时查到B的余额为100, 加一元然后保存的话,那么B的最终余额是101,而本该是102的。

解决这个并发问题常规做法是对数据库锁表, 在对某个字段进行操作时不允许其他进程访问该字段。也就是顺序访问,从而达到并发安全。在django的ORM里可以用select_for_update 来实现这个功能。

1
2
3
4
5
6
7
8
9
10
11
from django.db import transaction
from model import People
with transaction.atomic():
	try:
		person = People.objects.select_for_update().get(name='B')
	except People.DoesNotExist:
		return 
	
	balance = person.balance + 1
	person.balance = balance
	person.save()

其实, Mysql对于数字是支持原位操作的,可以在一条sql语句中根据数据库中原值的大小来增减。对应于django的ORM来说可以这样做:

1
2
3
4
5
from django.db.models import F
from model import People

num = People.objects.filter(name='B').update(balance=F('balance') + 1)
assert num == 1

使用神奇的F函数, 可以在一条SQL语句中对某个字段的值进行原位更新, 返回被更新了多少条数据。比上面的select_for_update 性能要高, 但这种方法一般只能用于简单的操作, 复杂的话还是需要用到select_for_update 其实, 转化为mysql的语句应该为: update People set balance = balance + 1 where name == B

对于减操作, 可能需要判断剩余金额是否足够:

1
2
3
num = People.objects.filter(name='B', balance>=1).update(balance=F('balance') + 1)
if num == 0:
	return '余额不足'

Redis 中的CAS

同样的操作放在redis中是怎么样的呢? 首先, redis中有incrdecr 这样的原位加减操作, 非常方便。

1
redis.incr('balance', 1)

既高效又安全。 但, 如果是减操作, 如何在减之前保证余额大于要减的金额而且要保证并发安全呢? 可以用到redis提供的watch 和 pipeline 操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    with redis_cache.pipeline() as pipe:
        while True
            try:
                pipe.watch(balance)
                balance = pipe.get(balance)
                balance = int(balance)
                if balance < 1:
                    return '余额不足'
                pipe.multi()
                pipe.set(balance, balance - 1)
                pipe.execute()
                break  # 成功
            except redis.WatchError:
                continue

pipe.multi() 操作保证了在执行pipe.execute() 之前的语句封装在一起, 保证了操作的原子性, 也就是这里面的语句会从第一条按顺序执行到最后一条而中间不会被其它进程的redis操作插入。 而pipe.watch(balance) 的作用则是在整个pipiline 未执行时, 如果watch的字段balance在redis中被改变了, 那么会抛出watchError异常, 也就是不会执行到pipe.execute(), 保证了balance的并发安全。

当然, 具体业务下, 如果要提升redis操作的性能,在保证balance安全的前提下, 其实可以变通下业务来达到一个高的QPS。 比如, 对于抢红包的操作可以只用incr操作(比完整的cas要快很多)

1
2
3
4
5
6
7
balance_left = redis.decr(balance, amount)
if balance_left >= 0:  # 减了之后还大于等于0说明余额充足, 领取成功
	return amount   # 返回领取的钱包金额
elif balance_left > -amount:  说明领红包之前剩余金额是大于零的
	return amount + balance_left  # 剩下的钱全部领走
else:
	return '余额不足'  # 领之前余额就小于等于领了

总结

其实Mysql的select_for_update 和 redis的watch虽然在cas操作上保证了并发安全, 但因为锁的引入会导致QPS降低, 所以, 对于一些简单的数值加减操作,还是建议采用mysql的update和redis的incr、decr等操作。