django-mysql 中的金錢計算事務處理 分類: 小技巧 python學習 mysql 2015-07-27 16:52 10人閱讀 評論(0) 收藏

原文:http://ichuan.net/post/60/django-mysql-decimal-transaction/
html


問題

在類銀行系統中,涉及金錢計算的地方,不能使用 float 類型,由於:python

# python 中
>>> 0.1 + 0.2 - 0.3
5.551115123125783e-17
>>> 0.1 + 0.2 - 0.3 == 0.3
False

// js 中
> 1.13 * 10000
11299.999999999998
> 1.13 * 10000 == 11300
false

因此 python 中提供了 Decimal 類型,用以人類指望的方式處理此類計算。mysql

django 中的 DecimalField

django 中提供了對應 Decimal 類型的 DecimalField 字段:git

class DecimalField(max_digits=None, decimal_places=None[, **options])

max_digits 表示最大位數,decimal_places 表示小數點後位數。假如你的系統裏最多存 9999 元人民幣,人民幣小數點後能夠精確到 2 位,須要的 max_digits 就是 6,decimal_places 就是 2。sql

假如使用了 MySQl backend, MySQL 中也支持 DECIMAL 類型,django 自動處理類型轉換。數據庫

示例

下面以一個 django app 爲例演示。django

models 定義:多線程

from django.db import models

class MyModel(models.Model):
    price = models.DecimalField(max_digits=16, decimal_places=2, default=0)

先建立一個 object 試試:併發

from decimal import Decimal
obj = MyModel.objects.create(price=Decimal('123.45'))

而後更新。假設商品降價,咱們須要把 price 減去 9.99:app

obj.price -= Decimal('9.99')
obj.save()

來看看具體執行的 SQL 是什麼:

from django.db import connection
print connection.queries[-1]

輸出爲:

UPDATE `hello_mymodel` SET `price` = '113.46' WHERE `id` = 1

這種 update 會有個問題。在併發很高時,會遇到相似多線程的問題,由於加減操做都在客戶端,某個線程寫入 price 時可能以前拿到的已經被別人更新過了,因此須要原子寫入。SQL 表述爲:

UPDATE `hello_mymodel` SET `price` = 'price' - '9.99' WHERE `id` = 1

這種方式在 django 中能夠用 F() 表達式來實現:

obj.price = F('price') - Decimal('9.99')
obj.save(update_fields=['price'])

另外,在調用 .save() 時,能夠用 update_fields 傳入須要 update 的字段(如上)。否則 django 可能會把全部字段都放在 SQL 中。

貌似很完美。但假如減去的 Decimal 和字段的精確度不一致呢?

obj.price = F('price') - Decimal('9.999')
obj.save(update_fields=['price'])

price 字段精確到小數點後 2 位,給它減去了 3 位的一個數,會報異常:

Traceback (most recent call last):
  File "<console>", line 1, in <module>
  ...
  File "/home/yc/envs/hello/local/lib/python2.7/sitein _warning_check
    warn(w[-1], self.Warning, 3)
Warning: Data truncated for column 'price' at row 1

這是 MySQL 報了一個 warning,django 所以報了異常。更新操做未成功。

解決方法很簡單,在入庫前,咱們手動轉換下精確度:

def to_decimal(s, precision=8):
    '''
    to_decimal('1.2345', 2) => Decimal('1.23')
    to_decimal(1.2345, 2) => Decimal('1.23')
    to_decimal(Decimal('1.2345'), 2) => Decimal('1.23')
    '''
    r = pow(10, precision)
    v = s if type(s) is Decimal else Decimal(str(s))
    try:
        return Decimal(int(v * r)) / r
    except:
        return Decimal(s)

obj.price = F('price') - to_decimal('9.999', 2)
obj.save(update_fields=['price'])

OK. 但對於只有 MySQL 在計算時才知道精確度多少的呢?好比乘積:

obj.price = F('price') * Decimal('9.99')
obj.save(update_fields=['price'])

這種仍是會報上面的異常,咱們無法在 django 層面對結果作類型轉換。這種類型怎麼辦?只有上 raw sql 了。

另外,事務處理在 django 中能夠用 transaction.atomic() 來作。事務的意思是代碼塊中的數據庫操做要麼都成功執行,沒有異常;要麼都不執行。實際操做中,用事務+原子操做配合可實現正確的金錢操做邏輯。

raw sql 加上事務,上面的例子改成:

from django.db import transaction, connection
try:
    with transaction.atomic():
        cursor = connection.cursor()
        cursor.execute(
            'UPDATE `hello_mymodel` SET `price` = CAST((`price` * %s) AS DECIMAL(16, 2)) WHERE `id` = %s',
            [Decimal('9.999'), obj.id]
        )
except:
    print 'save failed'

咱們在 MySQL 層面用 CAST(%s AS DECIMAL(16, 2)) 來把結果轉爲和 price 字段一樣格式的 Decimal 類型。

這種作法應該很強健了。最後還有一點,可能會有多個操做同時進行,實際應用中,減法操做咱們可能但願在事務中檢驗 price 被更新後要大於 0, 這個最好也能在 MySQL 層面作,把責任推給它。上面的例子改成:

try:
    with transaction.atomic():
        cursor = connection.cursor()
        ret = cursor.execute(
            'UPDATE `hello_mymodel` SET `price` = `price` - CAST(%s AS DECIMAL(16, 2)) WHERE `id` = %s AND `price` >= CAST(%s AS DECIMAL(16, 2))',
            [Decimal('9999.999'), obj.id, Decimal('9999.999')]
        )
        assert ret
except:
    print 'save failed'

ret 是更新的行數,假如正確更新了,ret 就是 1。

總結

金錢運算用 Decimal 類型;django 中字段間操做用 F();F() 配合 Decimal 計算時,結果類型和字段類型徹底一致的沒問題,不可預測的用 raw sql。

20140604 更新

Decimal 類型在 MySQL 中運算仍是有問題,即使結果類型和字段類型徹底一致仍是可能出問題(見 http://stackoverflow.com/q/23925271/265989 )。

django 中有 select_for_update(), 因此金錢操做時最好不要用自增自減運算,而是用 select_for_update() 的行級鎖來避免衝突。

這樣最後一個例子能夠改成:

try:
    with transaction.atomic():
        locked_obj = MyModel.objects.select_for_update().get(pk=obj.id)
        locked_obj.price -= to_decimal('9999.999', 2)
        assert locked_obj.price >= 0
        locked_obj.save(update_fields=['price'])
except:
    print 'save failed'

參考:

  1. https://code.djangoproject.com/ticket/13666
相關文章
相關標籤/搜索