Xiuno BBS 机制精讲 - 数据库
# Xiuno BBS 机制精讲 - 数据库
> 本文由人工智能辅助编写。
## 一、整体架构
Xiuno BBS 的数据库系统采用三层架构设计:
3. **业务代码层**
- (插件 model、route 中直接调用 db_find() 等函数)
2. **函数封装层**
- `db.func.php`
- 包括所有暴露出来的函数:`db_find(), db_insert(), db_exec()...`
- \+ SQL 构建器:`db_cond_to_sqladd(), db_orderby_to_sqladd(), db_array_to_insert_sqladd`
1. **驱动类层**
- `db_mysql.class.php `
- `db_pdo_mysql.class.php `
- `db_pdo_sqlite.class.php `
- `db_pdo_mongodb.class.php `(空壳,未实现)
**设计哲学**:Xiuno BBS 的原作者 axiuno 坚持面向过程的设计风格,认为"代码不仅仅是给人看的,更重要的是给编译器分析的,不要玩 `$db = new $dbclass()`,那样不利于优化和 opcache"。因此,虽然驱动层使用了类,但对外暴露的接口全部是过程式函数,开发者日常只需调用 `db_find()`、`db_insert()` 等函数即可。
---
## 二、驱动类层
驱动类层是整个数据库系统的底层,每个驱动类封装了特定数据库引擎的连接和操作逻辑。目前有三个完整实现:`db_mysql`、`db_pdo_mysql`、`db_pdo_sqlite`,以及一个空壳 `db_pdo_mongodb`。
### 2.1 驱动选择机制
驱动的选择发生在 `db_new()` 函数中(位于 `db.func.php`):
```php
function db_new($dbconf) {
global $errno, $errstr;
if($dbconf) {
switch ($dbconf['type']) {
case 'mysql': $db = new db_mysql($dbconf['mysql']); break;
case 'pdo_mysql': $db = new db_pdo_mysql($dbconf['pdo_mysql']); break;
case 'pdo_sqlite': $db = new db_pdo_sqlite($dbconf['pdo_sqlite']); break;
case 'pdo_mongodb': $db = new db_pdo_mongodb($dbconf['pdo_mongodb']); break;
default: return xn_error(-1, 'Not suppported db type:'.$dbconf['type']);
}
// ...
}
}
```
原作者刻意使用 `switch` 而非动态类实例化(`new $dbclass()`),目的是让 OPcache 能更好地进行静态分析和优化。
### 2.2 统一的驱动接口
所有驱动类都遵循相同的接口约定,拥有以下核心属性和方法:
#### 公共属性
| 属性 | 类型 | 说明 |
|------|------|------|
| `$conf` | array | 完整配置,支持主从 |
| `$rconf` | array | 当前使用的从库配置(仅 MySQL 驱动有) |
| `$wlink` | mixed | 写连接(主库) |
| `$rlink` | mixed | 读连接(从库) |
| `$link` | mixed | 最后一次使用的连接 |
| `$errno` | int | 错误号 |
| `$errstr` | string | 错误信息 |
| `$sqls` | array | 已执行的 SQL 记录(上限 1000 条) |
| `$tablepre` | string | 表前缀 |
| `$innodb_first` | bool | 是否优先使用 InnoDB(仅 MySQL 驱动有) |
#### 公共方法
| 方法 | 说明 |
|------|------|
| `__construct($conf)` | 保存配置,提取表前缀 |
| `connect()` | 同时连接主库和从库 |
| `connect_master()` | 连接写服务器(主库),懒连接 |
| `connect_slave()` | 连接读服务器(从库),懒连接,支持随机选择 |
| `real_connect(...)` | 实际建立连接,各驱动实现不同 |
| `sql_find_one($sql)` | 执行 SQL,返回单条记录 |
| `sql_find($sql, $key)` | 执行 SQL,返回多条记录 |
| `find($table, $cond, $orderby, $page, $pagesize, $key, $col)` | 高级查询,自动拼接 SQL |
| `find_one($table, $cond, $orderby, $col)` | 高级查询单条,自动拼接 SQL |
| `query($sql)` | 执行读操作 SQL |
| `exec($sql)` | 执行写操作 SQL |
| `count($table, $cond)` | 统计记录数 |
| `maxid($table, $field, $cond)` | 获取某字段最大值 |
| `truncate($table)` | 清空表 |
| `last_insert_id()` | 获取最后插入的 ID |
| `version()` | 获取数据库版本 |
| `error($errno, $errstr)` | 设置错误信息 |
### 2.3 读写分离机制
所有驱动类都实现了读写分离,核心逻辑在 `connect_slave()` 方法中:
```php
public function connect_slave() {
if($this->rlink) return $this->rlink; // 已有连接则复用
if(empty($this->conf['slaves'])) { // 没有配置从库
if($this->wlink === NULL) $this->wlink = $this->connect_master();
$this->rlink = $this->wlink; // 读连接指向主库
$this->rconf = $this->conf['master'];
} else {
$arr = array_rand($this->conf['slaves'], 1); // 随机选择一台从库
$conf = $this->conf['slaves'][$arr[0]];
$this->rconf = $conf;
$this->rlink = $this->real_connect(...); // 连接到选中的从库
}
return $this->rlink;
}
```
**关键设计点**:
1. **懒连接**:连接不在构造函数中建立,而是在第一次实际使用时才建立。`connect_master()` 和 `connect_slave()` 都有 `if($this->wlink) return $this->wlink` 的短路逻辑。
2. **读操作走从库**:`query()` 方法使用 `$this->rlink`(从库连接)。
3. **写操作走主库**:`exec()` 方法使用 `$this->wlink`(主库连接)。
4. **无从库时回退主库**:如果未配置从库,读操作也使用主库连接。
5. **从库负载均衡**:配置多台从库时,使用 `array_rand()` 随机选择,实现简单的负载均衡。
### 2.4 各驱动差异
#### db_mysql(已废弃的 mysql_* 扩展)
- 使用 `mysql_connect()`、`mysql_query()`、`mysql_fetch_assoc()` 等函数
- 这是 PHP 5 时代的扩展,在 PHP 7 中已被移除
- `query()` 和 `exec()` 方法接受可选的 `$link` 参数,允许指定连接
- `exec()` 方法区分 INSERT/REPLACE(返回 `mysql_insert_id()`)和 UPDATE/DELETE(返回 `mysql_affected_rows()`)
- 有 `close()` 方法显式关闭连接
#### db_pdo_mysql(推荐驱动)
- 使用 PDO 扩展,连接字符串格式为 `mysql:host=$host;port=$port;dbname=$name`
- 连接时设置 `PDO::ATTR_TIMEOUT => 5`(5 秒超时)
- 字符集通过 `SET names $charset, sql_mode=''` 设置
- `query()` 方法使用 try-catch 捕获异常
- `exec()` 方法中包含 InnoDB 自动替换逻辑:当检测到 `CREATE TABLE` 语句时,如果配置的引擎不是 MyISAM 且服务器支持 InnoDB,会自动将 SQL 中的 MyISAM 替换为 InnoDB
- `sql_find_one()` 在结果为 FALSE 时返回 NULL(而非 FALSE)
- `count()` 方法对 InnoDB 引擎做了优化:当条件为空时,从 `information_schema.tables` 读取 `TABLE_ROWS` 估算值,避免 `COUNT(*)` 全表扫描
InnoDB 自动替换逻辑的源码:
```php
if(strtoupper(substr($sql, 0, 12) == 'CREATE TABLE')) {
$fulltext = strpos($sql, 'FULLTEXT(') !== FALSE;
$highversion = version_compare($this->version(), '5.6') >= 0;
if(!$fulltext || ($fulltext && $highversion)) {
$conf = $this->conf['master'];
if(strtolower($conf['engine']) != 'myisam') {
$this->innodb_first AND $this->is_support_innodb()
AND $sql = str_ireplace('MyISAM', 'InnoDB', $sql);
}
}
}
```
这段逻辑的意图是:MySQL 5.6 之前 InnoDB 不支持全文索引,所以只有当 MySQL 版本 ≥ 5.6 且不需要全文索引时,才将 MyISAM 替换为 InnoDB。
#### db_pdo_sqlite
- 连接字符串格式为 `sqlite:$host`(此处 `$host` 实际是数据库文件路径)
- 连接时同时设置 `PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION`
- `version()` 方法调用 `SELECT VERSION()`,但 SQLite 不支持此函数,这是一个 bug
- `truncate()` 方法调用 `TRUNCATE`,但 SQLite 不支持 `TRUNCATE` 语法,这也是一个 bug
- 注释中包含了创建表、索引等方法的代码,但被注释掉了
- 缺少 `$sqls` 属性的初始化(在 `query()` 和 `exec()` 中使用了但未声明)
### 2.5 连接生命周期
驱动类的连接在对象析构时释放:
```php
public function __destruct() {
if($this->wlink) $this->wlink = NULL;
if($this->rlink) $this->rlink = NULL;
}
```
对于 PDO 驱动,将连接对象设为 NULL 即可释放连接。对于 `db_mysql` 驱动,还有 `close()` 方法调用 `mysql_close()`。
---
## 三、函数封装层
函数封装层位于 `db.func.php`,是开发者日常使用的主要接口。它将驱动类的方法包装为全局函数,并增加了错误处理和日志记录。
### 3.1 核心设计模式
所有 `db_*` 函数遵循统一的设计模式:
```php
function db_xxx($table, ..., $d = NULL) {
$db = $_SERVER['db']; // 从 $_SERVER 获取全局数据库实例
$d = $d ? $d : $db; // 允许传入自定义实例,默认使用全局实例
if(!$d) return FALSE; // 无可用连接则返回 FALSE
$r = $d->xxx(...); // 调用驱动类方法
db_errno_errstr($r, $d, $sql); // 统一错误处理
return $r;
}
```
**关键点**:
1. **全局实例存储在 `$_SERVER['db']`**:XiunoPHP 将全局数据库实例存放在 `$_SERVER` 超全局变量中,这是一种不常见但有效的做法——`$_SERVER` 在所有作用域中都可访问,且不会被 `extract()` 等操作覆盖。
2. **可选的 `$d` 参数**:每个函数都接受一个可选的数据库实例参数,允许在需要多个数据库连接时使用。
3. **统一错误处理**:所有函数执行后都调用 `db_errno_errstr()` 进行错误检查。
### 3.2 函数清单与 SQL 映射
| 函数 | 生成的 SQL 类型 | 返回值 |
|------|----------------|--------|
| `db_insert($table, $arr)` | `INSERT INTO ...` | 自增 ID 或 FALSE |
| `db_create($table, $arr)` | `INSERT INTO ...`(同 db_insert) | 自增 ID 或 FALSE |
| `db_replace($table, $arr)` | `REPLACE INTO ...` | 自增 ID 或 FALSE |
| `db_update($table, $cond, $update)` | `UPDATE ... SET ... WHERE ...` | 受影响行数或 FALSE |
| `db_delete($table, $cond)` | `DELETE FROM ... WHERE ...` | 受影响行数或 FALSE |
| `db_find($table, $cond, $orderby, $page, $pagesize, $key, $col)` | `SELECT ... FROM ... WHERE ... ORDER BY ... LIMIT ...` | 二维数组或 FALSE |
| `db_find_one($table, $cond, $orderby, $col)` | `SELECT ... FROM ... WHERE ... ORDER BY ... LIMIT 1` | 一维数组或 FALSE |
| `db_read($table, $cond)` | `SELECT * FROM ... WHERE ...` | 一维数组或 FALSE |
| `db_count($table, $cond)` | `SELECT COUNT(*) AS num FROM ...` | 整数或 FALSE |
| `db_maxid($table, $field, $cond)` | `SELECT MAX($field) AS maxid FROM ...` | 整数或 FALSE |
| `db_truncate($table)` | `TRUNCATE ...` | 布尔值 |
| `db_sql_find($sql, $key)` | 原生 SQL | 二维数组或 FALSE |
| `db_sql_find_one($sql)` | 原生 SQL | 一维数组或 FALSE |
| `db_exec($sql)` | 原生 SQL | 受影响行数或 FALSE |
### 3.3 表前缀的自动添加
函数封装层会自动为表名添加前缀。注意两层添加的区别:
- `db_find()`、`db_find_one()` 等高级函数**不会**添加前缀,前缀由驱动类的 `find()`/`find_one()` 方法内部添加(使用 `{$this->tablepre}$table`)
- `db_count()`、`db_maxid()`、`db_truncate()` 等函数在调用驱动方法前**手动添加**前缀:`$d->tablepre.$table`
- `db_insert()`、`db_update()`、`db_delete()`、`db_replace()` 等函数在拼接 SQL 时**手动添加**前缀:`{$d->tablepre}$table`
这意味着开发者在调用 `db_find('user', ...)` 时,传入的表名是**不含前缀**的(如 `user`),系统会自动加上配置的前缀(如 `bbs_`),最终查询的表名为 `bbs_user`。
### 3.4 db_insert / db_replace 的 SQL 生成
`db_insert()` 和 `db_replace()` 调用 `db_array_to_insert_sqladd()` 将关联数组转换为 SQL:
```php
function db_array_to_insert_sqladd($arr) {
$keys = array(); $values = array();
foreach($arr as $k=>$v) {
$k = addslashes($k);
$v = addslashes($v);
$keys[] = '`'.$k.'`';
$v = (is_int($v) || is_float($v)) ? $v : "'$v'";
$values[] = $v;
}
return "(".implode(',', $keys).") VALUES (".implode(',', $values).")";
}
```
生成的 SQL 格式为:`(key1, key2) VALUES (value1, value2)`
最终在 `db_insert()` 中拼接为:
```sql
INSERT INTO bbs_user (username, email) VALUES ('Jack', 'jack@email.com')
```
### 3.5 db_update 的 SQL 生成
`db_update()` 调用 `db_array_to_update_sqladd()` 生成 SET 子句,该函数支持增量更新:
```php
function db_array_to_update_sqladd($arr) {
$s = '';
foreach($arr as $k=>$v) {
$v = addslashes($v);
$op = substr($k, -1);
if($op == '+' || $op == '-') {
// 键名以 + 或 - 结尾表示增量操作
$k = substr($k, 0, -1);
$v = (is_int($v) || is_float($v)) ? $v : "'$v'";
$s .= "`$k`=$k$op$v,";
} else {
$v = (is_int($v) || is_float($v)) ? $v : "'$v'";
$s .= "`$k`=$v,";
}
}
return substr($s, 0, -1);
}
```
**增量更新语法**:当数组的键名以 `+` 或 `-` 结尾时,会生成增量操作 SQL。例如:
```php
db_update('user', array('uid'=>1), array('stocks+'=>1, 'name'=>'NewName'));
// 生成:UPDATE bbs_user SET `stocks`=stocks+1,`name`='NewName' WHERE uid=1
```
### 3.6 exec() 的返回值语义
`db_exec()` 的返回值取决于 SQL 类型,这个行为由驱动类的 `exec()` 方法决定:
- **INSERT / REPLACE**:返回 `last_insert_id()`(自增主键值)
- **UPDATE / DELETE**(仅 db_mysql 驱动):返回 `mysql_affected_rows()`(受影响行数)
- **其他**:返回 `$link->exec()` 的结果(PDO 驱动返回受影响行数)
- **失败**:返回 FALSE
**注意**:对于非自增表的 INSERT,`last_insert_id()` 始终返回 0。判断执行是否成功应使用 `=== FALSE` 严格比较。
---
## 四、SQL 构建器
SQL 构建器是函数封装层的重要组成部分,负责将 PHP 数组转换为 SQL 子句。它不是独立的模块,而是以一组辅助函数的形式存在。
### 4.1 db_cond_to_sqladd() — WHERE 子句构建
这是最复杂的构建器,支持四种条件格式:
#### 简单等值匹配
```php
$cond = array('uid' => 123, 'gid' => 1);
// 生成:WHERE `uid`=123 AND `gid`=1
```
当值不是数组时,直接生成等值条件。整数和浮点数不加引号,字符串使用 `addslashes()` 转义后加单引号。
#### 多值匹配(OR 展开)
```php
$cond = array('uid' => array(1, 2, 3));
// 生成:WHERE (`uid`=1 OR `uid`=2 OR `uid`=3)
```
当值是数字索引数组时,展开为 OR 条件。注意这里使用 OR 而非 IN,这是原作者的优化选择——他认为 OR 效率比 IN 高。
#### 范围匹配
```php
$cond = array('age' => array('>' => 18, '<=' => 30));
// 生成:WHERE `age`>18 AND `age`<=30
```
当值是关联数组且键为比较运算符时,生成范围条件。支持 `>`、`<`、`>=`、`<=`、`=` 等运算符。
#### 模糊匹配
```php
$cond = array('name' => array('LIKE' => 'John'));
// 生成:WHERE `name` LIKE '%John%'
```
当关联数组的键为 `LIKE`(全大写)时,生成模糊匹配条件。百分号会自动添加。
#### 混合使用
```php
$cond = array(
'gid' => 1,
'uid' => array('>' => 100, '<' => 1000),
'username' => array('LIKE' => 'jack')
);
// 生成:WHERE `gid`=1 AND `uid`>100 AND `uid`<1000 AND `username` LIKE '%jack%'
```
#### 安全性分析
`db_cond_to_sqladd()` 使用 `addslashes()` 进行转义。这是一种基本的 SQL 注入防护手段,但并非最安全的方案:
- `addslashes()` 可以防御大多数 SQL 注入攻击
- 但在特定字符集(如 GBK)下可能被绕过
- 推荐做法是使用 PDO 预处理语句,但 Xiuno BBS 的架构未采用预处理
### 4.2 db_orderby_to_sqladd() — ORDER BY 子句构建
```php
function db_orderby_to_sqladd($orderby) {
$s = '';
if(!empty($orderby)) {
$s .= ' ORDER BY ';
$comma = '';
foreach($orderby as $k=>$v) {
$s .= $comma."`$k` ".($v == 1 ? ' ASC ' : ' DESC ');
$comma = ',';
}
}
return $s;
}
```
排序规则简单但容易引起误解:
- 值为 `1` → 升序(ASC)
- 值**不为 1**(包括 `0`、`-1`、`2` 等) → 降序(DESC)
```php
$orderby = array('uid' => 1, 'create_date' => -1);
// 生成:ORDER BY `uid` ASC, `create_date` DESC
```
### 4.3 find() / find_one() 的 SQL 拼接流程
以 `find()` 为例,展示完整的 SQL 拼接过程:
```php
public function find($table, $cond = array(), $orderby = array(),
$page = 1, $pagesize = 10, $key = '', $col = array()) {
$page = max(1, $page);
$cond = db_cond_to_sqladd($cond); // → WHERE ...
$orderby = db_orderby_to_sqladd($orderby); // → ORDER BY ...
$offset = ($page - 1) * $pagesize; // 计算偏移量
$cols = $col ? implode(',', $col) : '*'; // 字段列表
return $this->sql_find(
"SELECT $cols FROM {$this->tablepre}$table $cond$orderby LIMIT $offset,$pagesize",
$key
);
}
```
最终生成的 SQL 示例:
```sql
SELECT uid,username FROM bbs_user WHERE `gid`=1 AND `uid`>100 ORDER BY `uid` ASC LIMIT 0,10
```
---
## 五、配置系统
### 5.1 数据库配置结构
```php
$conf['db'] = array(
'type' => 'pdo_mysql', // 驱动类型
'pdo_mysql' => array( // 对应驱动的配置
'master' => array( // 主库配置
'host' => 'localhost',
'user' => 'root',
'password' => 'root',
'name' => 'test', // 数据库名
'tablepre' => 'bbs_', // 表前缀
'charset' => 'utf8', // 字符集
'engine' => 'myisam', // 默认存储引擎
),
'slaves' => array( // 从库配置(可选,支持多台)
// array('host'=>'slave1', 'user'=>'root', ...),
// array('host'=>'slave2', 'user'=>'root', ...),
),
),
);
```
### 5.2 配置到实例化的流程
1. XiunoPHP 初始化时读取配置文件 `conf/conf.php`
2. 在 `xiunophp.php` 中执行:`$db = !empty($conf['db']) ? db_new($conf['db']) : NULL;`
3. `db_new()` 根据 `$conf['db']['type']` 选择驱动类并实例化
4. 实例化后的对象存入 `$_SERVER['db']`
5. 此时尚未建立实际连接(懒连接设计)
### 5.3 多数据库实例
当需要连接多个数据库时,可以手动调用 `db_new()`:
```php
$newdb = db_new(array(
'type' => 'pdo_mysql',
'pdo_mysql' => array(
'master' => array(
'host' => 'other-host',
'user' => 'root',
'password' => 'pass',
'name' => 'other_db',
'tablepre' => 'other_',
'charset' => 'utf8',
'engine' => 'innodb',
),
'slaves' => array(),
),
));
// 使用自定义实例
db_find('user', array(), array(), 1, 10, '', array(), $newdb);
```
---
## 六、错误处理机制
### 6.1 双层错误处理
Xiuno BBS 的数据库错误处理分为两层:
**驱动层**:每个驱动类维护 `$errno` 和 `$errstr` 属性
```php
public function error($errno = 0, $errstr = '') {
$error = $this->link ? $this->link->errorInfo() : array(0, $errno, $errstr);
$this->errno = $errno ? $errno : (isset($error[1]) ? $error[1] : 0);
$this->errstr = $errstr ? $errstr : (isset($error[2]) ? $error[2] : '');
}
```
**函数层**:`db_errno_errstr()` 将驱动错误传播到全局变量
```php
function db_errno_errstr($r, $d = NULL, $sql = '') {
global $errno, $errstr;
if($r === FALSE) {
$errno = $d->errno;
$errstr = db_errstr_safe($errno, $d->errstr);
$s = 'SQL:'.$sql."\r\nerrno: ".$errno.", errstr: ".$errstr;
xn_log($s, 'db_error'); // 记录到日志
}
}
```
### 6.2 错误信息安全过滤
`db_errstr_safe()` 在非调试模式下会过滤敏感的数据库错误信息:
```php
function db_errstr_safe($errno, $errstr) {
if(DEBUG) return $errstr; // 调试模式显示原始错误
if($errno == 1049) return '数据库名不存在,请手工创建';
if($errno == 2003) return '连接数据库服务器失败,请检查IP是否正确,或者防火墙设置';
if($errno == 1024) return '连接数据库失败';
if($errno == 1045) return '数据库账户密码错误';
return $errstr;
}
```
### 6.3 SQL 日志
驱动类内部维护 `$sqls` 数组,记录最近执行的 SQL 语句(上限 1000 条):
```php
if(count($this->sqls) < 1000) $this->sqls[] = $sql;
```
`db_exec()` 函数在 DEBUG 模式下还会额外记录到日志文件:
```php
DEBUG AND xn_log($sql, 'db_exec');
```
---
## 七、完整调用链路追踪
以一次 `db_find('user', array('gid'=>1), array('uid'=>-1), 1, 10)` 调用为例,追踪完整的执行链路:
```
1. db_find('user', array('gid'=>1), array('uid'=>-1), 1, 10)
↓ 获取全局数据库实例 $_SERVER['db']
↓ 调用 $d->find('user', array('gid'=>1), array('uid'=>-1), 1, 10, '', array())
2. db_pdo_mysql::find()
↓ db_cond_to_sqladd(array('gid'=>1))
→ "WHERE `gid`=1"
↓ db_orderby_to_sqladd(array('uid'=>-1))
→ " ORDER BY `uid` DESC "
↓ 拼接 SQL:
→ "SELECT * FROM bbs_user WHERE `gid`=1 ORDER BY `uid` DESC LIMIT 0,10"
↓ 调用 $this->sql_find($sql, '')
3. db_pdo_mysql::sql_find()
↓ 调用 $this->query($sql)
4. db_pdo_mysql::query()
↓ 检查从库连接: if(!$this->rlink && !$this->connect_slave()) return FALSE
↓ 懒连接触发: connect_slave() → connect_master() → real_connect()
↓ $link = $this->link = $this->rlink
↓ $query = $link->query($sql) [PDO::query]
↓ 记录 SQL: $this->sqls[] = $sql
↓ 返回 PDOStatement 对象
5. 回到 sql_find()
↓ $query->setFetchMode(PDO::FETCH_ASSOC)
↓ $arrlist = $query->fetchAll()
↓ arrlist_change_key() (当 $key 非空时)
↓ 返回二维数组
6. 回到 db_find()
↓ db_errno_errstr($arrlist, $d)
↓ 返回结果数组
```
---
## 八、缓存与数据库的协作
Xiuno BBS 的数据库系统与缓存系统紧密协作,这是其性能优化的核心策略。
### 8.1 SELECT * 策略
Xiuno BBS 推荐使用 `SELECT *` 而非指定字段,原因在于:
1. **缓存层消除了性能差异**:开启 Redis/Memcached/Yac 后,大部分查询不会到达数据库
2. **减少代码维护**:表结构变化时无需修改查询代码
3. **数据完整性**:避免遗漏字段导致的程序错误
敏感字段(如密码)在获取后通过 `user_format()` 等函数移除,而非在查询时排除。
### 8.2 典型的缓存 + 数据库模式
Xiuno BBS 中的 Model 层通常采用以下模式:
```php
function user_read($uid) {
// 先查缓存
$user = cache_get("user-$uid");
if($user !== FALSE) return $user;
// 缓存未命中,查数据库
$user = db_find_one('user', array('uid'=>$uid));
if($user) {
user_format($user); // 移除敏感字段
cache_set("user-$uid", $user); // 写入缓存
}
return $user;
}
```
### 8.3 InnoDB 的 COUNT 优化
对于 InnoDB 引擎,`COUNT(*)` 需要扫描全表。Xiuno BBS 在 `count()` 方法中做了优化:
```php
public function count($table, $cond = array()) {
$this->connect_slave();
if(empty($cond) && $this->rconf['engine'] == 'innodb') {
// 无条件时从 information_schema 读取估算值
$dbname = $this->rconf['name'];
$sql = "SELECT TABLE_ROWS as num FROM information_schema.tables
WHERE TABLE_SCHEMA='$dbname' AND TABLE_NAME='$table'";
} else {
// 有条件时仍使用 COUNT(*)
$cond = db_cond_to_sqladd($cond);
$sql = "SELECT COUNT(*) AS num FROM `$table` $cond";
}
// ...
}
```
注意:`TABLE_ROWS` 是估算值,与实际值可能有 5%-10% 的偏差。在论坛场景下,这个偏差是可以接受的。
---
## 九、安全考量
### 9.1 SQL 注入防护
Xiuno BBS 使用 `addslashes()` 作为主要的 SQL 注入防护手段,体现在:
- `db_cond_to_sqladd()` 对字符串值调用 `addslashes()`
- `db_array_to_insert_sqladd()` 对键和值都调用 `addslashes()`
- `db_array_to_update_sqladd()` 对值调用 `addslashes()`
**局限性**:
1. `addslashes()` 在 GBK 等宽字节编码下可能被绕过
2. `db_sql_find()`、`db_sql_find_one()`、`db_exec()` 接受原生 SQL,不做任何转义
3. 不支持 PDO 预处理语句(Prepared Statement)
**最佳实践**:
- 使用 `db_find()`、`db_insert()` 等封装函数,而非直接拼接 SQL
- 使用 `param()` 函数获取用户输入,它会自动进行 `htmlspecialchars` 和类型转换
- 必须使用原生 SQL 时,务必手动转义所有用户输入
### 9.2 敏感信息保护
- `db_errstr_safe()` 在生产环境过滤数据库错误详情
- 用户密码等敏感字段在 Model 层的 `format` 函数中移除,而非在 SQL 层排除
---
## 十、已知问题与局限
1. **db_pdo_sqlite 的兼容性问题**:`version()` 调用 `SELECT VERSION()` 在 SQLite 中无效;`truncate()` 调用 `TRUNCATE` 在 SQLite 中不支持;缺少 `$sqls` 属性声明。
2. **db_mysql 驱动已废弃**:依赖的 `mysql_*` 函数在 PHP 7 中已被移除,此驱动仅具历史参考价值。
3. **db_pdo_mongodb 为空壳**:仅有类声明,无任何实现。
4. **无预处理语句支持**:所有 SQL 均通过字符串拼接生成,依赖 `addslashes()` 防注入,安全性不如 PDO 预处理。
5. **db_cond_to_sqladd() 的 OR vs IN**:多值匹配使用 OR 展开而非 IN,在值较多时 SQL 会很长。
6. **exec() 返回值不一致**:`db_mysql` 驱动对 UPDATE/DELETE 返回 `mysql_affected_rows()`,而 PDO 驱动对 INSERT/REPLACE 返回 `last_insert_id()`,对其他语句返回受影响行数——但函数封装层未对这种差异做统一处理。
7. **表前缀添加不统一**:部分函数在封装层添加前缀,部分在驱动层添加,增加了理解成本。
8. **db_update() 不返回受影响行数**:由于 PDO 驱动的 `exec()` 在 INSERT/REPLACE 时返回 `last_insert_id()`,而 `db_update()` 生成的 SQL 是 UPDATE,走的是 `exec()` 的非 INSERT/REPLACE 分支,返回受影响行数——但如果 UPDATE 的值与原值相同,受影响行数为 0,可能被误判为失败。
---
## 十一、架构总结
Xiuno BBS 的数据库系统是一个典型的"够用就好"的轻量级设计。它没有 Laravel Eloquent 那样的完整 ORM 功能,也没有 Doctrine 那样的 DQL 抽象,但它做到了:
1. **对开发者友好**:`db_find()`、`db_insert()` 等函数名直观,参数简洁
2. **对多引擎兼容**:通过驱动抽象支持 MySQL、SQLite 等
3. **对性能敏感**:读写分离、懒连接、InnoDB COUNT 优化、缓存协作
4. **对安全有基本防护**:addslashes 转义、错误信息过滤
它的核心价值在于:**让开发者用最少的代码完成最常见的数据库操作**,同时保持足够的灵活性以应对复杂场景。这种设计哲学与 Xiuno BBS 整体"简洁至上"的风格一脉相承。