Skip to Content

Навантажувальне тестування Odoo 19

Задача: зрозуміти, скільки Odoo 19 витримує під навантаженням, і зробити це так, щоб отримана цифра щось означала. Типова помилка — відкрити Locust чи JMeter, вдарити сотнею віртуальних користувачів по / або по формі логіну і порадіти вражаючим, але беззмістовним результатом. Ну ок, тільки це не тест Odoo: маршрут / для анонімного відвідувача (index(), type='http', auth='none') — це два рядки логіки, редирект на /web/login_successful або на /odoo, без жодного важкого запиту до бази. Ви щойно виміряли швидкість редиректу, а не ORM і не PostgreSQL.

Що вважати навантаженням

Реальна робота — авторизовані дії: список записів (search_read), відкриття картки (read), збереження форми (create/write), виклик кнопки (call_button). Кожна відкриває курсор PostgreSQL і займає воркер на весь час обробки — саме це варто вимірювати.

Ще один нюанс: тестувати має сенс тільки конфіг з workers > 0. За замовчуванням workers = 0 («0 disable prefork mode», tools/config.py) — Odoo стартує в однопроцесному ThreadedServer, запити обробляють треди в одному процесі. У продакшні інша модель, PreforkServer: окремі процеси на HTTP, окремі на крони. Прогнати тест проти дефолтного конфігу — виміряти архітектуру, якої на бойовому сервері немає.

Сесія і CSRF

auth='user'-маршрути — а це майже вся реальна робота — вимагають сесії. Без логіну кожен запит або редиректить, або падає з помилкою прав, і тест «проходить» із підозріло стабільним і геть безглуздим результатом.

Неправильно:

import requests
requests.post("http://odoo:8069/web/dataset/call_kw", json={
    "jsonrpc": "2.0", "method": "call",
    "params": {"model": "res.partner", "method": "search_read", "args": [[], ["name"]], "kwargs": {}},
})

Сесії немає — у відповіді помилка автентифікації, а не результат пошуку.

Правильно — спочатку логін через /web/session/authenticate (type='jsonrpc', auth='none'), далі та сама HTTP-сесія (кука session_id) на решту викликів:

import requests

s = requests.Session()
s.post("http://odoo:8069/web/session/authenticate", json={
    "jsonrpc": "2.0", "method": "call",
    "params": {"db": "loadtest", "login": "demo", "password": "demo"},
})
s.post("http://odoo:8069/web/dataset/call_kw", json={
    "jsonrpc": "2.0", "method": "call",
    "params": {"model": "res.partner", "method": "search_read", "args": [[], ["name"]], "kwargs": {"limit": 80}},
})

Важливо! CSRF-токен тут не потрібен. Захист від CSRF в @http.route за замовчуванням увімкнений тільки для type='http', а для type='jsonrpc' — вимкнений. Весь бекенд-трафік вебклієнта (call_kw, call_button, authenticate) — якраз jsonrpc (у 19.0 старий type='json', до речі, став deprecated-аліасом до type='jsonrpc'). CSRF реально треба обробляти тільки для класичних HTML-форм: портал, /shop, сам /web/login як сторінка.

Сценарій, схожий на користувача

Пишемо сценарій: логін один раз в on_start, далі — зважені завдання, більшість трафіку читання, менше запису.

import uuid
from locust import HttpUser, task, between

class OdooUser(HttpUser):
    wait_time = between(1, 3)
    db = "loadtest"

    def on_start(self):
        self.rpc("/web/session/authenticate", {"db": self.db, "login": "demo", "password": "demo"})

    def rpc(self, path, params):
        payload = {"jsonrpc": "2.0", "method": "call", "params": params, "id": str(uuid.uuid4())}
        return self.client.post(path, json=payload).json().get("result")

    @task(5)
    def browse_partners(self):
        self.rpc("/web/dataset/call_kw", {
            "model": "res.partner", "method": "search_read",
            "args": [[], ["name", "email"]], "kwargs": {"limit": 80},
        })

    @task(2)
    def open_partner(self):
        self.rpc("/web/dataset/call_kw", {
            "model": "res.partner", "method": "read",
            "args": [[1], ["name", "email", "phone"]], "kwargs": {},
        })

    @task(1)
    def create_partner(self):
        self.rpc("/web/dataset/call_kw", {
            "model": "res.partner", "method": "create",
            "args": [{"name": "Load test partner"}], "kwargs": {},
        })

search_read — метод @api.model, а create@api.model_create_multi (окремий декоратор, який теж виставляє _api_model = True), тому в обох в args немає id. Диспетчер call_kw (odoo/service/model.py) перевіряє саме цей атрибут і підставляє id лише для методів, що працюють з конкретним записом, як read — там перший елемент args це список id.

Сліпі зони: крони і вебсокети

Навіть правильний сценарій бачить не все. Крони виконуються в окремих процесах WorkerCron, кількість яких задає max_cron_threads (дефолт 2) — вони не забирають слоти в HTTP-воркерів. Але це той самий PostgreSQL і ті самі ядра CPU: важкий крон, що стартував посеред тесту, спотворить результат без жодної зміни у скрипті. Я б перед прогоном вимикав некритичні ir.cron або хоча б знав розклад.

Друга сліпа зона — реальний час: чат, обговорення, нотифікації йдуть через маршрут /websocket. У режимі workers > 0 для нього піднімається окремий процес на gevent_port (дефолт 8072) — майстер-процес стартує його сам, поряд з HTTP-воркерами. Сценарій, що б'є тільки в 8069, цей процес жодного разу не зачепить.

Які параметри конфігу дають стелю

[options]
workers = 4
max_cron_threads = 2
limit_time_cpu = 60
limit_time_real = 120
limit_request = 65536
limit_memory_soft = 2147483648
limit_memory_hard = 2684354560
db_maxconn = 64

Значення — реальні дефолти з tools/config.py, крім workers (дефолт 0, виставляється вручну; офіційна документація Odoo для sizing дає емпіричне правило (#CPU × 2) + 1).

workers — і є стеля конкурентності. Кожен WorkerHTTP в циклі бере accept() з одного спільного сокета і обробляє один запит за раз, послідовно. N воркерів — N запитів одночасно на весь сервер, незалежно від кількості віртуальних користувачів у Locust. Росте черга на сокеті — росте латентність, а CPU кожного окремого воркера при цьому може бути далеко не 100%.

limit_request (65536) — не ліміт швидкості, а лічильник: після нього воркер сам себе перезапускає (захист від повільних витоків пам'яті). У логах — Worker (%d) max request (%s) reached., рівень info: штатна робота, не збій.

limit_time_cpu і limit_time_real плутають часто, а це різні речі. limit_time_cpu (60с) рахує CPU-час; при перевищенні Odoo кидає всередині запиту виняток CPU time limit exceeded., воркер лишається живим (Worker (%d) CPU time limit (%s) reached., info). limit_time_real (120с) рахує астрономічний час: якщо воркер не встиг вчасно «пінганути» батьківський процес, той шле йому SIGKILL ззовні — без винятку, і це вже error: %s (%s) timeout after %ss. Запит, що завис на блокуванні рядка в БД, майже не витрачає CPU — його зупинить саме limit_time_real, а limit_time_cpu проблеми й не помітить.

Важливо! limit_memory_soft/limit_memory_hard рахуються по virtual memory (process.memory_info().vms), не по RSS. У top/htop звіряйте з колонкою VIRT, не RES — інакше ліміт спрацює задовго до того, як RES здасться «близьким».

Всі ці сигнатури можна одразу шукати в логах:

grep -E "CPU time limit|timeout after|virtual memory limit|Connection Pool Is Full" odoo.log

PostgreSQL чи Odoo — хто насправді впирається

Остання сигнатура з grep вище стосується вже не воркерів, а бази. db_maxconn (дефолт 64) — ліміт не на весь Odoo, а на процес: кожен WorkerHTTP, кожен WorkerCron і gevent-процес тримають власний пул з'єднань до PostgreSQL окремо (sql_db.py, ConnectionPool — модульний глобал у межах процесу). Теоретична стеля — db_maxconn × (workers + max_cron_threads + 1): при workers = 4 це вже 4×64 + 2×64 + 1×64 = 448 можливих з'єднань, набагато більше за дефолтний max_connections = 100 у postgresql.conf.

Тому на практиці впираєтесь у PostgreSQL раніше, ніж у власний пул Odoo, — доки хтось свідомо не підняв max_connections. Перевіряємо, хто саме відмовляє:

SELECT state, count(*) FROM pg_stat_activity
WHERE application_name LIKE 'odoo-%' GROUP BY state;

(Odoo підключається з application_name = odoo-<pid> — параметр db_app_name, дефолт odoo-{pid}.) Якщо новим з'єднанням відмовляє сам PostgreSQL — упираєтесь у його max_connections. Якщо натомість в логах Odoo з'являється psycopg2.pool.PoolError: The Connection Pool Is Full — це вже власний пул конкретного воркера, і піднімати max_connections без db_maxconn тут безглуздо.

Тема за матеріалом https://www.cybrosys.com/blog/how-to-perform-load-testing-in-odoo-19. Код і поведінку перевірено на Odoo 19.0.

Навантажувальне тестування Odoo 19
KitWorks, Volodymyr Karabanov 23 липня 2026 р.
Поділитися цією публікацією
Теги
Архів