某些场景下我们需要确保多个用户不能同时修改同一条记录。为了实现这一点,可以使用SQL语句中的FOR UPDATE
来锁定记录。
场景描述
假设我们有一个自定义的Odoo模型hr.request
,其中包含一个quantity
字段。我们需要在更新quantity
时加锁,防止其他事务同时修改同一条记录。我们将创建两个按钮,分别用于写入不同的值,演示加上NOWAIT
选项前后的行为差异。
先演示一下官方对于高并发的处理:
A函数修改50,睡眠20秒。B函数修改成100,睡眠5秒。
2个函数同时执行:
B函数先执行完毕:
A函数执行会报错,说由于同步更新而无法串行访问,这个报错相信大家会经常看到:
然后自动开启重试机制,重试5次
最后A函数执行完成:
总结:odoo官方已经做了对于一些高并发操作的处理,并且会进入重试进制,但是如果遇到一些并发操作函数处理链条复杂,中途中断的情景,可以考虑用数据库上锁进入WAIT的情况,等待其他事务执行,再执行本函数。或者加入try实现一些其他功能。下面来探讨
步骤 1:定义模型
首先,定义一个简单的模型hr.request
,包含一个quantity
字段。
from odoo import models, fields
class HrRequest(models.Model):
_name = 'hr.request'
_description = 'HR Request'
quantity = fields.Integer(string='Quantity')
步骤 2:实现带锁定的更新方法
接下来,我们在模型中定义一个方法,使用self.env.cr.execute
来执行SQL语句,并加上FOR UPDATE NOWAIT
锁定记录。
from odoo import models, fields, api
from psycopg2 import OperationalError
class HrRequest(models.Model):
_name = 'hr.request'
_description = 'HR Request'
quantity = fields.Integer(string='Quantity')
@api.model
def _update_quantity_with_lock(self, new_quantity):
try:
# 获取数据库连接并锁定记录
# 不使用NOWAIT选项,会等待锁释放
# 使用NOWAIT选项,遇到锁定时立即报错,如果这里没有try起来,会抛出并发修改异常,并且自动重试。
# 如果不加这句上锁,odoo官方也会抛出并发修改异常,并且自动重试。
self.env.cr.execute('SELECT id FROM hr_request WHERE id = %s FOR UPDATE NOWAIT', (self.id,))
# 更新quantity
self.quantity = new_quantity
except OperationalError:
raise UserError('该记录正在被其他用户修改,请稍后再试。')
步骤 3:添加两个按钮以演示效果
我们为hr.request
模型添加两个按钮,分别调用不同的更新方法。
from odoo import models, api, _
from odoo.exceptions import UserError
class HrRequest(models.Model):
_inherit = 'hr.request'
def call_update_from_ui(self):
logging.info("开始调用update_quantity_from_ui=========50")
self.update_quantity_from_ui(50)
time.sleep(20)
logging.info("结束调用update_quantity_from_ui=========50")
def call_update_from_api(self):
logging.info("开始调用update_quantity_from_api=========100")
self.update_quantity_from_api(100)
time.sleep(5)
logging.info("结束调用update_quantity_from_api=========100")
步骤 4:界面XML定义
接下来,定义界面XML文件,为模型添加两个按钮。
<form string="HR Request">
<header>
<button name="call_update_from_ui" string="测试高并发修改50" type="object"
class="oe_highlight"/>
<button name="call_update_from_api" string="测试高并发修改100" type="object"
class="oe_highlight"/>
</header>
...
步骤 5:演示效果
- 未使用
NOWAIT
的情况:
self.env.cr.execute('SELECT id FROM hr_request WHERE id = %s FOR UPDATE', (self.id,))
如果记录已经被其他事务锁定,用户界面会等待,直到锁释放为止。这可能会导致用户体验不佳,特别是在锁被长时间占用的情况下。但是可以保证在其他事务执行完之后,该函数也能执行完成。
- 使用
NOWAIT
的情况:如果记录已经被其他事务修改,将不会等待其他事务执行完成,直接执行该函数。没有保证事务的执行顺序。会抛出异常:
并且会重试5次:
如果重试完第五次之前,其他事务执行完毕的话,该函数也会执行成功。
如果重试完第五次之前,其他事务没执行完毕的话,会抛出异常:
- 扩展:使用
NOWAIT
的情况+try捕获异常:
能实现只能允许一个用户修改一条记录
探索源码
路径:odoo\addons\base\models\ir_module.py
执行自动化任务的时候也是用了类似的方法上锁,不等待,抛出异常,然后捕获弹出UserError
def _button_immediate_function(self, function):
if getattr(threading.currentThread(), 'testing', False):
raise RuntimeError(
"Module operations inside tests are not transactional and thus forbidden.\n"
"If you really need to perform module operations to test a specific behavior, it "
"is best to write it as a standalone script, and ask the runbot/metastorm team "
"for help."
)
try:
# This is done because the installation/uninstallation/upgrade can modify a currently
# running cron job and prevent it from finishing, and since the ir_cron table is locked
# during execution, the lock won't be released until timeout.
self._cr.execute("SELECT * FROM ir_cron FOR UPDATE NOWAIT")
except psycopg2.OperationalError:
raise UserError(_("Odoo is currently processing a scheduled action.\n"
"Module operations are not possible at this time, "
"please try again later or contact your system administrator."))
function(self)
self._cr.commit()
api.Environment.reset()
modules.registry.Registry.new(self._cr.dbname, update_module=True)
self._cr.commit()
env = api.Environment(self._cr, self._uid, self._context)
# pylint: disable=next-method-called
config = env['ir.module.module'].next() or {}
if config.get('type') not in ('ir.actions.act_window_close',):
return config
# reload the client; open the first available root menu
menu = env['ir.ui.menu'].search([('parent_id', '=', False)])[:1]
return {
'type': 'ir.actions.client',
'tag': 'reload',
'params': {'menu_id': menu.id},
}