本文目录导读:

在 PHP 中实现聊天记录分页拉取,常见的方案有偏移量分页和游标(光标)分页两种,对于聊天记录这种高频、实时且数据量大的场景,推荐使用游标分页(基于ID或时间戳),因为它性能更好且能避免新增数据导致的分页错乱。
以下是两种方案的完整实现代码和说明:
游标分页(推荐,性能最优)
原理: 不通过页码,而是通过上一次请求返回的最后一条记录的ID或时间戳作为查询条件,去拉取比它更早的数据。
数据库表结构
CREATE TABLE chat_messages (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
room_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_room_id_id (room_id, id) -- 复合索引,加速游标查询
);
PHP 实现代码
<?php
// PDO 连接配置
$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', 'password', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
function getMessages(PDO $pdo, int $roomId, ?int $cursorId = null, int $limit = 20): array {
$sql = "SELECT * FROM chat_messages WHERE room_id = :room_id";
// 如果传入了游标ID,则拉取比该ID更早的数据
if ($cursorId !== null) {
$sql .= " AND id < :cursor_id"; // 降序拉取旧消息
}
$sql .= " ORDER BY id DESC LIMIT :limit";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':room_id', $roomId, PDO::PARAM_INT);
if ($cursorId !== null) {
$stmt->bindValue(':cursor_id', $cursorId, PDO::PARAM_INT);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
$messages = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 返回结果和下一次的游标(最后一条记录的ID,用于下次查询)
if (empty($messages)) {
return [
'data' => [],
'next_cursor' => null,
'has_more' => false
];
}
$lastId = end($messages)['id']; // 由于是降序,最后一条是ID最小的
return [
'data' => $messages, // 注意这里返回的是降序(新->旧),前端可能需要反转
'next_cursor' => $lastId,
'has_more' => count($messages) >= $limit
];
}
// 调用示例
$roomId = 1;
$cursorId = $_GET['cursor_id'] ?? null; // 第一次请求传 null
$limit = 20;
$result = getMessages($pdo, $roomId, $cursorId, $limit);
header('Content-Type: application/json');
echo json_encode($result);
前端调用策略:
- 首次加载:调用
getMessages(roomId, null, 20)→ 获取最新的20条。 - 向上滚动加载更多:将上一次响应的
next_cursor作为cursor_id传回 → 获取更早的20条。
偏移量分页(传统 LIMIT/OFFSET)
原理: 使用页码 page 和每页条数 limit,通过 LIMIT offset, limit 实现。
缺点: 数据量大时偏移量越大越慢;新消息插入时会导致页码错乱(数据重复或缺失),适合数据量小的简单场景。
PHP 实现代码
<?php
function getMessagesByPage(PDO $pdo, int $roomId, int $page = 1, int $limit = 20): array {
$offset = ($page - 1) * $limit;
$sql = "SELECT * FROM chat_messages
WHERE room_id = :room_id
ORDER BY id DESC
LIMIT :offset, :limit";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':room_id', $roomId, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
$messages = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 可选:获取总条数用于计算总页数
$countSql = "SELECT COUNT(*) FROM chat_messages WHERE room_id = :room_id";
$countStmt = $pdo->prepare($countSql);
$countStmt->execute([':room_id' => $roomId]);
$total = $countStmt->fetchColumn();
return [
'data' => $messages,
'current_page' => $page,
'total_pages' => ceil($total / $limit),
'has_more' => $page < ceil($total / $limit)
];
}
// 调用
$roomId = 1;
$page = $_GET['page'] ?? 1;
$result = getMessagesByPage($pdo, $roomId, $page, 20);
关键优化与注意事项
-
索引优化:
- 游标分页务必建立复合索引
(room_id, id),否则查询会很慢。 - 偏移量分页建议建立
(room_id, created_at)索引。
- 游标分页务必建立复合索引
-
时间戳游标(按时间拉取) 如果聊天室有严格的时序要求,可以用
created_at作为游标(注意时间戳精度问题,建议使用微秒或与id结合使用):// 游标条件改为: WHERE created_at < :cursor_time
-
实时消息轮询/推送 分页是针对历史消息的,新消息建议通过 WebSocket、SSE 或轮询单独推送,可以避免与分页逻辑混淆。
-
返回顺序调整: 代码中返回的是降序(新→旧),前端展示可能希望“旧→新”(聊天气泡从上往下时间递增),前端拿到数据后需
array_reverse()反转后再渲染。 -
安全限制:
LIMIT最大值建议硬编码限制(如最大100条),防止恶意请求。- 使用预处理语句(PDO)防止SQL注入。
总结对比表
| 特性 | 游标分页 (方案一) | 偏移量分页 (方案二) |
|---|---|---|
| 性能 | 优(走索引) | 差(大OFFSET慢) |
| 数据一致性 | 好(新增数据不影响) | 差(易重复/缺失) |
| 实现复杂度 | 中等 | 简单 |
| 适用场景 | 聊天、朋友圈、Feed流 | 后台管理、小数据量 |
最终建议: 生产环境聊天模块务必使用游标分页(方案一),配合Nginx/Redis等缓存热点房间数据,效果最佳。