记我的小网站发现的Bug之一 —— 某用户签到了两次
1.故事背景
今天上午我忙完手中的事情之后突然想起来我还没签到,于是赶紧打开签到页面,刚点击了签到按钮,提示“签到成功,获得25阅读额度!”,正准备退出浏览器,忽然发现签到列表有异常,居然有用户有两条签到记录!!!
难道我的代码又出bug了???不可能!!!
2.查找问题
不过保险起见,还是去检查了一下代码。
代码如下:
@app.route('/api/sign', methods=['post']) @is_authenticated def api_sign(): id = current_user.id if current_user.is_sign: return jsonify({'status':0,'message':'今日已签到,请明天8点再来签到'}) else: pass
我在用户信息上放了一个is_sign
字段表示当天该用户是否有签到,然后在每天8点的时候通过linux的定时任务更新所有用户的这个字段为false
,在用户签到的时候,会首先检查这个字段,如果为false
就会执行签到逻辑,然后会把这个字段更新为true
,我感觉这个逻辑应该没啥问题。
一时陷入僵局
遂决定先去查查nginx的log,看看请求信息,费了九牛二虎之力,终于把日志文件下载了下来,阿里云1m小水管可太慢了,然后因为前两天分了站点来归档log,忘了做日志切割,整个日志文件有17m之巨,压缩完也下了好久。
根据此用户签到时间,找到了当时的请求记录
通过日志,可以看到连续post了三条,不知道是因为浏览器卡了还是因为这个用户有点意思,先不去纠结这些细枝末节,解决问题更重要。
3.确定问题
看到这个日志我大概明白了,应该是并发没有加锁背锅。
写点代码测试一下,python有个并发库叫grequests
,就拿这个测测
import grequests import requests if __name__ == '__main__': urls=[ 'http://192.168.48.129/api/sign', 'http://192.168.48.129/api/sign', 'http://192.168.48.129/api/sign', 'http://192.168.48.129/api/sign', 'http://192.168.48.129/api/sign', 'http://192.168.48.129/api/sign', ] cookies = dict(session='xxxxxxx') rs = (grequests.post(u,cookies=cookies,data=dict(card_id=1)) for u in urls) resp = grequests.map(rs) for r in resp: print(r.json())
果然,前四次都签到成功了!
只成功四次是因为我是用uwsgi
部署得站点,然后配置了processes = 4
,只有四个进程处理请求,所以轮到后两个请求得时候,is_sign
已经是true
了
用户签到的逻辑如下:
- 插入一条签到记录
- 修改阅读额度表,为用户增加额度
- 插入一条额度变更记录
- 提交修改
正常来说,如果是不同用户操作的,即使并发了对业务来说不会有任何问题,因为每个人都操作的是自己的数据,不会产生错误数据。
但是,今天遇到的是单用户并发了。
emmm,只能说这个老哥有点东西。
4.解决问题
不过既然发现了问题,那就得解决掉它。
orm框架我用的是flask-sqlalchemy
,还不知道它加锁得怎么搞,先查一下资料。
函数的定义如下:
@_generative() def with_for_update(self, read=false, nowait=false, of=none): """return a new :class:`.query` with the specified options for the ``for update`` clause. the behavior of this method is identical to that of :meth:`.selectbase.with_for_update`. when called with no arguments, the resulting ``select`` statement will have a ``for update`` clause appended. when additional arguments are specified, backend-specific options such as ``for update nowait`` or ``lock in share mode`` can take effect. e.g.:: q = sess.query(user).with_for_update(nowait=true, of=user) the above query on a postgresql backend will render like:: select users.id as users_id from users for update of users nowait .. versionadded:: 0.9.0 :meth:`.query.with_for_update` supersedes the :meth:`.query.with_lockmode` method. .. seealso:: :meth:`.generativeselect.with_for_update` - core level method with full argument and behavioral description. """
read
:是标识加互斥锁还是共享锁. 当为 true 时, 即 for share 的语句, 是共享锁. 多个事务可以获取共享锁, 互斥锁只能一个事务获取. 有"多个地方"都希望是"这段时间我获取的数据不能被修改, 我也不会改", 那么只能使用共享锁.nowait
:其它事务碰到锁, 是否不等待直接"报错".of
:指明上锁的表, 如果不指明, 则查询中涉及的所有表(行)都会加锁.
这里需要对用户信息表进行修改,要更新is_sign
字段,所以应该使用互斥锁。
修改后代码如下:
def api_sign(): id = current_user.id _user_info = user_info.query.filter_by(id=id).with_for_update().first() if _user_info.is_sign: return jsonify({'status':0,'message':'今日已签到,请明天8点再来签到!'}) else: pass
再次执行上面的并发请求代码,现在就只有第一次签到成功了。
问题成功解决!
5.心得
通过对这次问题的解决,加深了对sqlalchemy
的了解,同时对并发锁有了更直观的理解。
上一篇: 满星云MONIES年中汇报会,发布生态链底层生态系统Nexus
下一篇: 一次元