CodeIgniter 4 原生不支持读写分离,因 Query Builder 初始化即绑定单一数据库连接,无自动路由逻辑;正确做法是通过自定义 BaseModel 在构造时根据方法类型动态切换连接,并避免跨库事务与复用 builder 实例。

CodeIgniter 4 原生不支持读写分离,Query Builder 默认只走主库(default 配置),强行在业务层手动切库会破坏封装性,也容易漏掉事务或关联查询场景。
为什么 Query Builder 无法自动读写分离
CI4 的 Query Builder 实例由 Database 类生成,而该类在初始化时就绑定了单一连接实例($db = \Config\Database::connect())。即使你配置了多个数据库组(如 read 和 write),Query Builder 本身没有路由逻辑,也不会根据 SELECT 或 INSERT/UPDATE/DELETE 自动选择连接。
常见误操作包括:
- 在控制器里反复调用
\Config\Database::connect('read')手动换连接,但后续$builder->where()->get()仍可能走错连接(因为$builder是从旧连接创建的) - 试图给
$builder赋值新连接对象,但Query Builder内部不提供 setter 方法,强行反射或属性赋值会导致不稳定 - 在模型中硬编码
connect('read'),结果update()也被发到从库,报错Can't execute UPDATE on read-only connection
正确做法:用自定义 BaseModel 封装读写路由
核心思路是让模型决定用哪个连接,而不是让 Query Builder 自己判断。你需要一个基类,在构造时根据方法类型(find/findAll/select → 读;insert/update/delete → 写)动态选择连接。
实操建议:
- 在
app/Models/BaseModel.php中继承CodeIgniter\Model,重写__construct() - 用
method_exists($this, 'beforeInsert')等钩子判断当前操作意图,但更可靠的是直接检查调用栈或约定方法命名(如以get、list开头为读) - 实际连接切换必须在
new BaseBuilder()之前完成,即:$this->db = \Config\Database::connect($role === 'read' ? 'read' : 'write'); - 不要复用同一个
$builder实例跨读写操作 —— 每次select()或update()都应基于当前$this->db新建Builder
示例关键片段:
class BaseModel extends \CodeIgniter\Model
{
protected $DBGroup = 'write'; // 默认写库
public function __construct()
{
// 根据当前调用方法推测角色
$method = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function'] ?? '';
$this->DBGroup = in_array($method, ['find', 'findAll', 'select', 'countAllResults']) ? 'read' : 'write';
$this->db = \Config\Database::connect($this->DBGroup);
parent::__construct();
}
}
事务与跨库查询的硬限制
MySQL 本身不支持跨主从实例的事务(XA 事务除外,CI4 不内置支持)。一旦开启 $this->db->transStart(),所有语句必须落在同一连接上,否则会出错或静默失败。
这意味着:
- 任何涉及
update()+select()的复合操作(比如先改状态再查最新记录),不能依赖“自动读从库”,必须显式指定$this->db = \Config\Database::connect('write')并禁用读库路由 -
join()查询若跨库(如主库 users 表 + 从库 logs 表),MySQL 会报错ERROR 1146 (42S02): Table 'logs.table_name' doesn't exist—— 因为从库连接看不到主库的表结构 - 缓存层(如 Redis)需配合使用,避免因从库延迟导致
select查不到刚insert的数据
最易被忽略的一点:CI4 的 BaseBuilder 在执行 get() 后会缓存字段元信息($this->fields),如果中途切换了连接但没重置 builder 实例,后续 update() 可能带着旧连接的字段映射去写新连接,引发列名不匹配或类型错误。


















