原文: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 中提供了對應 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。
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'
參考: