ichuan.net

自信打不死的心态活到老

django-mysql 中的金钱计算事务处理

末尾有更新:20140604 更新

问题

在类银行系统中,涉及金钱计算的地方,不能使用 float 类型,因为:

# 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 类型,用以人类期望的方式处理此类计算。

django 中的 DecimalField

django 中提供了对应 Decimal 类型的 DecimalField 字段:

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

max_digits 表示最大位数,decimal_places 表示小数点后位数。假如你的系统里最多存 9999 元人民币,人民币小数点后可以精确到 2 位,需要的 max_digits 就是 6,decimal_places 就是 2。

假如使用了 MySQl backend, MySQL 中也支持 DECIMAL 类型,django 自动处理类型转换。

示例

下面以一个 django app 为例演示。

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:

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

计划.IN 开源

如同之前承诺的那样,现在将这个项目开源了,欢迎 fork:

https://github.com/ichuan/jihua

jihua.in 这个网站会一直运行,我本身就一直在用。

有个 mm 图在 doc/design.mm,是那晚简单做的一个设计。其他介绍就不写了,都在 github 那里。

计划.IN 上线

最近一周都没写博客,因为我在做一个网站。现在基本成型了,于是放出来,欢迎使用。

计划.IN —— 简单有效的工作计划表

从上周五买域名开始,到刚才把网站部署上去,总共花去了我 10 天的“空闲”时间。平时要上班,哪有那么多空闲时间啊。嗯,我基本都是在熬夜写代码的。10 天里熬夜了几天都记不清了,每次都是最早凌晨 5 点睡,那时睡的话要设 4 个间隔 10 分钟的闹钟,以防上班迟到。期间有天要出差,早上 7 点 50 的火车,那天就没睡,在火车上补的觉。

这么疯狂的原因一个是这是我喜欢的事,自然有无穷的动力去做;另一个也是做这个网站的初衷:我想确信自己还没老去,能力还没消失,我还不是个废人。

这期间又开始喝咖啡了。有些熬夜的晚上,抬起头会有奇异的感觉。后来也看到某百度员工猝死云云,甚惊恐,怕自己哪个晚上也会不小心驾鹤西去,一命呜呼。自毕业后我已有好久没通宵过了,之前玩游戏时偶尔通宵一次也会累个半死,第二天下午睡醒后有世界末日般的感觉。这次还好。

说正题。计划.IN(我这么叫它)其实就是个 todo list + 备忘录工具,具体的介绍可以看帮助页面,刚写的,还热乎着。开发过程可以简单概括为:买域名;用 freemind 列需求和设计;用 mockups 画原型图;实现 ui;实现后台逻辑。

前端 ui 实现占用了我 80% 的开发时间。因为我对细节有洁癖,而且自认有些审美能力,所以一直在细调。logo 是照着 twitter 的用 ps 画的;首页的预览图也是截图后用 ps 旋转得到的。js 代码局部重构了好几次,因为我要的既能运作又看起来漂亮的代码。为了想实现一种动画效果,把 css3 的 transform 尝试了遍,最后以失败告终。前端用的 twitter 的 bootstrap css 框架和核武器 backbone.js MVC 框架,后者的源码也几乎被我看完了。

后台逻辑实现比较简单,主要是一个 RESTful 接口。这个项目里我写的 python 代码远没有写的 js 代码多。现在发现 django 太庞大了,这次又学到很多以前没用过的功能。

因为比较关心数据安全性,于是申请了 StartSSL 的免费 SSL 证书,折腾一会给装上了。

促成我开始做这个站的最初原因是徐东同学说他有 todo list 的需求,然后我想起了我荒废很久的 MyPDC2.0 计划,其中就包含一个类似 计划.IN 的记事功能。于是先把这个实现了,要等想法都成熟才开始干是不明智的。

还有些没实现的功能,但我等不下去了,先让功能可用的它上线吧,我得休息下了。

djblog开源

刚把 djblog——也就是我这个blog用的程序——放到 github 上去了:https://github.com/ichuan/djblog

写这个程序没花多少时间,主要是方便自己用,功能也很少,但够用。

下面贴的是 README

介绍

一个基于 django 开发的 blog 程序,特性包括:

  1. HTML5
  2. 手持设备访问优化
  3. Disqus 评论系统集成
  4. Markdown 语法写作
  5. 标签
  6. 单页面
  7. 友情链接
  8. Google Analytics 集成
  9. 主题
  10. Google 自定义搜索

依赖

  1. markdown: https://github.com/waylan/Python-Markdown/
  2. django >= 1.3

安装步骤

  1. 创建数据库,例如 mysql 数据库:$ mysql -u root -proot -e 'create database blog'
  2. 拷贝 local_settings.default.pylocal_settings.py,修改里面的数据库配置。还有其他配置可以改
  3. 执行 python manage.py syncdb 同步数据库
  4. 执行 python manage.py runserver 0.0.0.0:8000 启动临时服务器
  5. 浏览器访问 http://localhost:8000,后台地址是 http://localhost:8000/admin/,口令在第 3 步时创建

自定义主题

主题放在 templates/ 目录下,一个目录是一个主题,classic 为默认主题,可参考这个制作新主题。

用uWSGI替代fastcgi部署django应用

今日有同事推荐我用 uWSGI 来部署 django 应用,因为它比 fastcgi 方式快很多。刚才实验完毕,现在这个blog和 911.im 已经使用上 uWSGI 了。

对比测试

我用的 webbench 来测试 fastcgi 和 uwsgi 两种方式的效率,下面是换 uwsgi 之前的数据:

$ webbench -c 500 -t 30 http://ichuan.net/archives/
Webbench - Simple Web Benchmark 1.5
Copyright (c) Radim Kolar 1997-2004, GPL Open Source Software.

Benchmarking: GET http://ichuan.net/archives/
500 clients, running 30 sec.

Speed=12220 pages/min, 1245829 bytes/sec.
Requests: 6110 susceed, 0 failed.

换 uwsgi 后:

$ webbench -c 500 -t 30 http://ichuan.net/archives/
Webbench - Simple Web Benchmark 1.5
Copyright (c) Radim Kolar 1997-2004, GPL Open Source Software.

Benchmarking: GET http://ichuan.net/archives/
500 clients, running 30 sec.

Speed=42470 pages/min, 2778475 bytes/sec.
Requests: 21235 susceed, 0 failed.

可以看到换 uwsgi 后能处理的页面数是换之前的 3.5 倍左右。这个数据在我本地虚拟机上是 30 倍。

部署过程

首先下载编译 uwsgi:http://projects.unbit.it/downloads/uwsgi-0.9.9.1.tar.gz,我是 ubuntu 系统,需要额外装个 libxml2-dev 库:sudo apt-get install libxml2-dev

编译完后将得到的 uwsgi 复制到系统目录:sudo cp uwsgi /usr/sbin/

启动 uwsgi:

uwsgi -s /tmp/uwsgi.sock -C -M -p 4 -t 30 --limit-as 128 -R 10000 --vhost -d /tmp/uwsgi.log --pidfile /tmp/uwsgi.pid --pythonpath /var/www

表示用 unix socket 方式执行 uwsgi,-C 表示将 /tmp/uwsgi.sock 文件权限改成 666 以便 nginx 可以读取,-M 表示启动管理进程,-p 4 表示预生成 4 个 worker 子进程,-t 30 是 cgi 程序超时,--limit-as 128 表示限制内存最大 128M,-R 10000 表示每个 worker 处理的最大请求数,--vhost 表示启用虚拟服务器,-d /tmp/uwsgi.log 表示以守护进程方式启动,指定日志文件。

这个命令可以放入 /etc/rc.local 作为开机自启动。

这样启动的 uwsgi 可以被多网站共用,nginx 的 server 块配置如下:

location / {
    uwsgi_pass  unix:///tmp/uwsgi.sock;
    uwsgi_param UWSGI_CHDIR  /var/www/djblog;
    uwsgi_param UWSGI_SCRIPT wsgi_app;
    include     uwsgi_params;
}

上面的配置的前提是,你的 django 工程在 /var/www/djblog,并且在该目录下有个 wsgi_app.py 文件。文件内容如下:

import os
os.environ['DJANGO_SETTINGS_MODULE'] = 'djblog.settings'
import django.core.handlers.wsgi
application = django.core.handlers.wsgi.WSGIHandler()

如此配置好后,重启 nginx 就可以了。

如果要多加一个网站,只需在 nginx 配置中新加个 server 块,改变下 UWSGI_CHDIR 配置即可。

如果要让 uwsgi 重新载入,可以执行:

kill -SIGHUP `cat /tmp/uwsgi.pid`

如果要让停止 uwsgi,可以给它的 master 进程发送 SIGINT 信号:

kill -SIGINT `cat /tmp/uwsgi.pid`

uwsgi 的更多配置在此