某些场景下我们需要确保多个用户不能同时修改同一条记录。为了实现这一点,可以使用SQL语句中的FOR UPDATE来锁定记录。

场景描述

假设我们有一个自定义的Odoo模型hr.request,其中包含一个quantity字段。我们需要在更新quantity时加锁,防止其他事务同时修改同一条记录。我们将创建两个按钮,分别用于写入不同的值,演示加上NOWAIT选项前后的行为差异。

先演示一下官方对于高并发的处理:

A函数修改50,睡眠20秒。B函数修改成100,睡眠5秒。

2个函数同时执行:

odoo的高并发上锁机制_字段

B函数先执行完毕:

odoo的高并发上锁机制_高并发_02

A函数执行会报错,说由于同步更新而无法串行访问,这个报错相信大家会经常看到:

odoo的高并发上锁机制_字段_03

然后自动开启重试机制,重试5次

odoo的高并发上锁机制_高并发_04

最后A函数执行完成:

odoo的高并发上锁机制_抛出异常_05

总结: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:演示效果

  1. 未使用NOWAIT的情况
self.env.cr.execute('SELECT id FROM hr_request WHERE id = %s FOR UPDATE', (self.id,))

如果记录已经被其他事务锁定,用户界面会等待,直到锁释放为止。这可能会导致用户体验不佳,特别是在锁被长时间占用的情况下。但是可以保证在其他事务执行完之后,该函数也能执行完成。

  1. 使用NOWAIT的情况:如果记录已经被其他事务修改,将不会等待其他事务执行完成,直接执行该函数。没有保证事务的执行顺序。会抛出异常:

odoo的高并发上锁机制_抛出异常_06

并且会重试5次:

odoo的高并发上锁机制_高并发_07

如果重试完第五次之前,其他事务执行完毕的话,该函数也会执行成功。

如果重试完第五次之前,其他事务没执行完毕的话,会抛出异常:

odoo的高并发上锁机制_抛出异常_08

  1. 扩展:使用NOWAIT的情况+try捕获异常:

能实现只能允许一个用户修改一条记录

odoo的高并发上锁机制_抛出异常_09

探索源码

路径: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},
        }