如何保证 Django 项目的数据一致性

如何保证 Django 项目的数据一致性

随谈#macOS

March 8, 20190

本文是《提升你的 Python 项目代码健壮性和性能》系列的第 3 篇文章。

本系列总计 8 篇文章目录

  1. 用 Type Annotation 提升你的 Python 代码健壮性
  2. 如何通过测试提升 Python 代码的健壮性
  3. 如何保证 Django 项目的数据一致性
  4. 这几招,让你快速提升 Python 项目的性能
  5. 为你的项目快速搭建 ELKFA 日志系统
  6. 如何写出整洁的 Python 代码 上
  7. 如何写出整洁的 Python 代码 中
  8. 如何写出整洁的 Python 代码 下

0x00 前言

这次我们来谈谈 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()

显然,这个写法确实简洁。

但问题就来了。

商品会超卖, 除非你的应用没什么人访问。

为什么呢?

0x01 背景知识

并发会带来数据不一致

看图

知道问题出在哪儿了吧?

  • 理解了?
  • 你确定理解了?
  • 你确定你真的理解了?

其实是我给读者挖了一个坑,我画的这张图其实是有预设的,比如 NOTE2 处确保了 B 用户读到的是 2。

如果数据库隔离级别是 read uncommitted, NOTE2 处读到的也有可能是 1 ,

本文仅仅针对于 read committed 隔离级别下的 MYSQL / PostgreSQL

在上图中。

一般人写程序

  1. 往往不是用 Django 的 F 表达式,来实现 update balance = balance - 20 的操作。 update balance = balance - 20 where id = 1
  2. 而是计算 出新的 balance 然后 user.balance = 80, 接着 user.save()

这就会放大了问题

在低并发量的情况下,这个用户手动不断的下单,下单到 200 的时候,后端准时的报卖完了。

假如我现在是 20 个用户同时在下单,很可能机会出现上图的情况。

出了问题要解决问题 看到问题就要想法子

  • 鲁迅说过,一见短袖子,立刻想到白臂膊,立刻想到全 X 体,立刻想到....
  • 我也说过,提到并发,就想到锁,就想到乐观锁,就想到悲观锁。

0x02 Django 项目如何解决项目

有乐观的方式和悲观的方式,即所谓的乐观锁和悲观锁。是不是有高下之分呢、? 不一定。

这就好比乐观和悲观本身也并不见的有高下之分。如同有的人乐观,并不见得一定就是乐观,搞不好是傻乐呵,有的人的悲观,只是底色比较悲凉,但内心还是积极向上的。

本小节内容依照自己的理解厚颜无耻的援引了这篇文章的代码

https://medium.com/@hakibenita/how-to-manage-concurrency-in-django-models-b240fed4ee2

悲观的方式

悲观的方式就是锁住某个资源,不让其他人使用(排他),直到完成工作后释放。

为什么使用数据库的锁(准确来说是关系型数据库的锁)

  1. 数据库非常擅长处理锁来完成数据一致性。
  2. 数据库级别的锁可以保护其他进程修改数据。
@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. 如果是对整张表的更新比较频繁,而不是频繁修改某几行。乐观锁,就比较合适了。

  • 乐观方式在应用层,无法阻拦数据库的操作。不会存在死锁的问题。
  • 悲观方式是数据库实现,他阻止数据库写操作。

0x03 如何解决超卖问题

  1. 把数量和已卖放到 redis 里面呢?
  2. 交给 deamon 呢?
  3. 用 celery 然后排个队异步任务呢?

搞个再复杂一点点的,

  1. 在 Redis 里面直接生成 200 个订单号
  2. 然后用户来一个取走一个订单号码
  3. 通过 Celery 削峰 排队走异步任务
  4. 最后通过数据表的 uniq 约束来防止下单超过 200 个。

嗯,就是这么简单。

0x04 番外篇 数据库隔离级别

提到了数据库隔离级别,就利用上面的例子顺手讲解一下数据库隔离级别吧。

READ-UNCOMMITTED

READ-COMMITTED

REPEATABLE-READ

SERIALIZABLE

这个就不放图了。没啥好讲的。性能太低.... 我是基本上没怎么使用过的

性能上 RU > RC > RR > S

一般人用 RC 和 RR 会多一些,比如我的项目里就使用了 RC , 但什么时候我可能会考虑用 RR 呢、? 比如,我想在一个 Session 里面选两次 最近两个月的用户数据,但是并不希望 出现新的用户。

0x05 番外篇 Django ORM

评论区

@灵魂对撞机

提出了防止超卖的另一种解决方案

# 先做订单记录,在直接 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 是实体和记录的映射,比较适合面向业务设计的表。

@灵魂对撞机

也提到表关联查询太痛苦了

在我的认知里

  1. Django 本身是支持 RawSQL 查询的
  2. 当你想要的 object 和 row 是一一对应关系的时候,ORM 写起来特别舒服
- django orm 写过滤条件是很舒服的。(抛开性能来说)
- 针对 Object 的修改也很方便,比如商品数量减少。比如自增 update。

这也是 orm 的不足之处,显式多表连续 join 的话过滤条件就很麻烦。

如果需要关联查询这也应该看情况

  1. 如果一般的多表连续 join 如果能使用 nested queries 的话,用 Django 写起来也是特别的舒服。(如果你不是使用 mysql 这种对 nested query 几乎无优化的数据库)
VoteActivity.objects.filter\(category=obj.category, user\_\_name\_contains\("王"\), user\_\_city\_\_type="一线城市"\) # 伪代码

2. 或许是应该走 ETL 或者是把数据丢到 ES 或者针对查询优化的表会更加合适、?

当然,这也就看具体的情况了。

0xEE 参考链接

  1. https://www.cnblogs.com/huanongying/p/7021555.html
  2. Django and MySQL
  3. https://medium.com/@hakibenita/how-to-manage-concurrency-in-django-models-b240fed4ee2