一道日志系统设计面试题

设计一个简单高效的日志列表页(基于游标的倒序分页)

目标是:实现一个「按发生时间倒序,滚动翻页(类似朋友圈)显示最近 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):建议使用带毫秒的时间类型(或直接用 BIGINTepoch_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(可空):上一页最后一条日志的 id
  • page_size(可选,默认 50):每页数量

规则:

  • 第一页last_timelast_id 都为 null(或根本不传)
  • 往下翻页:传 last_time + last_id,后端返回更早的记录

建议:后端可以返回一个不透明的游标 token(例如把 last_timelast_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_timelast_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 及更早的记录 —— 正确且不重复。


为什么不用 OFFSETLIMIT 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 形成复合排序键。

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();
}

面试问答

  1. 如果只用 time 来分页(不使用 id),会发生什么问题?
    答:当多条日志有相同时间时,翻页会出现重复或漏行,因为无法唯一定位“上一页最后一条”的排序位置。
  2. 为什么 OFFSET 在大数据量场景下不合适?
    答:OFFSET 会让数据库扫描并跳过 OFFSET 数量的行,时间复杂度随偏移量增长,性能差且不稳定(并发插入会导致重复/漏行)。
  3. 如果需要隐藏内部 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)。

posted @ 2025-08-30 09:39  书画三千里  阅读(18)  评论(0)    收藏  举报