先说结论
Odoo 的实时能力,核心不是“浏览器连上 WebSocket 之后就能收到消息”,而是数据库事务、PostgreSQL NOTIFY、bus_bus 表、last id 回放和频道订阅这一整套配合。
从 /home/ubuntu/odoo-temp/addons/bus/models/bus.py 与 ir_websocket.py 可以看到,官方真正关心的是五件事:
- 消息先不先进数据库,再发给前端。
- 只有事务真正提交后,才允许触发广播。
- 如果 NOTIFY 载荷太大,要自动拆包。
- 频道必须带数据库语义,避免不同库之间串消息。
- 客户端断线重连时,不能只看“有没有连接”,还要看 last id 从哪里继续补。
所以最短结论是:
Odoo bus 不是一个“推送接口”,而是一条以事务一致性为第一优先级的实时分发链。
为什么 _sendone() 不直接把消息推给 WebSocket
bus.bus._sendone() 的实现很值得细看。
它做的不是“立刻发消息”,而是先把消息放进:
self.env.cr.precommit.data["bus.bus.values"]self.env.cr.postcommit.data["bus.bus.channels"]
这其实已经说明了 Odoo 的设计立场:
- precommit 阶段负责把消息记录写入
bus_bus表; - postcommit 阶段负责通知 PostgreSQL 的
imbus频道,让分发线程去唤醒 WebSocket。
为什么要这么绕?
因为如果你在业务事务还没提交时就直接通知前端,前端会看到一个“刚刚发生”的事件,但数据库里那笔真实数据也许随后回滚了。
这会制造一种很糟糕的体验:
- 页面弹出“新消息到了”;
- 用户点进去却发现记录不存在;
- 或者列表数量变了又变回去。
所以 Odoo 的原则很清楚:
先保证数据库里真的有这件事,再告诉前端“这件事发生了”。
这和很多稳定的第三方同步模块坚持 post-commit 推送,本质上是同一类工程思路。
bus_bus 表不是历史包袱,而是回放缓冲层
很多人看到 bus.bus 模型会疑惑:
- 既然已经有 WebSocket,为什么还要往表里写?
答案是:因为实时系统不只要“推”,还要“补”。
_poll() 里有两条关键逻辑:
- 如果
last == 0,就抓近TIMEOUT时间窗口内的通知; - 如果
last > 0,就抓id > last的未读通知。
这意味着 bus_bus 表并不只是“存一下再删”,它承担了一个非常实际的职责:
- 新连接初次进入时,用它做短窗口缓冲;
- 老连接重连时,用它按通知 ID 补齐漏掉的消息。
也就是说,Odoo 的实时链路不是单纯的“瞬时广播”,而是:
- 数据库表保存通知对象;
- NOTIFY 只负责唤醒;
- WebSocket 真正取消息时还要回查数据库。
这样设计的好处是,短暂断网、worker 切换、浏览器休眠后恢复,都不会立刻把实时体验打成全丢。
为什么频道一定要带数据库名
channel_with_db() 非常关键。
它会把频道统一转成带数据库前缀的结构,例如:
(dbname, 'broadcast')(dbname, model_name, id)(dbname, model_name, id, subchannel)
这说明 Odoo 根本不把频道当成一个普通字符串,而是把它视作带租户隔离语义的路由键。
这一步很重要,因为 Odoo 经常是一台服务跑多个数据库。
如果频道只是 broadcast、mail.channel 或某个 partner id,而不带 dbname,你就会遇到最危险的情况:
- A 数据库发的消息,B 数据库也可能捡到;
- 或者订阅键本来一样,却属于不同业务世界。
所以这里最值得记住的一句话是:
在 Odoo bus 里,频道不是“房间名”,而是“库内对象地址”。
为什么官方还要拆分 NOTIFY payload
get_notify_payloads() 的作用,很多人容易忽略。
它会检查当前要广播的 channels JSON 如果过长,是否超过 NOTIFY_PAYLOAD_MAX_LENGTH。超过就递归拆半。
这说明 Odoo 非常清楚 PostgreSQL NOTIFY 不是无限大的消息队列,而是一个有负载上限的唤醒机制。
官方这里的思路不是:
- 反正发不出去就报错;
而是:
- 自动把需要唤醒的频道列表拆成多个 payload,分次通知。
这背后有两个现实意义:
- bus 广播规模可能很大。 比如群组、批量对象更新、多人在线协作场景。
- NOTIFY 发送的不是完整业务数据,只是“这些频道有新消息了,去表里取”。
也正因为业务数据主要仍在 bus_bus 表里,拆分的是频道唤醒信号,而不是拆业务消息体本身,这个设计才成立。
WebSocket 订阅为什么还要追加 broadcast、群组和 partner 频道
ir.websocket._build_bus_channel_list() 做了几件额外事情:
- 自动追加
broadcast - 自动追加当前用户的所有 group
- 已登录用户再追加自己的
partner_id
这说明客户端提交给服务器的频道,并不是最终订阅结果。
Odoo 会根据当前会话和用户身份,补上系统级频道。
这带来的影响是:
- 你以为自己只订阅了一个业务频道,其实还天然带着全局广播通道;
- 权限相关的群组消息,不需要前端自己拼;
- 用户私有消息可以稳定落到 partner 维度,而不是暴露一个可猜的字符串。
这也是为什么 _sendone() 的 docstring 里专门提醒:
如果直接用字符串频道,这个 target 不该是攻击者容易猜中的值。
因为字符串频道一旦可预测,消息边界就容易变成“只靠大家不乱猜”。这显然不够稳。
last 为什么要和 _bus_last_id() 比较
_prepare_subscribe_data() 里有个很关键的小动作:
- 如果客户端传来的
last大于当前系统里的_bus_last_id(),就把last归零。
这一步其实是在防止客户端拿着一个不可信或过期上下文来请求回放。
比如:
- 浏览器缓存了旧数据库里的 last id;
- 服务器清理过消息表;
- 测试环境复制后 id 序列发生变化;
- 客户端自己传了一个离谱的大值。
如果不做这个保护,服务端就会误以为“你已经收到过很多消息”,从而跳过实际应该补发的通知。
所以这里的逻辑其实是:
last id 不是客户端说了算,而是服务端结合当前 bus 状态来校正。
bus 的 GC 为什么能直接用 SQL 删除
_gc_messages() 直接执行:
DELETE FROM bus_bus WHERE create_date < %s
而且注释明确说,是为了避免 ORM 开销,可以快速删除数百万行。
这说明官方把 bus_bus 定义得很明确:
- 它是底层通信表;
- 不期待复杂引用关系;
- 过期消息是可以粗暴清掉的。
这对运维理解很重要。
你不应该把 bus_bus 当成长期审计日志,也不该拿它做业务归档。它更像一个短期缓冲层。
默认保留时间来自 bus.gc_retention_seconds,默认是 24 小时。也就是说:
- 它足够帮你应对短线重连;
- 但不是给你存一周聊天历史的地方。
一句话理解 Odoo bus 的真实架构
如果非要把这条链路压缩成一句话,我会这样总结:
业务事务先落库,bus 消息先入表,提交后再通过 PostgreSQL NOTIFY 唤醒分发线程,线程再根据频道把对应 WebSocket 叫起来,客户端必要时按 last id 回表补消息。
这条设计看起来比“直接发 WebSocket”复杂,但它更符合 ERP 场景:
- 更在意一致性;
- 更怕事务回滚后的假通知;
- 更需要断线补偿;
- 更强调多数据库隔离。
所以理解 Odoo bus,最重要的不是记住某个前端 API,而是先记住一句话:
实时不是第一目标,一致地实时才是第一目标。
DISCUSSION
评论区