一道日志系统设计面试题
设计一个简单高效的日志列表页(基于游标的倒序分页)
目标是:实现一个「按发生时间倒序,滚动翻页(类似朋友圈)显示最近 50 条日志」的日志系统。
需求回顾
- 每条日志包含:发生时间(时间戳)和内容(最多 500 字符)。
- 页面显示最近的 50 条日志,支持向下滚动翻页(时间倒序)。
- 要保证翻页不会重复或漏数据(尤其当多条日志时间相同或有并发写入时)。
Q1:日志表如何设计?
CREATE TABLE log_table (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键,作为唯一 tie-breaker',
occurred_at DATETIME(3) NOT NULL COMMENT '日志发生时间,建议带毫秒精度 DATETIME(3)',
content VARCHAR(500) NOT NULL COMMENT '日志内容(最多 500 字)',
INDEX idx_time_id (occurred_at, id) -- 组合索引用于按时间+id 排序/筛选(考虑了时间重复的问题)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
设计要点说明
id(自增主键):time并不保证唯一,id作为二级字段用于打破时间相同的平局(tie-breaker),考虑某个字段重复时,再引入一个字段来进行区分(重点)。occurred_at DATETIME(3):建议使用带毫秒的时间类型(或直接用BIGINT存epoch_ms),降低“不同日志同一秒”导致的冲突概率,同时便于精确排序。- 组合索引
(occurred_at, id):,支持ORDER BY occurred_at DESC, id DESC和基于游标的查询,能让数据库走索引而不是全表扫描。 - 不要把
content放入索引(会膨胀索引并降低写入性能)。若需要覆盖索引并且内容短,可考虑,但一般日志内容较大,不建议。
Q2:前端向后端传递哪些参数?
采用基于游标(cursor-based / keyset)分页的方式,前端每次请求需要传递上一次最后一条日志的位置(游标):
last_time(可空):上一页最后一条日志的occurred_at,ISO8601 字符串或YYYY-MM-DD HH:MM:SS[.sss]last_id(可空):上一页最后一条日志的idpage_size(可选,默认 50):每页数量
规则:
- 第一页:
last_time、last_id都为null(或根本不传) - 往下翻页:传
last_time+last_id,后端返回更早的记录
建议:后端可以返回一个不透明的游标 token(例如把 last_time 与 last_id JSON 后 base64 编码),前端只需把 token 返还;这样隐藏内部实现细节。
示例请求(第一页):
GET /api/logs?page_size=50
示例请求(下一页):
GET /api/logs?last_time=2025-08-30T10:00:00.123Z&last_id=12345&page_size=50
或
GET /api/logs?cursor=eyJsYXN0X3RpbWUiOiAiMjAyNS0wOC0zMFQxMDowMDA6MDAuMTIzWiIsICJsYXN0X2lkIjogMTIzNDV9
Q3:后端执行逻辑与实际 SQL
采用time + id 的复合排序(occurred_at DESC, id DESC)和基于游标的 where 条件,避免使用 OFFSET。
1) 第一页(无游标)
SELECT id, occurred_at, content
FROM log_table
ORDER BY occurred_at DESC, id DESC
LIMIT ?; -- e.g. 50
2) 往下翻页(带游标:last_time、last_id)
查询条件:要获取所有在游标之后(更早)的记录,语义为“严格小于上次最后一条的排序位置”。
SQL(参数化):
SELECT id, occurred_at, content
FROM log_table
WHERE (occurred_at < ?)
OR (occurred_at = ? AND id < ?)
ORDER BY occurred_at DESC, id DESC
LIMIT ?;
参数顺序示例: (last_time, last_time, last_id, page_size)
注意:使用
<而非<=,保证不会把上次最后一条再次包含进来。
为什么这样能正确处理“时间重复”?
当多个记录的 occurred_at 相同时,使用 id 作为二级排序并在 WHERE 中添加 AND id < ? 的条件,就能精确过滤:只返回严格在上次最后一条之后(更旧)的记录,不会漏也不会重复。
示例
假设数据(按 occurred_at desc, id desc 的顺序):
| id | occurred_at | content |
|---|---|---|
| 100 | 2025-08-30 15:30:00.500 | A |
| 99 | 2025-08-30 15:30:00.500 | B |
| 98 | 2025-08-30 15:29:59.200 | C |
| 97 | 2025-08-30 15:29:58.000 | D |
| ... | ... | ... |
-
第一次请求(无游标)返回前 3 条:
100(A), 99(B), 98(C),最后一条游标为(last_time=2025-08-30 15:29:59.200, last_id=98)。 -
下一次请求的 WHERE:
WHERE (occurred_at < '2025-08-30 15:29:59.200') OR (occurred_at = '2025-08-30 15:29:59.200' AND id < 98)将返回 id=97 及更早的记录 —— 正确且不重复。
为什么不用 OFFSET(LIMIT 50 OFFSET n)?
- 对于日志这种不断写入的流式数据,
OFFSET在偏移量大的时候非常慢:数据库需要扫描并丢弃OFFSET数量的行,成本随OFFSET线性增长。 OFFSET还会因为并发写入(新数据插入到前面)导致分页不稳定(可能看到重复或漏行)。- Keyset(游标)分页是大规模列表翻页的推荐方案:查询稳定、性能优(走索引)、延迟小。
对工程化场景的补充与注意事项
1. 时间精度
- 如果业务中每秒会产生大量日志,建议:
- 使用
DATETIME(3)/DATETIME(6)或者直接BIGINT存毫秒/微秒时间戳(epoch_ms)。 - 精度更高,能减少相同
occurred_at的记录数,但仍需id做最终 tie-breaker。
- 使用
2. 索引设计细节
- 建组合索引:
(occurred_at, id)。MySQL 8 支持显式降序索引(occurred_at DESC, id DESC),但通常(occurred_at, id)已足够。 - 避免把
content放入索引(太长)。如果需要做纯时间范围查询并返回content,这会导致回表(从索引拿到 id 后再读行),这是正常且可接受的。
3. 并发写入与实时性
- 用户滚动翻页时,后端根据游标返回更早的记录,新写入的更“新的”日志(发生时间更大)不会被随后请求返回,这与用户期望一致(新数据应出现在顶部)。
- 如果你希望用户在翻页过程中也能看到新写入的数据,需要前端定期刷新顶部或提供“有 N 条新日志,点击加载”这样的交互。
4. 删除/更新的影响
- 删除会导致后续页面出现“本应存在但被删除”的空缺,但不会产生重复。
- 更新
occurred_at的操作会改变排序位置,可能导致翻页体验不稳定。
5. 分布式/多写入节点的注意点
- 在水平分库或多写实例场景下,
AUTO_INCREMENT的单调性可能被破坏(不同节点的自增间隔、或不同数据库实例的 id 不连续)。如果是分布式系统:- 可使用全局单增序列(如数据库 central sequence 或 Snowflake 类型的 ID),确保
id能作为可靠 tie-breaker。 - 或者把
occurred_at精度提高并结合node_id/seq形成复合排序键。
- 可使用全局单增序列(如数据库 central sequence 或 Snowflake 类型的 ID),确保
6. 不想暴露内部 id?使用不透明游标
把游标封装成一个 base64 编码的 JSON(例如 {"last_time":"...","last_id":123}),前端只传 cursor=...,后端解码并解析。这样可以避免直接暴露 id。
示例:完整 API 与后端伪代码
REST API 设计(简洁)
GET /api/logs?page_size=50&cursor=<optional>- 返回:
{
"items": [
{"id":100,"occurred_at":"2025-08-30T15:30:00.500Z","content":"A"},
{"id":99,"occurred_at":"2025-08-30T15:30:00.500Z","content":"B"}
],
"next_cursor": "eyJsYXN0X3RpbWUiOiAiMjAyNS0wOC0zMFQxNTozMDowMC41MDBaIiwgImxhc3RfaWQiOjg4fQ=="
}
后端伪代码(Java + JDBC 风格)
// decode cursor or set lastTime = null, lastId = null if first page
if (lastTime == null) {
sql = "SELECT id, occurred_at, content FROM log_table ORDER BY occurred_at DESC, id DESC LIMIT ?";
ps = conn.prepareStatement(sql);
ps.setInt(1, pageSize);
} else {
sql = "SELECT id, occurred_at, content FROM log_table " +
"WHERE (occurred_at < ?) OR (occurred_at = ? AND id < ?) " +
"ORDER BY occurred_at DESC, id DESC LIMIT ?";
ps = conn.prepareStatement(sql);
ps.setTimestamp(1, lastTime);
ps.setTimestamp(2, lastTime);
ps.setLong(3, lastId);
ps.setInt(4, pageSize);
}
ResultSet rs = ps.executeQuery();
// collect results and build next_cursor from last returned row
前端(浏览器 fetch)示例(简化)
async function fetchLogs(cursor, pageSize = 50) {
const params = new URLSearchParams();
if (cursor) params.set('cursor', cursor);
params.set('page_size', pageSize);
const res = await fetch(`/api/logs?${params.toString()}`);
return await res.json();
}
面试问答
- 如果只用
time来分页(不使用id),会发生什么问题?
答:当多条日志有相同时间时,翻页会出现重复或漏行,因为无法唯一定位“上一页最后一条”的排序位置。 - 为什么
OFFSET在大数据量场景下不合适?
答:OFFSET会让数据库扫描并跳过OFFSET数量的行,时间复杂度随偏移量增长,性能差且不稳定(并发插入会导致重复/漏行)。 - 如果需要隐藏内部
id,该如何设计游标?
答:把(last_time, last_id)序列化为 JSON,然后 base64 编码或签名,前端只传回这个cursor字符串,后端解码后再查询。
总结
- 正确做法:用
occurred_at(高精度)+id(自增或全局单调 ID)作为复合排序键,基于游标的查询语句WHERE (time < ?) OR (time = ? AND id < ?),并ORDER BY time DESC, id DESC LIMIT N。 - 为何这样:保证稳定、无重复、无漏行,并且性能较
OFFSET优越(可走索引)。 - 工程化建议:使用复合索引、合适时间精度、封装不透明游标、根据流量做分区/冷热分离,必要时引入专门的日志引擎(ES/ClickHouse)。
浙公网安备 33010602011771号