本文是《提升你的 Python 项目代码健壮性和性能》系列的第三篇文章。
第三篇,即本文,我们来谈谈 Django 项目并发可能带来的问题以及如何保持 Django 项目的数据一致性。
本文目录如下:
0x00 前言 : section
▼ 0x01 背景知识 : section
并发会带来数据不一致 : section
▼ 0x02 Django 项目如何解决项目 : section
悲观的方式 : section
乐观的方式 : section
没有银弹 : section
0x03 解决超卖问题 : section
▼ 0x04 番外篇 数据库隔离级别 : section
READ-UNCOMMITTED : section
READ-COMMITTED : section
REPEATABLE-READ : section
SERIALIZABLE : section
0xEE 参考链接 : section
本文讨论的场景如下:
一个简单的秒杀系统,商品还剩 200 件。有一些用户来访问并下单。
这个项目的接口的简单写法就是:
@db_transaction
def user_order_by_product(user,product):
if product.quantity > 0:
product.quantity = product.quantity - 1
make_order(user,product)
product.save()
显然,这个写法确实简洁。
但问题就来了。
商品会超卖, 除非你的应用没什么人访问。
为什么呢?
看图
知道问题出在哪儿了吧?
其实是我给读者挖了一个坑,我画的这张图其实是有预设的,比如 NOTE2 处确保了 B 用户读到的是 2。
如果数据库隔离级别是 read uncommitted, NOTE2 处读到的也有可能是 1 ,
本文仅仅针对于 read committed 隔离级别下的 MYSQL / PostgreSQL。
在上图中。
一般人写程序
这就会放大了问题
在低并发量的情况下,这个用户手动不断的下单,下单到 200 的时候,后端准时的报卖完了。
假如我现在是 20 个用户同时在下单,很可能机会出现上图的情况。
出了问题要解决问题 看到问题就要想法子
有乐观的方式和悲观的方式,即所谓的乐观锁和悲观锁。是不是有高下之分呢、? 不一定。
这就好比乐观和悲观本身也并不见的有高下之分。如同有的人乐观,并不见得一定就是乐观,搞不好是傻乐呵,有的人的悲观,只是底色比较悲凉,但内心还是积极向上的。
本小节内容依照自己的理解厚颜无耻的援引了这篇文章的代码
https://medium.com/@hakibenita/how-to-manage-concurrency-in-django-models-b240fed4ee2
悲观的方式就是锁住某个资源,不让其他人使用(排他),直到完成工作后释放。
为什么使用数据库的锁(准确来说是关系型数据库的锁)
@classmethod
def deposit(cls, id, amount):
with transaction.atomic():
account = (
cls.objects
.select_for_update()
.get(id=id)
)
account.balance += amount
account.save()
return account
@classmethod
def withdraw(cls, id, amount):
with transaction.atomic():
account = (
cls.objects
.select_for_update()
.get(id=id)
)
if account.balance < amount:
raise errors.InsufficentFunds()
account.balance -= amount
account.save()
return account
使用 select_for_update 锁住这个 object 直到事务结束
在使用悲观锁的情况下,存钱和取钱流程如下
乐观的方式就是新建一个 version column, 每次修改余额的时候,版本增 1
同样我厚颜无耻的援引了 hakibenita 的代码
def deposit(self, id, amount):
updated = Account.objects.filter(
id=self.id,
version=self.version,
).update(
balance=balance + amount,
version=self.version + 1,
)
return updated > 0
def withdraw(self, id, amount):
if self.balance < amount:
raise errors.InsufficentFunds()
updated = Account.objects.filter(
id=self.id,
version=self.version,
).update(
balance=balance - amount,
version=self.version + 1,
)
return updated > 0
django 默认会返回修改成功的行数,于是,是不是存取成功,就看 updated 是否大于 0 了
计算机世界里面,多快好省的场景就不存在。一切看场景。
同样在并发量大的情况下
1. 如果对某几行修改比较频繁,版本更新频繁,可能乐观锁的 retry 就比较浪费了。
2. 如果是对整张表的更新比较频繁,而不是频繁修改某几行。乐观锁,就比较合适了。
搞个再复杂一点点的,
嗯,就是这么简单。
提到了数据库隔离级别,就利用上面的例子顺手讲解一下数据库隔离级别吧。
这个就不放图了。没啥好讲的。性能太低… 我是基本上没怎么使用过的
性能上 RU > RC > RR > S
一般人用 RC 和 RR 会多一些,比如我的项目里就使用了 RC , 但什么时候我可能会考虑用 RR 呢、? 比如,我想在一个 Session 里面选两次 最近两个月的用户数据,但是并不希望 出现新的用户。
评论区
提出了防止超卖的另一种解决方案
先做订单记录,在直接 sql 改数量,前提库存大于 0,失败一个就事务回滚,抢购失败。
即如下
def do_order(product):
with transaction.atomic():
order = make_order(product)
# 他的思路 是 RawSQL
# updated = sqlexecute(f"update from product set quantity = quantity - 1 where id = {product.id} and quantity > 0 ")
# 在这个基础上,其实可以写出 django orm 对应的语句
updated = Product.objects.filter(
id=id,
quantity_gt=0
).update(
quantity=quantity - 1,
)
if updated == 0:
transaction.rollback()
面向业务设计的表,和面向数据分析的表应该是两种设计思路。orm 是实体和记录的映射,比较适合面向业务设计的表。
也提到表关联查询太痛苦了
在我的认知里
- django orm 写过滤条件是很舒服的。(抛开性能来说)
- 针对 Object 的修改也很方便,比如商品数量减少。比如自增 update。
这也是 orm 的不足之处,显式多表连续 join 的话过滤条件就很麻烦。
如果需要关联查询这也应该看情况
VoteActivity.objects.filter\(category=obj.category, user\_\_name\_contains\("王"\), user\_\_city\_\_type="一线城市"\) # 伪代码
2. 或许是应该走 ETL 或者是把数据丢到 ES 或者针对查询优化的表会更加合适、?
当然,这也就看具体的情况了。