八股文 / 百度后端
百度后端开发八股文
面向后端研发与 Go 服务端岗位。先掌握每题的主干回答,再准备追问和场景化取舍。
一、Go 语言与运行时
Q1:Go 是值传递还是引用传递?
Go 的参数传递统一是值传递,函数得到的是参数副本。
- 数组传参会复制整个数组。
- 指针传参复制的是指针值,两个指针仍指向同一对象。
- slice 传参复制的是 slice header,通常仍共享底层数组。
- map、channel 传参复制的是运行时描述值,仍关联同一底层对象。
- interface 传参复制其动态类型和动态值描述。
因此,函数能通过 slice 修改元素,并不代表 Go 存在“引用传递”。如果函数内的 append 触发扩容,新 slice 会指向新的底层数组,调用方也不会自动获得新的长度和容量。
常见追问:数组与 slice 传参有什么区别?为什么 map 通常不需要传 *map?
Q2:new 和 make 有什么区别?
new(T)为T准备零值空间,返回*T。make只用于 slice、map 和 channel,返回已经初始化的T。new(map[K]V)得到的是指向 nil map 的指针,仍不能直接写入。make(map[K]V)得到可直接写入的 map。
对象在栈还是堆上由编译器的逃逸分析决定,不能根据是否使用 new 或 make 直接判断。
Q3:slice 的底层结构和扩容过程是什么?
slice 可抽象为三个字段:底层数组指针、长度 len、容量 cap。
append 时容量足够就复用原数组;容量不足则申请新数组、复制旧元素并返回新的 slice。扩容策略是运行时实现细节,会受 Go 版本、所需容量、元素大小和内存分配规格影响,不应死背“1024 以下翻倍、以上增长 25%”。
典型陷阱:
a := make([]int, 2, 4)
b := append(a, 1)
c := append(a, 2)
b 与 c 都可能复用 a 的底层数组,并向同一个位置写入;第二次 append 可能覆盖第一次的结果。需要隔离时可复制:
b := append([]int(nil), a...)
b = append(b, 1)
从大数组切出很小的 slice 也可能长期持有整个底层数组。若只需少量数据,应复制到独立的小 slice。
Q4:map 的底层实现是什么?为什么并发读写不安全?
Go map 本质上是哈希表。运行时根据哈希定位数据位置,处理哈希冲突,并在负载过高时扩容、迁移数据。具体桶结构会随 Go 版本演进,面试时应说明原理,不要把某个版本的字段布局当成语言规范。
普通 map 在没有任何写操作时可以并发读取;并发读写或并发写存在 data race,运行时还可能直接报错。
常见处理方式:
- 使用
sync.Mutex或sync.RWMutex; - 适合特定读多写少、键相对稳定场景时使用
sync.Map; - 由单个 goroutine 持有 map,通过 channel 串行处理;
- 对热点大 map 做分片加锁。
map 的遍历顺序没有保证。key 必须可比较,因此 slice、map 和 function 不能直接作为 key。
Q5:interface 为什么会出现“值是 nil,但接口不等于 nil”?
接口值包含动态类型和动态值。只有两者都为空时,接口才等于 nil。
type MyError struct{}
func (*MyError) Error() string { return "failed" }
var p *MyError = nil
var err error = p
fmt.Println(err == nil) // false
这里 err 的动态类型是 *MyError,只有动态值为 nil,因此接口整体不为 nil。返回 error 时应直接返回 nil,不要先把 nil 指针装入接口。
方法集方面:T 的方法集包含值接收者方法,*T 的方法集包含值接收者和指针接收者方法。普通方法调用时的自动取地址,不会改变接口实现所依据的方法集规则。
Q6:defer、panic 和 recover 有哪些规则?
- 多个 defer 按后注册先执行的顺序运行。
- defer 的函数值和普通实参在注册时求值。
- 闭包捕获变量后,读取可能发生在闭包真正执行时。
- defer 可以读取或修改命名返回值。
- panic 会沿当前 goroutine 的调用栈展开并执行 defer。
recover只有在发生 panic 的同一 goroutine 中,由 deferred function 直接调用时才有效;跨 goroutine 调用,或在 deferred function 调用的普通辅助函数中间接调用,都不能恢复该 panic。
服务端可在请求或任务入口设置 recover 边界,记录堆栈并转换为错误响应,但不应把 panic 当普通业务错误使用。
Q7:goroutine 与线程有什么区别?
goroutine 是 Go runtime 调度的轻量级执行单元,初始栈较小并可动态增长,大量 goroutine 复用较少的操作系统线程。它的创建和切换成本通常低于线程,但仍会消耗栈、调度、对象和监控资源。
线程由操作系统调度,创建和上下文切换成本通常更高。goroutine 轻量不等于可以无限创建;无界并发会造成内存上涨、调度开销、下游过载和 goroutine 泄漏。
Q8:什么是 GMP 调度模型?
- G:goroutine,保存执行栈、状态和待执行任务等信息。
- M:machine,对应操作系统线程。
- P:processor,持有执行 Go 代码所需的调度资源和本地运行队列。
M 通常需要绑定 P 才能执行 Go 代码;P 的数量主要由 GOMAXPROCS 决定。新 G 优先进入当前 P 的本地队列,本地任务不足时可从全局队列、网络轮询器或其他 P 获取任务。M 因系统调用阻塞时,P 可以与它解绑并交给其他 M。
常见调度契机包括 channel 或锁阻塞、网络 I/O、系统调用、GC 安全点、时间片与异步抢占、runtime.Gosched() 以及 goroutine 结束。
易错点:P 不是 CPU 核,也不是线程;GOMAXPROCS 限制的是并行执行 Go 代码的 P 数量,不等于进程的线程总数。
Q9:channel 的发送、接收和关闭语义是什么?
channel 的运行时结构包含缓冲区、发送和接收位置、等待队列、锁及关闭状态等信息。
发送时:有等待的接收者则直接交付;否则缓冲区有空间就写入;再否则发送方阻塞。接收过程与之对称。
必须记住:
- 向 nil channel 发送或接收:永久阻塞;
- 关闭 nil channel:panic;
- 向已关闭 channel 发送:panic;
- 重复关闭 channel:panic;
- 从已关闭但未排空的 channel 接收:先取得剩余数据;
- 从已关闭且排空的 channel 接收:立即得到零值,
ok == false。
通常由发送方负责关闭 channel。关闭的作用是广播“不再发送”,不是为了让 GC 回收 channel。
Q10:select 和 context 如何配合避免 goroutine 泄漏?
select 在多个 channel 操作中选择可执行分支。多个 case 同时就绪时不会承诺严格优先级;没有分支就绪且没有 default 时阻塞。把某个 channel 设为 nil,可以动态禁用对应 case。
context 用来在请求链路中传递取消信号、截止时间和少量请求元数据。父 context 的取消会向子 context 传播,但 context 不会强制终止 goroutine,任务必须主动监听:
select {
case result := <-resultCh:
return result, nil
case <-ctx.Done():
return nil, ctx.Err()
}
创建 WithCancel、WithTimeout 或 WithDeadline 后,应及时调用 cancel。高频循环中反复使用 time.After 会产生重复的定时器分配;Go 1.23 之前,未触发且未停止的定时器还可能持续占用资源。需要反复重置超时时,通常可复用 Timer,或直接使用带超时的 context。
Q11:Go GC、三色标记和写屏障是什么?
Go 使用并发标记清扫型 GC。三色标记是一种理解可达性扫描的抽象:
- 白色:尚未发现;
- 灰色:已发现但引用尚未扫描完成;
- 黑色:自身及其引用已经扫描完成。
大致流程包括开启标记时的短暂 STW、并发标记、标记终止时的短暂 STW,以及清扫。并发标记期间业务 goroutine 仍会修改指针关系,写屏障用于记录这类变化,避免可达对象被漏标。
Go GC 不是完全没有 STW。优化目标也不只是降低单次停顿,还要平衡 CPU 开销、堆增长和吞吐量。
Q12:什么是逃逸分析?如何排查不必要的堆分配?
逃逸分析由编译器判断对象能否安全地留在当前栈帧。若对象可能在函数返回后继续被引用,或编译器无法证明它不会逃逸,就可能分配到堆上。
常见诱因包括返回局部变量指针、闭包捕获、存入全局状态、某些接口装箱和动态生命周期对象,但“使用指针就逃逸”是错误的。
可用以下命令检查:
go build -gcflags="-m=2" ./...
堆分配会增加 GC 压力,但不能为了减少逃逸而牺牲正确性和可维护性。应先用 benchmark、allocation profile 和 pprof 找到真实热点。
Q13:Mutex、RWMutex、atomic 和 channel 怎么选?
- 临界区共享状态保护,优先考虑
Mutex。 - 读操作很多、写较少且临界区有一定工作量时,可测试
RWMutex。 - 简单计数器、状态位或指针交换可使用 atomic。
- 所有权转移、任务编排、流式数据传递适合 channel。
RWMutex 并非天然更快,atomic 也不能自动维护多个字段构成的业务不变量。Go 的锁不可重入,已经使用过的锁及 WaitGroup 不应复制。
并发问题应运行:
go test -race ./...
-race 是动态检测,测试中没有报告不代表理论上不存在竞态。
Q14:如何排查 goroutine 泄漏和 Go 服务内存上涨?
先区分 goroutine 泄漏、Go heap 活对象增长、已释放但尚未归还操作系统的内存、线程栈、mmap/cgo 内存,以及容器统计口径差异。
常用工具:
- goroutine profile:查看阻塞在 channel、锁或网络操作上的任务;
- heap 与 alloc profile:区分当前存活对象和累计分配;
- mutex/block profile:查锁竞争和阻塞;
- runtime metrics、trace、
go tool pprof; - 对比多个时间点的 profile,而不是只看单次快照。
常见泄漏原因包括无人消费的 channel、没有退出机制且仍持有 ticker 或等待其 channel 的定时 goroutine、未响应 context、未关闭响应体或连接、worker 缺少退出信号,以及异常路径未释放资源。Go 1.23 起,未再被引用的 ticker 可由 GC 回收,但这不会自动终止仍在运行或阻塞的业务 goroutine。
二、MySQL
Q15:为什么 InnoDB 索引使用 B+ 树?
B+ 树的非叶子节点主要保存索引键和子指针,单页能容纳更多索引项,树高更低,可以减少随机 I/O。数据集中在叶子节点,叶子节点按键有序连接,适合范围查询和排序。
哈希适合等值查找,但不支持有序遍历、范围查询和最左前缀;红黑树扇出较小,数据量大时树高更高;B 树内部节点也保存数据,单页扇出通常低于 B+ 树。
Q16:聚簇索引、二级索引、回表和覆盖索引是什么?
InnoDB 聚簇索引的叶子节点保存整行数据;二级索引的叶子节点保存索引列和主键。使用二级索引找到主键后,再访问聚簇索引取得其他列,就是回表。
如果查询需要的列都可以直接从某个索引获得,就是覆盖索引。覆盖索引能减少回表,但加入过多列会增加索引体积、写放大和维护成本。
联合索引应根据查询的等值条件、范围条件、排序和选择性设计。不能机械地说“遇到范围查询后索引全部失效”:范围列通常仍可定位区间,后续列能否继续缩小扫描范围或参与索引下推,要结合版本和执行计划判断。
Q17:事务的 ACID、MVCC 和隔离级别如何实现?
- 原子性:undo log 支持回滚。
- 持久性:redo log 与刷盘策略支持崩溃恢复。
- 隔离性:锁和 MVCC 协同实现。
- 一致性:由事务机制、约束和业务逻辑共同保证。
MVCC 通过事务信息、undo 版本链和 Read View 判断记录可见性。快照读通常读取一致性版本;当前读读取最新记录并配合锁。
在可重复读级别下,普通快照读通过一致性视图避免同一事务内看到新版本;当前读会配合记录锁、间隙锁或临键锁控制范围插入。因此不应简化成“MVCC 在所有场景彻底解决幻读”。
Q18:redo log、undo log 和 binlog 的区别是什么?
- redo log:InnoDB 层的重做日志,用于持久性和崩溃恢复。
- undo log:保存撤销所需的信息,用于事务回滚和 MVCC 历史版本。
- binlog:Server 层的逻辑变更日志,主要用于主从复制和基于时间点的数据恢复,也可作为数据变更订阅的来源;它不能替代完整的数据库审计日志。
一次事务同时涉及 redo log 和 binlog 时,需要协调两份日志的提交状态,避免出现引擎已提交但 binlog 不完整,或 binlog 已记录但引擎事务未提交的矛盾。
Q19:如何排查慢 SQL?
- 从慢查询日志和监控确认 SQL、耗时、频率和数据量。
- 用
EXPLAIN或EXPLAIN ANALYZE查看访问路径、索引、估算行数与实际行数。 - 检查是否全表扫描、回表过多、临时表、额外排序、隐式转换或索引选择错误。
- 优先减少扫描行数和返回列,再考虑联合索引、覆盖索引或 SQL 改写。
- 检查锁等待、主从延迟、Buffer Pool 命中率和磁盘 I/O,避免把所有慢查询都归因于“没建索引”。
深分页不宜长期使用很大的 OFFSET,可根据稳定排序键做键集分页,例如使用 (created_at, id) 作为下一页游标。
Q20:死锁如何产生、排查和避免?
死锁需要互斥、持有并等待、不可剥夺和循环等待。数据库中常见原因是多个事务以不同顺序更新相同资源,或者查询未命中合适索引而锁住过大范围。
处理方式:
- 统一资源加锁顺序;
- 缩短事务,避免事务内调用慢外部服务;
- 建立合适索引以缩小锁范围;
- 查看 InnoDB 死锁信息和事务等待关系;
- 应用捕获死锁错误后进行次数有限、带退避的重试。
三、Redis
Q21:Redis 为什么快?“单线程”应如何理解?
Redis 主要数据在内存中,核心数据结构和协议开销较低,并通过 I/O 多路复用处理大量连接。所谓“单线程”主要指命令执行路径串行化,并不代表 Redis 的所有工作都只有一个线程;持久化、异步释放和部分网络 I/O 可以由后台进程或线程完成。
单条慢命令、大 Key、热 Key、网络带宽和内存压力仍会显著影响 Redis,不能只凭“内存数据库”判断性能一定足够。
Q22:RDB 和 AOF 怎么选?
- RDB 是时间点快照,文件紧凑、恢复较快,但两次快照之间的数据可能丢失。
- AOF 记录写操作,通常能缩小数据丢失窗口,但文件更大,重写和恢复成本更高。
- 两者可以结合使用,具体取舍由可接受的数据丢失窗口、恢复目标、写入压力和运维能力决定。
持久化不是高可用的替代品。还需要副本、故障转移、备份和恢复演练。
Q23:缓存穿透、击穿和雪崩有什么区别?
- 穿透:持续查询不存在的数据。可用参数校验、短期空值缓存和布隆过滤器。
- 击穿:单个热点 Key 失效后大量请求同时回源。可用互斥重建、逻辑过期和热点预热。
- 雪崩:大量 Key 同时失效或缓存集群整体异常。可用随机化过期、分批预热、多级缓存、限流降级和数据库保护。
布隆过滤器存在假阳性;空值缓存会占用额外空间,并可能在源数据新建后、空值 TTL 到期前短暂返回不存在,因此要结合数据变化频率设置较短 TTL 或主动失效机制。
Q24:缓存和数据库如何保持一致?
常见策略是先更新数据库,再删除缓存。数据库写成功但缓存删除失败时,通过重试队列、事务消息或订阅数据库变更日志补偿。
这类方案通常提供最终一致性,仍可能存在短暂不一致窗口。强一致要求更高时,应重新审视是否必须缓存该数据,或者将读写收敛到能够提供所需一致性的单一权威系统。
延迟双删不是万能方案:延迟难准确设定,进程崩溃也可能遗漏删除。无论使用哪种方案,都要先定义业务能够接受的一致性等级。
Q25:Redis 分布式锁如何正确实现?
获取锁时应原子地设置“不存在才写入”和过期时间;锁值使用每次请求唯一的 token。释放锁时用 Lua 原子地比较 token 后删除,避免误删其他客户端新获得的锁。
长任务需要合理租期或续租机制。若旧持有者在锁过期后仍可能继续写入,应增加 fencing token,由下游拒绝旧版本操作。
Redis 锁不能自动把数据库、消息队列和外部接口组成强一致事务。面试中应说明故障模型和业务能否容忍重复执行。
Q26:大 Key 和热 Key 如何处理?
大 Key 会造成网络阻塞、迁移困难、主线程长时间处理和内存碎片。可以拆分数据、压缩、限制单值大小,并使用异步删除。
热 Key 会让单个分片成为瓶颈。可以使用本地缓存、读副本、Key 拆分、请求合并和限流。在线排查应控制扫描频率,避免诊断本身拖垮实例。
四、API 设计与网络
Q27:如何设计一个可维护的 REST API?
- URI 表达资源,HTTP 方法表达操作语义。
- 正确使用状态码,不把所有失败都包装成 HTTP 200。
- 错误响应包含稳定业务错误码、可读消息和 trace ID。
- 明确分页、过滤、排序、字段选择、时间格式和时区。
- API 版本优先采用兼容演进;确需破坏性修改时再切换版本。
- 对请求体大小、超时、并发、权限和敏感字段设置边界。
- 写接口定义幂等、重试和重复提交策略。
接口文档还应包含示例、错误码、限流规则和兼容性约束,而不仅是字段列表。
Q28:什么是接口幂等?POST 如何实现幂等?
幂等指同一业务请求执行一次或多次,最终业务效果一致。客户端为写请求生成幂等键,服务端持久化该键、请求摘要、处理状态和首次结果。
处理流程:
- 根据用户或业务主体与幂等键查询记录;
- 首次请求创建处理中状态并执行业务;
- 成功后保存响应;
- 重复请求返回相同结果;
- 同一幂等键对应不同请求摘要时拒绝处理。
数据库唯一约束是重要防线,但只拦截重复写入并不等于完整幂等;还要处理处理中请求、超时重试和首次响应复用。
Q29:接口的超时、重试、熔断和限流如何配合?
调用链应有总超时预算,下游超时要短于上游剩余预算。仅对可安全重试的操作进行有限次数重试,并使用指数退避和随机抖动,避免每一层都重试造成流量放大。
- 限流:保护入口和稀缺资源;
- 熔断:依赖持续失败时快速拒绝;
- 降级:返回缓存、默认值或缩减功能;
- 隔离:让不同依赖使用独立连接池、线程池或并发额度;
- 幂等:降低重试造成重复副作用的风险。
客户端超时只表示没有按时收到响应,不代表服务端一定没有执行成功。
Q30:页码分页和游标分页怎么选?
页码分页简单,适合数据量较小、更新不频繁的管理页面。大数据量或持续写入场景应使用游标或键集分页。
排序键必须稳定且唯一,例如 (created_at, id)。升序查询下一页时使用字典序 (created_at, id) > (?, ?),降序时使用 (created_at, id) < (?, ?),而不是使用很大的 OFFSET。游标可编码并签名,避免暴露或被篡改内部查询状态。
Q31:TCP 为什么三次握手、四次挥手?
三次握手让双方确认彼此的发送与接收能力,并同步初始序列号,同时避免失效的旧连接请求直接建立连接。两次握手不足以让服务端确认客户端已经收到服务端的序列号和确认。
TCP 是全双工连接,双方的发送方向需要分别关闭。主动关闭方发送 FIN,对方先 ACK;对方处理完剩余数据后再发送自己的 FIN,主动关闭方最后 ACK,因此常见为四次挥手。
TIME_WAIT 使最后一个 ACK 丢失时仍可重传,并让旧连接报文在网络中自然消失,避免影响相同四元组的新连接。
Q32:TCP 粘包、HTTP Keep-Alive 与 HTTP/2 多路复用分别是什么?
TCP 是字节流,不保留应用消息边界。一次写入不保证对应一次读取,因此应用层需要固定长度、分隔符或“长度字段 + 消息体”等协议,并处理 partial read 和最大消息长度。
HTTP Keep-Alive 是复用 TCP 连接;TCP keepalive 是内核探测长时间空闲连接是否存活;HTTP/2 多路复用是在一条连接上并发传输多个 stream。HTTP/2 缓解 HTTP 层的队头阻塞,但底层 TCP 丢包仍可能影响同一连接上的多个流。
五、操作系统与服务端基础
Q33:进程、线程和协程有什么区别?
进程拥有独立虚拟地址空间,是资源隔离和分配单位;线程共享进程内存,是操作系统调度单位;协程通常由语言运行时或用户态库调度,切换成本更低,但最终仍需要线程执行。
进程切换通常涉及地址空间和页表相关状态,成本高于同进程线程切换;线程切换仍要保存寄存器、栈和调度状态。协程阻塞是否拖住线程,取决于运行时能否接管对应 I/O 或系统调用。
Q34:select、poll 和 epoll 有什么区别?
select 需要反复传入并扫描 fd 集合,并有 fd 数量限制;poll 使用数组描述符,取消了固定上限,但仍需线性扫描;epoll 在内核维护关注集合,并通过就绪队列返回活跃事件,适合大量连接、少量活跃的场景。
LT 模式只要 fd 仍可操作就会持续通知;ET 模式主要在状态变化时通知,通常配合非阻塞 I/O,并一直读写到返回 EAGAIN。
不能简单说 epoll 的所有操作都是 O(1),实际成本仍受活跃连接、回调、数据结构和内核实现影响。
Q35:什么是零拷贝?
零拷贝通常指减少数据在用户态与内核态之间的复制和上下文切换,不代表物理系统中绝对没有任何复制。
例如发送静态文件时,sendfile 可以让数据从页缓存直接进入网络发送路径,避免先复制到用户缓冲区再写回内核。mmap 则将文件映射到进程地址空间,减少显式的 read 拷贝。
是否适合要结合数据是否需要业务处理、文件大小、页缓存命中率和操作系统支持判断。
六、系统设计:K 线数据怎么存储?
Q36:如果让你设计 K 线存储系统,你会如何回答?
不要一上来只说“用 MySQL,按股票代码和时间建索引”。先澄清:
- 标的是股票、期货、外汇还是数字资产?
- 标的数量、成交事件吞吐和历史保留周期?
- 需要 1 秒、1 分钟、日线,还是任意周期?
- 查询以单标的时间范围为主,还是全市场横截面分析?
- 实时延迟要求是多少?是否允许历史修正和回放?
- 是否涉及交易所时区、午间休市、夜盘、复权和停牌?
这些条件决定使用 MySQL、时序数据库、列式数据库和对象存储中的哪一种或哪几种。
Q37:K 线的数据模型如何设计?
建议保留两层数据。
原始成交或行情事件作为可回放事实源:
market, instrument_id, event_time, sequence_id,
price, quantity, amount, side, source, ingest_time
event_time 是交易所事件时间,ingest_time 是系统接收时间。sequence_id 只有在数据源协议定义的作用域内才用于去重、排序和发现缺口;应同时保存其作用域,例如频道、标的、交易日、连接 epoch 或数据源提供的全局流标识。
K 线聚合结果:
market, instrument_id, interval_code, open_time, close_time,
open_price, high_price, low_price, close_price,
volume, amount, trade_count, is_final, version,
source, first_source_sequence, last_source_sequence,
has_sequence_gap, updated_at
业务唯一键为:
(market, instrument_id, interval_code, open_time)
价格可使用 DECIMAL,也可使用定点整数;使用整数时必须在版本化的标的元数据中保存价格缩放因子或最小价格单位,保证历史数据能按当时的规则解释。不要直接使用二进制浮点数保存权威价格。时间统一为 UTC 或明确的交易所时区,周期边界必须结合交易日历。股票拆分与分红对应的复权因子应单独保存,不要覆盖未复权原始数据。
Q38:MySQL、时序数据库和 ClickHouse 如何选?
中小规模、单标的时间范围查询为主:MySQL 足够,主键按 (market, instrument_id, interval_code, open_time) 排列,方便前缀过滤和时间范围扫描。
CREATE TABLE kline (
market VARCHAR(16) NOT NULL,
instrument_id BIGINT NOT NULL,
interval_code SMALLINT NOT NULL,
open_time DATETIME(3) NOT NULL,
close_time DATETIME(3) NOT NULL,
open_price BIGINT NOT NULL,
high_price BIGINT NOT NULL,
low_price BIGINT NOT NULL,
close_price BIGINT NOT NULL,
volume DECIMAL(30, 8) NOT NULL,
amount DECIMAL(30, 8) NOT NULL,
trade_count BIGINT NOT NULL,
is_final BOOLEAN NOT NULL,
version BIGINT NOT NULL,
source VARCHAR(32) NOT NULL,
first_source_sequence BIGINT NULL,
last_source_sequence BIGINT NULL,
has_sequence_gap BOOLEAN NOT NULL DEFAULT FALSE,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (market, instrument_id, interval_code, open_time)
);
时序聚合、保留策略和压缩需求较多:可选择时序数据库,按时间切分数据,并根据标的做空间分区或哈希分片。
海量历史与全市场分析:可使用 ClickHouse 一类列式数据库,按月分区,并按市场、标的、周期、时间排序。版本化替换通常依赖后台合并,不保证写入后立即只有一行;在线查询最新修订时要显式处理版本,不能无条件依赖昂贵的 FINAL。
同一套排序键不能同时完美服务“单标的时间序列”和“某时刻全市场横截面”两种查询。后者可建立独立投影、物化视图或分析表。
Q39:实时 K 线如何生成和更新?
推荐链路:
行情源
-> 消息队列(按 market + instrument_id 分区)
-> 流式聚合服务
-> 实时 K 线状态/缓存
-> 周期结束后幂等落库
-> WebSocket 推送
同一标的进入同一消息分区,尽量保持相对顺序。聚合使用事件时间而非只使用接收时间,通过 watermark 或允许迟到窗口处理乱序数据。
正在形成的 K 线可保存在流处理状态或 Redis 中。每条事件先按具有完整作用域的事件标识幂等去重,再根据 event_time 更新对应窗口;乱序事件不能仅因序号较小而丢弃,因为它仍可能改变 OHLC、成交量等字段。事件序号主要用于去重、检测缺口和记录输入范围,K 线 version 则用于防止旧的聚合快照覆盖新结果。
watermark 越过允许迟到窗口后,再将该周期标记为 is_final = true,按业务唯一键幂等 UPSERT,并触发更大周期 K 线的聚合。超过允许窗口的修正应生成更高版本并保留修订记录;严格审计场景不能无痕覆盖历史结果。
Q40:多周期 K 线如何聚合?
可保存原始成交和 1 分钟基础 K 线,常用的 5 分钟、15 分钟、小时线和日线做预聚合,非常用周期在查询时由更细粒度数据生成并缓存。查询周期不能细于基础 K 线粒度;如果需要秒级或边界不对齐的任意周期,应从更细粒度 K 线或原始成交事件聚合。由基础 K 线向上聚合时,还必须保证窗口边界与交易日历一致。
聚合规则:
open = 周期内第一条有效记录的 open
high = max(high)
low = min(low)
close = 周期内最后一条有效记录的 close
volume = sum(volume)
amount = sum(amount)
trade_count = sum(trade_count)
日线和周线不能简单按固定秒数切分,必须处理交易所时区、节假日、午间休市、夜盘、夏令时和跨日交易归属。
Q41:K 线如何查询、分页和补齐缺口?
核心查询是根据市场、标的、周期和时间范围过滤,并按时间排序。时间范围使用左闭右开区间 [start, end),避免边界重复。
查询最近 N 根时,可按时间倒序取 N 条,再在返回前调整为升序。大结果集使用时间和唯一键游标,不使用深 OFFSET。
缺失数据要区分:无成交、停牌、休市和数据源丢失。若产品要求补空 K 线,可用前收盘价作为 OHLC、成交量和成交额置零,并增加状态字段标识这是填充数据,不能伪装成真实成交。
Q42:K 线如何做冷热分层和压缩?
- 热层:正在形成和近期高频访问的数据,放在流处理状态、Redis 或低延迟数据库。
- 温层:最近数月的常用历史数据,放在 MySQL、时序数据库或 ClickHouse。
- 冷层:多年历史成交和 K 线,以 Parquet 等列式文件保存到对象存储,用于回测、审计和重算。
压缩可使用时间戳差分、定点价格差分、整数变长编码、列式编码和 ZSTD/LZ4。热数据优先低延迟,冷数据优先压缩率和扫描效率。查询网关根据时间范围路由到不同层并合并结果。
Q43:K 线系统如何保证一致性、去重和可恢复?
不能只回答“消息队列提供 Exactly Once”。更稳妥的设计是:
- 按数据源协议定义的完整事件标识去重,例如
(source, sequence_scope, sequence_id);只有数据源明确保证序号全局唯一时,才简化为(source, sequence_id); - 用 K 线业务唯一键避免重复插入;
- 更新时比较
version或最大源序号,拒绝旧结果覆盖新结果; - 协调消费位点和结果写入,或依靠可重复消费与幂等写实现最终一致;
- 数据库成功但缓存失败时,以持久化数据为准并异步重建缓存;
- 定期从原始成交重算 K 线,与线上聚合结果校验;
- 校验
high >= max(open, close)、low <= min(open, close)、high >= low等业务不变量; - 保存数据源、输入序列范围、聚合程序版本、交易日历版本和修订版本。
这套设计的关键不是选了哪个数据库,而是保留可回放事实源,并让聚合、修正和缓存更新都具备幂等性与版本控制。
七、面试速答顺序
时间有限时,按以下顺序复习:
- Go:slice、map、interface nil、goroutine/channel、GMP、GC、逃逸、锁与 pprof。
- MySQL:B+ 树、联合索引、回表、MVCC、锁、三类日志、慢 SQL。
- Redis:线程模型、持久化、缓存异常、一致性、分布式锁、大 Key/热 Key。
- API 与网络:幂等、超时重试、分页、限流、TCP、HTTP/2。
- 场景题:K 线存储重点讲清数据模型、实时聚合、版本修正、冷热分层和回放校验。
真正能拉开差距的不是背出名词,而是先说明约束,再解释方案为什么成立、何时失效,以及故障发生后如何恢复。