Gline|面试追问树与参考回答
本章依据 2026-09-07 的 E:\Proj\gline-full 当前工作区编写,HEAD 为 81600c3,包含当时尚未提交的改动。检查范围是 Agent 文件采集、WAL、投递协议、Server 摄取、认证、PostgreSQL 存储、查询、观测与相关测试定义。**本次没有运行测试、Compose、故障注入或性能测试。**源码事实、设计推演和建议在下文分别标明;参考回答不能替代你对本人贡献的真实说明。
面试中建议先用 90 秒讲清可靠性链路,再由面试官选择分支。本章共有 10 个主问题、34 个追问,不是一次面试必须全部问完的题单。证据编号对应文末源码表。
90 秒项目叙述
“Gline 是面向个人项目和小型服务集群的自托管日志平台,由 Go Agent、Go Server、PostgreSQL 和管理控制台组成。我会重点介绍日志可靠投递这条链路:Agent 按文件身份和字节偏移采集完整日志行,先把批次载荷与对应采集进度共同写入带 CRC 校验的本地 WAL,Sync 成功后才进入待发送队列。网络失败时重试同一个批次 ID 和载荷,Server 在 PostgreSQL 同一事务里保存批次、日志和用量,只在事务提交成功后返回 ACK;Agent 再把 ACK 持久化,解除本地待发送状态。
“租户身份来自 API Key 对应的认证上下文,应用层检查 scope 和资源归属,数据库用复合外键防止跨项目关联。查询要求时间范围和条数上限,用时间与 ID 组成 keyset 游标。项目的价值是把崩溃、重试、幂等、租户隔离等边界连成完整链路。保证范围是同一持久化批次的重复投递不会重复落库;重新采集产生新批次、源文件已经被轮转删除、磁盘损坏等情况,需要分别讨论,不能概括为所有场景 exactly-once。”
这段只陈述项目实现。面试官问“哪些由你完成”时,应按实际情况补充本人负责的模块、关键决策、调试案例以及他人或工具参与情况,不凭代码仓库推断个人贡献。
树状索引
G1 项目要解决什么问题,为什么采用这个架构?
└─ G1.1 为什么本地 WAL,而不是内存队列或直接引入 MQ?
└─ G1.1.a 这是不是 exactly-once?
G2 采集进度和待发送数据怎样一起持久化?
├─ G2.1 为什么必须先落 WAL 再推进 checkpoint?
│ └─ G2.1.a 在三个不同位置崩溃,各会发生什么?
└─ G2.2 批次 ID、sequence 与重试载荷如何保持稳定?
└─ G2.2.a 重试次数和下次重试时间也持久化了吗?
G3 WAL 格式和恢复机制如何设计?
├─ G3.1 不完整尾记录与完整损坏记录为何不同处理?
│ └─ G3.1.a 最后一条 CRC 错了也能截掉吗?CRC 覆盖哪些字段?
└─ G3.2 File.Sync 成功是否等于任何掉电都不丢?
└─ G3.2.a compaction 为什么还要保留 checkpoint?
G4 多文件采集和日志轮转怎么处理?
├─ G4.1 为什么路径不能作为文件身份?
│ └─ G4.1.a rename/recreate 时如何避免未读完就切换?
└─ G4.2 copytruncate 和停机期间轮转还存在哪些边界?
G5 断网很久、磁盘写满或日志异常怎么办?
├─ G5.1 背压怎样传回文件读取端?
│ └─ G5.1.a 逻辑 spool 上限能否防止磁盘和内存耗尽?
└─ G5.2 解析失败和永久不合法批次如何处理?
G6 HTTP 投递怎样决定重试、隔离和停止?
├─ G6.1 429、409、401、503 分别做什么?
│ └─ G6.1.a HTTP 200 就可以删除本地批次了吗?
└─ G6.2 退避、Retry-After 和取消如何配合?
G7 Server 的事务幂等能否扛住并发重试?
├─ G7.1 两个请求同时提交相同 batch_id 会怎样?
│ ├─ G7.1.a 为什么计算 canonical hash,不直接 hash 原始 JSON?
│ └─ G7.1.b 同 ID 不同载荷,覆盖旧记录还是返回 duplicate?
└─ G7.2 COMMIT 成功但响应丢失,与 COMMIT 返回错误有何区别?
G8 多租户隔离具体由哪些层负责?
├─ G8.1 客户端修改 project_id 或 agent_id 能跨项目吗?
│ └─ G8.1.a 有复合外键就不必在 SELECT 中过滤租户了吗?
└─ G8.2 API Key 为什么使用 HMAC,传输与存储安全怎样区分?
G9 keyset 查询为什么比 OFFSET 适合日志?
├─ G9.1 相同时间戳怎么分页,索引怎样配合?
│ └─ G9.1.a 翻页期间新日志到达,会形成一致性快照吗?
├─ G9.2 游标为什么签名并绑定查询条件?
└─ G9.3 有索引就一定快吗,模糊查询怎样控制成本?
G10 怎样观测可靠性,又怎样证明实现有效?
├─ G10.1 日志停止增长时先看哪些指标?
│ └─ G10.1.a /livez 和 /readyz 分别证明什么?
└─ G10.2 Go 并发退出与资源关闭有什么顺序?
└─ G10.2.a race、重启测试与真实故障注入各能证明什么?
G1|架构与保证范围
G1:项目要解决什么问题,为什么采用这个架构?
**参考回答:**核心问题是本地服务产生的日志,在短期网络故障或进程重启后还能继续送达,并且同一批次的重试不能重复写入数据库。Agent 处理文件、解析和持久化积压;Server 按控制、摄取、查询和运维分模块,但共享进程与 PostgreSQL 事务。这个规模下模块化单体能让认证、事务和运维边界保持清晰。暂时没有证据要求拆微服务;后续应先测吞吐、WAL Sync 时延、数据库写入和查询成本,再判断是否需要独立摄取服务或分析存储。[E1、E5、E6]
G1.1:为什么本地 WAL,而不是内存队列或直接引入 MQ?
父节点:G1。触发句:“进程重启后还能继续送达”。
**参考回答:**内存队列无法保留进程退出后的积压,本地 WAL 把离线恢复能力放在日志产生侧。远端 MQ 不能消除“Agent 到网络另一端不可达”这一段风险,通常仍需要本地缓存,而且增加服务部署和运维成本。当前 WAL 还在内存中保存 pending 的载荷与索引,不能宣传成“数据全部只在磁盘、几乎不占内存”;如果积压量明显增大,可以把内存状态缩成磁盘位置索引,按需加载批次。[E1]
G1.1.a:这是不是 exactly-once?
父节点:G1.1。触发句:“本地 WAL 把离线恢复能力放在日志产生侧”。
**参考回答:**更准确是“持久化批次的至少一次投递,加上服务端批次幂等”。同一 project 和 batch ID、同一规范载荷,只产生一次数据库写入效果。重复发送日志文本但换了 batch ID,会被当作新数据;清空 checkpoint 后重新采集,也不由现有幂等键自动去重。若要实现源记录级去重,需要另定义稳定的源身份、文件代次、记录位置等契约,不能只给消息正文做 hash,否则正常重复日志会被误删。
常见错误纠偏:“UUID 保证日志不重复”不成立。UUID 解决批次身份冲突;重复检测依赖稳定复用身份、数据库唯一约束和载荷一致性校验。[E2、E6]
G2|checkpoint、批次与崩溃边界
G2:采集进度和待发送数据怎样一起持久化?
**参考回答:**每个 RawRecord 带 source_key、file_identity 和行末字节偏移。读取端只在读到换行符后产出记录;Pipeline 按条数或时间聚合,把批次 payload 和最后一条记录对应 checkpoint 放在同一个 WAL commit record。WAL 先追加并 Sync,再更新内存 pending 和 checkpoint。启动时重放 WAL,恢复各 source 的位置及未 ACK 批次;读取进度与发送完成进度是两个概念,后者由 ACK 表示。[E1、E2、E4]
G2.1:为什么必须先落 WAL 再推进 checkpoint?
父节点:G2。触发句:“把 payload 和 checkpoint 放在同一个 commit record”。
**参考回答:**如果先记录 offset=1000,再持久化对应日志,进程在两步之间退出,重启会跳过尚未保留的数据。反过来,数据和位置共同形成完整记录,在记录可恢复后才发布新位置,就不会出现“位置已前进,但没有对应待发送载荷”的正常提交状态。这里的共同持久化是应用层记录与恢复协议,并不意味着磁盘能原子写完任意长度的一条记录;半条记录要交给恢复逻辑识别。[E1]
G2.1.a:在三个不同位置崩溃,各会发生什么?
父节点:G2.1。触发句:“半条记录要交给恢复逻辑识别”。
**参考回答:**第一,已经读完行但尚未形成可恢复 WAL 记录:从旧 checkpoint 重读,前提是源文件仍在。第二,WAL 完整保存但内存状态尚未更新:重启重放该记录,恢复 payload 和 checkpoint。第三,Server 已提交但 Agent 的 ACK 尚未持久化:恢复后重发原批次,Server 返回 duplicate,再落本地 ACK。Sync 返回前崩溃,不能武断说一定存在或一定丢失,需要看恢复时记录是否完整有效;我们允许重放,禁止未经可靠确认就跳过数据。[E1、E2、E6]
G2.2:批次 ID、sequence 与重试载荷如何保持稳定?
父节点:G2。触发句:“恢复未 ACK 批次”。
**参考回答:**buildBatch 为新批次生成随机 UUID,批内 sequence 从 0 连续编号;批次级 sequence 当前使用该批次结束处的文件字节偏移,并非全局单调消息编号,轮转后也可能回到小值。构建时冻结 sent_at 和 payload,WAL 保存这些字节,Dispatcher 每次直接发保存的 payload。因此重试不重新生成 UUID、时间戳或重新分批,否则相同逻辑日志会失去原来的幂等身份。[E2]
G2.2.a:重试次数和下次重试时间也持久化了吗?
父节点:G2.2。触发句:“WAL 保存这些字节”。
**参考回答:**没有。当前持久化的是批次、checkpoint、ACK 和 quarantine 状态;attempts 与 blockedUntil 是 Dispatcher.Run 内的内存 map。重启后批次仍在,但退避进度从头开始。这不会破坏批次幂等,却可能让大量 Agent 同时重启时产生重试突峰。要解决应增加启动抖动,或在有明确需求时持久化必要的调度状态;不能把“重试载荷持久化”说成“整个重试状态机持久化”。[E1、E2]
G3|WAL 格式、损坏与落盘
G3:WAL 格式和恢复机制如何设计?
**参考回答:**每条记录有 16 字节头:4 字节 magic、1 字节版本、1 字节类型、2 字节保留位、4 字节大端 payload 长度、4 字节 CRC32。类型区分 commit、ACK、checkpoint 和 quarantine;CRC 覆盖类型字节与 body。重放时先校验头和长度上限,再读取 body、校验 CRC、解码并应用状态。长度限制还能防止损坏长度导致无界分配。当前格式是版本 1,但存在版本号不等于已经有版本迁移实现。[E3]
G3.1:不完整尾记录与完整损坏记录为何不同处理?
父节点:G3。触发句:“先校验头和长度上限”。
**参考回答:**末尾不足一个头,或者头合法但剩余字节不足声明的 body 长度,符合追加过程中被打断的情况,当前恢复会截到上一条完整记录结束处并 Sync。完整记录发生 CRC 错误、格式异常或语义不合法则报 ErrCorrupt,拒绝启动,避免假装恢复成功却跳过有效数据。不能通过搜索下一个 magic 就声称中段已修复,因为 payload 中也可能出现相同字节,而且前后状态依赖可能已经断裂。[E3]
G3.1.a:最后一条 CRC 错了也能截掉吗?CRC 覆盖哪些字段?
父节点:G3.1。触发句:“完整记录发生 CRC 错误则拒绝启动”。
**参考回答:**按当前代码,即便是最后一条完整记录,CRC 不一致也返回 ErrCorrupt;它无法证明这条记录只是未完成追加,所以不自动删除。CRC 是偶发损坏检测,不是恶意篡改认证。另一个限制是长度字段未纳入 CRC;一个被改坏但仍在允许范围的长度,可能被误判为短尾,因此不能宣称对所有中段损坏都能精确分类。更强的格式可把版本和长度纳入校验,并加入记录序号,但那需要格式升级。[E3]
**常见错误纠偏:**简历的“WAL 尾部损坏恢复”建议精确成“WAL 不完整尾记录截断恢复,完整校验失败时拒绝启动”。“CRC 校验失败就截断”与当前实现不符。
G3.2:File.Sync 成功是否等于任何掉电都不丢?
父节点:G3。触发句:“CRC 校验与恢复”。
**参考回答:**Sync 请求把文件内容提交到稳定存储,比只 Write 到操作系统缓存更强;CRC 检测与 Sync 持久性是两件事。实际掉电恢复仍依赖文件系统、设备及写缓存行为,也要处理文件新建、目录项和替换的持久化。当前每条 WAL 记录都会 Sync,但 Windows 的 syncParent 直接返回,compaction 也忽略父目录 Sync 错误,所以我能解释现有恢复协议,不能承诺任意掉电、磁盘损坏下零损失。[E3;Go 的语义见 File.Sync 官方文档]
G3.2.a:compaction 为什么还要保留 checkpoint?
父节点:G3.2。触发句:“也要处理文件替换的持久化”。
**参考回答:**ACK 后批次可以不再重发,但源文件已经读到的位置不能忘。compaction 写出 pending、quarantine,再写最新 checkpoint 快照,后者放最后,避免旧 pending 批次携带的位置覆盖更新的轮转状态。新文件 Sync 后,经 .bak 和 .compact 替换原文件,启动时处理遗留替换文件。这是回收空间,不是 ACK 正确性的前提;ACK 本身已落盘后,普通回收失败不应让调用方无限重发。不过该替换协议的掉电保证仍受上一问的限制。[E3]
G4|文件身份和轮转
G4:多文件采集和日志轮转怎么处理?
**参考回答:**当前以多个 Pipeline 对应多个 DurableFileSource,共享一个 WAL;每个 source_key 对应独立 checkpoint。checkpoint 使用文件身份和字节偏移,而不只保存路径或行数。启动时先恢复 WAL,再打开文件并比较身份;需要进入新文件位置时,先持久化 checkpoint transition,再开始读。多文件并发共享 WAL 锁意味着写入顺序是全局序列,但不等于不同文件中的业务事件有全局时间顺序。[E1、E4]
G4.1:为什么路径不能作为文件身份?
父节点:G4。触发句:“比较文件身份”。
**参考回答:**app.log 被重命名后,同一路径可能指向一个新文件。若继续使用旧 offset,会跳过新文件头部。当前 Unix 用设备号与 inode,Windows 用卷序列号与文件索引识别文件;Windows 打开文件时允许读、写和删除共享,才能在持有句柄时配合轮转。文件身份也不是永不复用的业务记录 ID,仍需结合轮转策略理解。[E4]
G4.1.a:rename/recreate 时如何避免未读完就切换?
父节点:G4.1。触发句:“持有句柄时配合轮转”。
**参考回答:**读取端继续读旧句柄,在 EOF 检查路径是否已指向另一文件,并经过连续两次轮转观察后通知上层。Pipeline 先 flush 已读完整行,再用旧 checkpoint 做 CAS 持久化新身份、offset=0,成功后才替换句柄。旧文件还有未终止的半行时直接报错,不把两个文件内容拼在一起。这里的“两次观察”是工程策略,不保证任意延迟写旧文件都被收齐;生产接入必须约定轮转后旧文件何时停止写、保留多久。[E4]
G4.2:copytruncate 和停机期间轮转还存在哪些边界?
父节点:G4。触发句:“需要进入新文件位置”。
**参考回答:**copytruncate 保持文件身份,当前在发现文件大小小于读偏移时,从零开始并丢弃未终止片段。如果截断后又快速写到大于原偏移,轮询可能看不到变小过程;这不是单靠 offset 能彻底解决的问题。Agent 停机期间发生 rename/recreate,当前会打开新路径的新文件,并不会自动遍历全部旧轮转文件补读。因而恢复依赖源日志保留策略,不能把“WAL 可恢复”扩展成“所有尚未进入 WAL 的旧文件数据都可恢复”。[E4]
G5|背压和坏数据
G5:断网很久、磁盘写满或日志异常怎么办?
**参考回答:**先区分三个层次:网络故障保留 pending 并退避;达到配置的逻辑 spool 容量时阻塞新的 commit,反向降低采集速度;真实磁盘写入或 Sync 错误则报错,不能把失败伪装成已采集。永久不合法批次进入持久化 quarantine,避免阻塞正常投递,但仍占容量。这些机制使故障可见、积压可控,并不意味着无论故障持续多久都不会丢源日志。[E1、E2]
G5.1:背压怎样传回文件读取端?
父节点:G5。触发句:“反向降低采集速度”。
**参考回答:**flush 遇到 ErrFull 会等待 WAL 变化通知或 context 取消,不清空当前 entries。Pipeline 因而不能继续消费 records;records 是无缓冲 channel,source goroutine 也会在交付记录处阻塞。已读但没提交的 offset 不成为持久 checkpoint,重启可以重读。传导靠阻塞与有限批次容量实现;它并没有通知产生日志的业务进程停止写日志,文件仍可能继续增大或被轮转删除。[E2]
G5.1.a:逻辑 spool 上限能否防止磁盘和内存耗尽?
父节点:G5.1。触发句:“有限批次容量”。
**参考回答:**不能简单等同。UsedBytes 统计保留的 commit 记录,ACK 和 checkpoint 等历史记录也占物理文件空间,回收还需要临时空间;pending payload 同时在内存里。当前读取长行使用 ReadString 和 strings.Builder,没有明确的行长度上限。批次主要按条数或时间切分,也没有完整的按总字节自适应拆分:单批大于 spool 总上限时,等待空间也解决不了。实际要补足行/批次字节限制、物理磁盘水位和容量预留,再分别测 ENOSPC 与积压内存。[E1、E2、E4]
G5.2:解析失败和永久不合法批次如何处理?
父节点:G5。触发句:“永久不合法批次进入 quarantine”。
**参考回答:**parser 失败时当前保留原始消息,把内部等级设为 Unknown,编码时映射到协议支持的 INFO,并用 gline.original_level 保留原等级信息,不直接丢弃这行。如果 Server 判定某个不可变批次永久无效,Dispatcher 持久化 quarantine 后跳过它,载荷继续占容量。不能悄悄修改旧 batch ID 下的 payload 再重发,那会触发幂等冲突;如需修复重投,应设计显式处置、保留原批次关联并生成新身份。当前 Agent CLI 提供查看与显式丢弃能力,不能说已有完整自动修复重放流程。[E2、E3、E11]
G6|HTTP 错误分类和取消
G6:HTTP 投递怎样决定重试、隔离和停止?
**参考回答:**按错误影响范围分层。临时网络或服务容量问题保留批次重试;不可变载荷自身有问题就 quarantine,继续其他批次;凭据、权限或端点配置这类系统性错误停止 Dispatcher,保留 pending 供修复后恢复;人为禁用资源则暂缓该批次,让其他 Pipeline 继续。对结果分类比“所有非 200 都重试”更重要。[E2]
G6.1:429、409、401、503 分别做什么?
父节点:G6。触发句:“按错误影响范围分层”。
**参考回答:**当前分类如下;这是项目协议,不能当成所有系统的统一规定。
| 结果 | 当前动作 | 原因 |
|---|---|---|
| 网络错误、408、429、5xx | retryable | 可能恢复,保留同一批次 |
| 409 且 code=resource_unavailable | blocked | 资源禁用等状态,等待恢复并让其他批次先发 |
| 400、其他 409、413、422 | quarantine | 当前批次按此协议无法直接成功 |
| 401、403、404 等其余失败 | terminal | 优先修复认证、权限或端点配置 |
特别是 409 既可能表示状态暂不可用,也可能是 payload 冲突,所以需要机器可读 code,不能只看状态码。[E2]
G6.1.a:HTTP 200 就可以删除本地批次了吗?
父节点:G6.1。触发句:“保留同一批次”。
**参考回答:**不能。当前 2xx 还要解码 ACK,batch_id 必须匹配本次请求,status 必须是 accepted 或 duplicate;空响应、错误 ID、未知状态都按可重试处理。匹配后也不是立即物理删除 WAL,而是先追加并 Sync 本地 ACK 记录,再移除 pending、释放逻辑占用,之后才可能 compaction。这样进程在收到响应与本地落 ACK 之间崩溃,最多重试,不会把没被确认的批次丢掉。[E1、E2]
G6.2:退避、Retry-After 和取消如何配合?
父节点:G6。触发句:“临时问题保留批次重试”。
**参考回答:**当前指数退避带抖动,记录每批 blockedUntil,挑选到期或未阻塞的批次,发送本身是串行的;这避免一个等待退避的批次挡住全部 Pipeline,但单个在途慢请求仍会占住 Dispatcher。Retry-After 支持秒数或 HTTP 日期,当前取它与本地退避的较大者,最后仍截到 MaxDelay,因此不能声称无条件完整遵守更长的服务端等待时间。发送用请求 context,等待用 timer/select;观察到取消后结束投递循环,留下未 ACK WAL,下一次启动继续。[E2]
G7|PostgreSQL 事务与幂等
G7:Server 的事务幂等能否扛住并发重试?
**参考回答:**身份是认证项目加 batch ID,载荷一致性由服务端规范化后计算 SHA-256 校验。事务中检查项目、Agent、Pipeline 状态,插入 ingest_batches;插入成功才写 log_entries 和用量;重复则读取已有 hash 验证,不重复写日志或增加用量。事务 runner 明确用 Read Committed,只有回调和 Commit 都成功后 Service 才返回结果,HTTP handler 再回 ACK。[E5、E6]
G7.1:两个请求同时提交相同 batch_id 会怎样?
父节点:G7。触发句:“重复则读取已有 hash”。
**参考回答:**不能用“先 SELECT 不存在,再 INSERT”作为并发正确性保障。这里靠 (project_id,id) 主键和 INSERT ON CONFLICT DO NOTHING 仲裁。A 尚未结束时,B 的同键写入可能等待;A 提交后 B 不插入,下一条 SELECT 在 Read Committed 的新语句快照里读取 A 的已提交行,再比 hash;A 回滚则 B 可继续插入。唯一约束和事务保证只有成功插入者写 entries。这个推演依赖实际 SQL 和隔离级别;现有已检查的 PostgreSQL 测试验证顺序重试,没有等价于双连接竞争测试的证据。[E5、E6、E11;参见 PostgreSQL 事务隔离文档]
G7.1.a:为什么计算 canonical hash,不直接 hash 原始 JSON?
父节点:G7.1。触发句:“读取已提交行,再比 hash”。
**参考回答:**同一含义的 JSON 可能有不同空白、对象键顺序、时区表示和数字写法,直接 hash 原文会产生假冲突。当前服务端统一时间为 UTC,规范 level、service、host,按固定字段顺序编码,递归排序属性键;Decode 使用 json.Number,十进制规范化不经 float64,避免大整数精度丢失后错误等同。它是项目自己的 canonical 契约,不应宣传为实现了某个未验证的标准。sent_at 参与 hash,所以每次重试更新时间仍然会造成冲突。[E5]
G7.1.b:同 ID 不同载荷,覆盖旧记录还是返回 duplicate?
父节点:G7.1。触发句:“再比 hash”。
**参考回答:**返回幂等冲突,不能覆盖也不能当成重复成功。相同身份意味着调用方声明“这是原来那次操作”;载荷不同说明身份复用或客户端实现有错。覆盖旧记录会改写已确认事实,直接 duplicate 又会隐瞒本次内容没被处理。当前 VerifyRetry 检查 committed 状态和 hash,一致才 duplicate,不一致返回 ErrIdempotencyConflict;Agent 收到相应 409 后进入 quarantine。[E2、E6]
G7.2:COMMIT 成功但响应丢失,与 COMMIT 返回错误有何区别?
父节点:G7。触发句:“Commit 成功后才返回结果”。
**参考回答:**对 Agent 来说,超时或连接中断通常只说明没有获得可靠确认,无法断言 Server 未提交。Server 的 Commit 返回连接错误时,也可能存在结果不确定性。因此不能直接生成新批次重试,而应保持身份与载荷,重新请求或查询提交状态。若已提交则 duplicate,未提交则正常 accepted。Agent 本地 ACK 持久化失败同样保留重发能力。至于数据库崩溃后的持久性,还取决于 PostgreSQL 的持久化配置与部署,本次没有检查运行实例配置,不能把“代码调用 Commit”当成任意部署上的零丢失承诺。[E1、E5、E6]
G8|认证与多租户边界
G8:多租户隔离具体由哪些层负责?
**参考回答:**认证层用 API Key 解析出 Principal;授权层检查 scope、project 和可选 Agent 绑定;Service 把认证项目注入领域对象并校验资源归属;SQL 的读写都带项目条件;数据库用复合外键约束 project、Agent、Pipeline、batch 的关联一致性。几个层次解决不同问题:有权调用接口、只能操作自己的资源,以及数据库不会保存跨租户错误关联,不能用一个“做了多租户”代替解释。[E5、E6、E7]
G8.1:客户端修改 project_id 或 agent_id 能跨项目吗?
父节点:G8。触发句:“认证项目注入领域对象”。
**参考回答:**摄取协议根本没有让客户端选择项目的字段,严格解码拒绝未知字段;Normalize 接收的是认证得到的 ProjectID。Service 再检查候选对象的项目和 Agent 绑定,按本项目获取 Agent、Pipeline,检查 Pipeline 属于该 Agent。查询若显式传 project_id,也要和普通 Key 的项目一致;bootstrap 管理凭据是明确的特殊权限,可选择管理项目,但当前摄取 handler 禁止 bootstrap 直接上报批次。服务端信任边界应以这些检查为准,不依赖前端隐藏选项。[E5、E7]
G8.1.a:有复合外键就不必在 SELECT 中过滤租户了吗?
父节点:G8.1。触发句:“按本项目获取 Agent、Pipeline”。
**参考回答:**仍然必须过滤。外键保证写入关系合法,不会自动限制 SELECT 返回哪些行。当前共享表、共享数据库连接的隔离,依赖应用层授权和所有查询显式 project_id 条件;已检查迁移没有用 RLS 代替它。如果未来用 RLS,可作为额外防线,但要正确设置事务级租户上下文并处理连接池复用。隔离读写权限也不等于隔离资源消耗,单个项目打满数据库仍需单独的准入和容量控制。
常见错误纠偏:“数据库里每行有 project_id,所以已经隔离”不充分。必须指出输入身份从哪里来、查询如何限制、跨项目关系怎样被拒绝。[E6、E7、E8]
G8.2:API Key 为什么使用 HMAC,传输与存储安全怎样区分?
父节点:G8。触发句:“认证层用 API Key”。
**参考回答:**Key 由可查找的 prefix 与高熵 secret 组成,数据库保存 secret 的 HMAC-SHA256,pepper 留在服务端配置,比较时使用常量时间比较,并检查状态、过期时间和 scope。它不是可逆加密;高熵 API secret 与用户低熵密码的威胁模型不同,不能照搬成“密码也用快速 SHA-256 就好”。HMAC 主要约束数据库凭据材料泄露后的风险,不能保护传输链路,也防不住同时拿到应用密钥和数据库的攻击者。当前 HTTP 客户端不会强制 HTTPS,跨不可信网络时仍应由部署提供 TLS。[E7]
G9|keyset、索引与查询边界
G9:keyset 查询为什么比 OFFSET 适合日志?
**参考回答:**日志往往持续写入,深 OFFSET 需要跳过大量已有结果,新增或删除还会改变页面位置。keyset 保存上一页最后一个排序键,下一页从它之后继续范围扫描,减少随页数增长的跳过成本。当前按 observed_at DESC、id DESC 排序,要求 [from,to) 时间范围,有条数限制、执行超时和项目查询并发限制。它解决分页定位和成本问题,不自动提供跨请求一致性快照。[E8]
G9.1:相同时间戳怎么分页,索引怎样配合?
父节点:G9。触发句:“按 observed_at DESC、id DESC”。
**参考回答:**时间戳可能相同,所以再用唯一 ID 构成严格全序。下一页条件是 (observed_at,id) 小于上页最后一行的二元组,两个字段都使用降序。SQL 多取 limit+1 条判定是否还有下一页,游标用真正返回的最后一行生成。基础索引是 (project_id,observed_at DESC,id DESC),service 和 level 有各自带时间的复合索引,使租户等值条件与排序范围尽量匹配。[E6、E8]
G9.1.a:翻页期间新日志到达,会形成一致性快照吗?
父节点:G9.1。触发句:“下一页从最后一行继续”。
**参考回答:**不会。新写入但 observed_at 更早的延迟日志,可能落在游标之后而在后续页出现;落在已经走过的位置,则这次翻页看不到。记录删除也会改变可见集合。当前查询没有跨请求快照。如果需要一致导出,可设计数据库快照或物化导出任务;只在首屏记一个 max(id) 也不是严格提交水位,因为序列分配顺序不保证事务提交顺序。实时浏览和一致导出应定义不同契约。[E8;序列不随事务回滚的语义见 PostgreSQL 事务隔离文档]
G9.2:游标为什么签名并绑定查询条件?
父节点:G9。触发句:“keyset 保存排序键”。
**参考回答:**当前游标包含版本、project_id、规范化过滤条件 hash、时间和 ID,JSON 经 Base64URL 编码后附 HMAC。服务端校验签名、项目、过滤 hash 和时间范围,防止伪造位置、跨项目或更换筛选条件继续翻页。HMAC 不提供保密性,Base64 也不是加密,不能在游标中放秘密。签名更不能代替 SQL 的项目过滤,即便校验失败处理有漏洞,数据库查询仍应带认证项目。[E8]
G9.3:有索引就一定快吗,模糊查询怎样控制成本?
父节点:G9。触发句:“减少跳过成本”。
**参考回答:**不一定。当前 message 使用带转义的 ILIKE '%内容%',普通 B-tree 不能解决任意子串检索;service 与 level 组合过滤也不保证单个索引包办所有条件。先用真实数据的 EXPLAIN ANALYZE、BUFFERS 看扫描行数、排序、回表与耗时,再考虑 trigram、全文检索或专用分析引擎。现有默认查询范围最多 7 天、最多 500 条、执行预算 10 秒,并发限制是进程内按项目计数;多实例部署时它不是全局限流,不应这样宣传。[E8]
G10|观测、退出和验证
G10:怎样观测可靠性,又怎样证明实现有效?
**参考回答:**要把“源记录产生、进入 WAL、尝试投递、数据库接受、查询可见”分开观测。单看 HTTP 200 或进程存在,无法证明日志持续送达。验证也分协议与状态机测试、WAL 文件恢复测试、真实 PostgreSQL 集成、运行进程故障注入以及长期压力测试。当前仓库具备多层测试定义;本章只读了实现和测试,不能替我宣称当前工作区全量测试已通过。[E9、E10、E11]
G10.1:日志停止增长时先看哪些指标?
父节点:G10。触发句:“分开观测各阶段”。
**参考回答:**先看 Agent pipeline_up 与 records_read_total 是否增长,区分没读到与没送到;再看 spool_bytes、spool_batches、oldest_pending_seconds、quarantined_batches;结合 send_attempts_total 的 retryable、blocked、terminal 分类定位网络、配置或坏批次。Server 侧看摄取结果/耗时、查询耗时和数据库池等待。注意 duplicate 也会计入按结果分类的观测计数,算新增日志速率应看 accepted;spool_bytes 是逻辑占用,不能当磁盘文件大小。[E9]
G10.1.a:/livez 和 /readyz 分别证明什么?
父节点:G10.1。触发句:“区分没读到与没送到”。
**参考回答:**Server /livez 当前返回进程 HTTP 健康响应;/readyz 检查 draining 状态并在超时内 Ping 数据库,失败返回 503。数据库不可用不等于进程必须重启,探针区分有助于避免无效重启。ready 也只证明当时数据库可达,不证明当前 schema、全部 Pipeline 或整条日志链路均正常。Agent 的可选运维端口目前提供 /livez 和 /metrics,没有等价的 /readyz;全 Pipeline 失败时仍可能进程存活,必须结合 pipeline_up 判断。[E9、E10]
G10.2:Go 并发退出与资源关闭有什么顺序?
父节点:G10。触发句:“进程存在不等于链路有效”。
**参考回答:**原则上是停止接纳、取消任务、等待使用资源的 goroutine 结束、最后关闭共享资源。Agent 会取消运行 context,尽力用脱离原取消但有 2 秒期限的 context,把内存剩余批次写入 WAL,然后等待 Pipeline、Dispatcher 和心跳退出并关闭 WAL;这不是“停机前保证所有批次已送到 Server”。Server 有 draining 与 HTTP Shutdown,但当前源码只等待 maintenance、alerts 中一个后台结果,随后就关闭 store,未实现对两个 worker 都完整 join 的保证;这是应继续修正的生命周期边界,面试时不能用理想设计替现状作答。[E2、E10]
G10.2.a:race、重启测试与真实故障注入各能证明什么?
父节点:G10.2。触发句:“应继续修正的生命周期边界”。
**参考回答:**race detector 帮助发现实际运行路径上的未同步并发访问,既不证明未执行路径无 race,也不证明没有死锁、丢 ACK 或协议漏洞。当前 WAL 测试会写入、关闭、重开和注入损坏;Server outage 集成测试用真实 PostgreSQL 加 HTTP 503 开关验证积压继续发送,但没有真的杀 Agent 进程,也不等价于物理断网或掉电。后续高价值验证是:双连接同批次竞争;提交后丢 ACK;子进程在 WAL/ACK 边界被强杀后恢复;ENOSPC 和超长行;两个后台 worker 分别延迟退出。只跑 go test -race ./... 还不会包括带 integration 标签的测试。[E11;见 Go race detector 官方说明]
简历主张核对与建议
| 简历主张 | 当前源码支持程度 | 更稳妥的表达 |
|---|---|---|
| 带 CRC 的 WAL 持久化多文件进度及待发送日志 | 支持:多个 source_key、commit 与 checkpoint 同记录、Sync 后发布 | 可以保留,并准备解释完整行边界和内存积压成本 |
| 进程崩溃恢复、断点续传 | 恢复协议存在;本次没有强杀进程验证,源文件仍存在是关键前提 | “基于 WAL 重放与文件身份/字节偏移 checkpoint 支持重启恢复” |
| 版本化批量上传 | 有 protocol_version=1 和格式版本检查 | 可以保留;不要扩展成已经实现多版本迁移与兼容协商 |
| 提交后 ACK,重复上传无重复日志 | 支持同项目、同 ID、同规范载荷的重试幂等 | 明确“同批次重试不重复落库”,避免被理解为任意重采集去重 |
| API Key 与复合租户外键隔离 | 支持,同时依赖 Service 校验和 SQL 项目过滤 | 可以保留;不要说仅靠外键就隔离所有读写 |
| keyset 日志分页 | 支持二元排序键、签名游标和查询条件绑定 | 可以保留;不要承诺跨页实时数据的一致性快照 |
| 尾部损坏、进程崩溃、断网场景验证 | 可读到短尾修复、完整损坏拒绝、关闭重开、HTTP 503 恢复测试;本次未运行 | 用真实测试记录限定“模拟短尾写入中断、服务暂不可用及重试恢复”;有真实强杀记录后再写“进程崩溃验证” |
| /livez、/readyz 与 Prometheus 观测 | Server 两探针和指标存在;Agent 是可选 /livez 与 /metrics | 可以保留,但应能解释探针归属与各自证明范围 |
可以把第三条改为:“围绕 WAL 不完整尾记录、服务暂不可用与重复投递设计恢复验证;通过 Agent 积压/重试指标、Server 存活与数据库就绪探针观测链路状态。” 若你确实保留了真实断网、强杀进程的独立验证记录,可补回对应动作与结果,而不是为了措辞保守删去已有事实。
源码证据与本次未验证范围
行号对应上述工作区快照,仓库后续修改后可能移动。下表给出核对入口,正文的保证范围应以实际代码和运行证据共同判断。
| 编号 | 文件与位置(均相对 E:\Proj\gline-full) | 支撑内容 |
|---|---|---|
| E1 | internal/agent/spool/wal.go:50、156、187、258、482、524 | commit 含 payload/checkpoint;Sync 后应用;ACK 持久化后解除 pending;内存保存 payload |
| E2 | internal/agent/reliable/agent.go:172、206、236、273;internal/agent/reliable/batch.go:16;internal/agent/reliable/dispatcher.go:68、135、202;internal/agent/reliable/transport.go:61、85、123 | 批次构建、背压、停止时落 WAL、固定载荷、内存重试调度、响应分类与 ACK 校验 |
| E3 | internal/agent/spool/wal.go:390、549、559、637、663、741、768、789、811 | 不完整尾截断、CRC 失败拒绝、compaction、校验覆盖范围、替换恢复和父目录同步边界 |
| E4 | internal/agent/source/durable_file.go:27、87、123、158、195;internal/agent/source/identity_windows.go:11;internal/agent/source/identity_unix.go:11;internal/agent/source/open_windows.go:10;internal/agent/build/reliable.go:47 | 完整行、文件身份、启动恢复、轮转、copytruncate、Windows 文件共享方式 |
| E5 | internal/protocol/ingestv1/protocol.go:30、62、84、117、192、281、357;internal/server/httpapi/handlers.go:369;internal/server/ingest/service.go:110、160 | 严格版本化协议、规范化和数字精度、认证项目注入、事务摄取入口 |
| E6 | internal/server/bootstrap/transactions.go:32;internal/storage/postgres/ingest.go:18、43、51;internal/domain/models.go:202;migrations/0002_agents_pipelines_keys.up.sql:16;migrations/0003_ingest_batches.up.sql:14;migrations/0004_log_entries.up.sql:15、22 | Read Committed、ON CONFLICT、hash 验证、事务内日志写入、复合键与查询索引 |
| E7 | internal/server/auth/authenticator.go:76、97、105;internal/server/auth/principal.go:17、58、65、72;internal/server/httpapi/middleware.go:100、183;internal/server/httpapi/handlers.go:637 | Key 验证、scope、项目/Agent 归属、bootstrap 特权边界和查询项目检查 |
| E8 | internal/server/query/service.go:56、135、329、420、437、468;internal/storage/postgres/entry.go:15、58;internal/server/bootstrap/limiter.go:25 | 查询上限、签名游标、过滤指纹、二元 keyset、ILIKE、进程内项目并发限制 |
| E9 | internal/agent/observability/metrics.go:18、67;internal/server/observability/server.go:26、101、129;internal/server/observability/http.go:17、36;internal/agent/build/reliable.go:116 | Agent/Server 指标含义、低基数标签、逻辑容量与可选 Agent 运维端点 |
| E10 | internal/server/httpapi/handlers.go:19、26;internal/server/bootstrap/application.go:188、199、218;internal/agent/reliable/agent.go:78、112、139、236 | Server 探针、draining、关闭顺序与未完整等待两个后台 worker 的边界;Agent 退出 |
| E11 | internal/agent/spool/wal_test.go:16、45、85、119、224、257、298;internal/agent/reliable/transport_test.go:113、145、174、193;internal/server/ingest/service_test.go:60、77;internal/storage/postgres/postgres_integration_test.go:23;internal/server/bootstrap/application_integration_test.go:224、301、349;cmd/agent/main_test.go:17;.github/workflows/ci.yml:64、134 | 恢复、错误分类、提交失败、顺序幂等与租户约束测试;503 积压恢复;integration 与 race 的执行边界 |
**本次已做:**只读 AGENTS.md、Git 状态、相关当前实现、数据库迁移及测试定义,并核对官方 Go/PostgreSQL 语义。没有修改项目文件,没有查看本地凭据内容。
**本次未做:**未运行现有测试、race、真实 PostgreSQL 集成、压测、进程强杀、网络故障、磁盘写满、掉电及 UI 验收;没有把 STATUS.md、DEVLOG.md 中的历史结果当作本次验证。面试时需要以你能展示的真实记录补足这些证据,不填写未经验证的吞吐量、零丢失比例或生产规模。