《游戏服务端架构·规模化、全球化与成本》10.3 日志留存、审计与取证

安全审计、支付、战斗和行为日志的保留期不同,冷热分层与不可改写要求也不同。本文按日志类别配置留存参数,讨论热存、冷存、归档与到期清理的边界,用追加写和哈希链检测篡改,说明取证导出时的授权分离、脱敏与清单摘要,并附 internal/auditretention 的 Go 实现与六组表驱动测试。

一次退款纠纷,四类日志

《泡泡工坊》的一笔退款纠纷把四个团队拉进同一个工单。财务要看支付回调与退款状态,安全团队要看登录设备和异常行为,运营要看玩家当天的对局记录,客服要确认工单本身有没有被改过。

四类日志都在,但它们的保留期、存储介质和访问权限完全不同。支付日志按财务参数保留,放在可查询的热存储;安全日志需要更长的窗口,冷热分层;战斗日志量大,归档后只保留聚合与回放索引;客服工单属于行为记录,既要能查,又不能被客服自己改写。

更麻烦的是取证。安全团队要按玩家导出登录记录,财务要按订单导出支付流水,法务要按时间窗口导出全部相关事件。三个请求都叫“导出日志”,但如果都走同一条 SQL,脱敏、授权和清单格式一定有一个是错的。

这一节讨论日志留存、防篡改和取证的工程做法,不解读具体法律条文,也不构成法律意见。每类日志保留多久、哪些字段属于敏感信息、谁有权批准导出,都是外部输入;服务端要做的是让这些输入可配置、可验证、可追溯。

留存分级:先分类,再谈成本

把所有日志塞进一个索引,最常见的结局是:热存储成本失控,冷存储没人查,到期清理不敢动。北辰按“谁会用、用来回答什么问题”把日志分成四类。

类别典型内容主要使用者存储策略
security_audit登录、权限变更、后台操作安全与审计热存短、冷存长,禁止改写
payment下单、回调、退款、对账财务与客服热存可查,按财务参数保留
combat对局开始、结算、异常运营与反作弊热存短,归档保留索引
behavior点击、页面、客服工单运营与产品聚合优先,明细可匿名化

保留参数按类别配置,不在代码里写死。每类有三个时间点:热存窗口、冷存窗口、总保留期。超过热存窗口进入冷存,超过总保留期才允许清理;三个值必须单调递增,配置加载时校验,避免“冷存比热存还短”这种看起来能跑、实际永远命不中冷存的配置。

到期清理要单独授权。清理任务只读策略,不读业务代码里的常量;某类日志被法律保留时,策略上的 hold 标记会让 Purgeable 返回 false,清理任务即使跑到了总保留期也不能删。hold 的开关是一次可审计的人工动作,不能由定时任务自动解除。

成本优化发生在分层,不发生在“少记”。安全审计和支付日志的价值在完整,不在样本;战斗日志可以先聚合再归档;行为日志可以只保留聚合指标和少量样本。把“降本”做成“随机丢 90% 日志”,出事时既证明不了访问,也还原不了现场。

分层边界要按查询模式定,而不是按天数拍脑袋。安全调查通常在事件发生后的一小段时间内最频繁,热存窗口应覆盖这段高频期;支付对账集中在结算周期,冷存要保证按订单号可查;战斗日志的峰值在赛季末,归档后保留索引比保留全量原文更划算。

查询场景主要命中的层索引键可接受延迟
客服查玩家最近行为热存账号 ID + 时间秒级
财务按订单对账热存或冷存订单号分钟级
安全事件回溯冷存 + 归档账号 ID / 设备小时级
法务按窗口调取归档时间范围天级

这张表也解释了为什么“把热存调短”不能单独作为降本手段:如果冷存没有按订单号建索引,财务的查询会退化成全量扫描,成本从存储转移到了计算,总账未必下降。

防篡改:追加写、哈希链与 WORM

日志系统最危险的假设是“写进去就不会变”。运维有删库权限,应用有更新权限,对象存储的管理员可以覆盖对象;如果没有校验,任何一次误操作或内部人操作都无法被发现。

第一层是追加写。应用只有 append 权限,没有 update 和 delete 权限;写入接口不接受“修改第 N 条”,只接受“追加一条”。这一层挡的是应用 bug 和普通权限滥用,挡不住存储管理员。

第二层是哈希链。每条记录包含序号、时间、类别、操作者、动作、对象、结构化字段、前一条的哈希,以及自身内容的哈希。自身哈希覆盖前面所有字段,因此修改任意字段都会导致校验失败;前一条哈希把记录串成链,删除或重排中间一条会让后续链接断开。

第三层是 WORM 或对象锁。归档对象写入后进入不可覆盖状态,保留期由存储策略控制。哈希链能发现篡改,WORM 能阻止篡改;两者解决的是不同问题,不能互相替代。

机制挡住什么挡不住什么代价
追加写权限应用误改、普通账号删除存储管理员低
哈希链字段修改、删除、重排整链重算(若攻击者可写全部)中
WORM / 对象锁覆盖与提前删除配置错误、保留期设置过短高
外部见证整链重算见证服务自身被控中高

哈希链的校验要保存“断点”。生产环境不会每次从头校验全链,通常按天或按批次做锚点,把锚点哈希写到独立介质;校验时从最近锚点开始,发现断点就停下并告警。不要静默跳过坏记录继续往后算,那会把一次可定位的篡改变成一条看起来完整的链。

锚点存储必须独立于日志存储。如果锚点和链在同一个账号、同一个对象存储桶里,能改链的人也能改锚点,见证就失效了。北辰把每日锚点写入独立的审计账号,只允许追加;校验任务用只读凭据读日志链,用另一套凭据读锚点。

一次完整的校验至少要回答三个问题:

  • 链内是否连续:序号从锚点之后逐条递增,没有缺口;
  • 链接是否闭合:每条记录的前向哈希等于上一条的自身哈希;
  • 内容是否未变:每条记录的自身哈希与重新计算的结果一致。

三个问题分别对应删除、重排和字段修改。只校验其中一项,都会留下一种未被覆盖的篡改方式。

审计日志自身的访问也要控制。能读审计日志的人,不能同时拥有删除审计日志的权限;导出动作本身要写一条审计记录,形成“谁在什么时候导出了谁的数据”的闭环。

取证:按主体、按时间、按授权

取证导出与运营报表是两件事。报表可以聚合、可以采样;取证导出要能回答“这个玩家/订单在某个时间窗口内发生了什么”,并且每一条都能回到原始记录。

一个可执行的导出流程包含四个要素。

第一,主体明确。按玩家导出用账号键,按订单导出用订单号,按时间导出必须同时给出用途和范围。没有主体的“全量导出”需要更高级别的授权,不能和普通客服工单走同一个入口。

第二,授权分离。申请人、审批人、执行人至少有两个角色;导出请求记录申请理由、审批人、时间窗口和字段范围。审批不是走形式,它决定了脱敏规则:客服看到设备型号,不需要看到完整设备号;财务看到订单金额,不需要看到聊天内容。

第三,字段脱敏。导出前按字段名替换敏感值,并记录哪些字段被替换。脱敏发生在生成清单的阶段,不是导出后手工处理;否则明文已经落到临时文件,删除请求又要多扇出一层。

第四,清单摘要。导出结果附带一份清单:请求号、生成时间、行数、每行的序号与脱敏字段、整份清单的摘要。接收方可以用摘要验证文件没有被追加或裁剪;审计只需保存摘要和授权记录,不必长期保存明文导出件。

导出件本身也有生命周期。生成后的临时文件放在受控目录,交付后按参数清理;接收方拿到的文件登记编号和摘要,后续删除请求到达时,系统能定位哪些导出件包含该主体。导出件散落在个人下载目录,删除权就失去了边界。

清单摘要还解决了一个常见争议:接收方说文件少了几行,导出方说原始数据就是这样。摘要只证明文件与生成时一致,不证明生成时的过滤条件正确;因此授权记录里必须同时保存主体键、时间窗口和字段范围,否则摘要只能自证完整,不能自证范围。

导出类型主体键默认脱敏审批级别
玩家查询账号 ID设备号、IP、支付凭证客服主管
订单核对订单号聊天、设备指纹财务主管
安全调查账号 ID + 时间窗支付凭证安全负责人
法务调取时间窗 + 范围按调取函要求法务 + 安全

把防篡改做成链:auditretention

下面的实现展示追加写与哈希链的核心:每条记录覆盖自身内容与前一条哈希,校验时同时检查序号连续、前向链接和自身哈希。

// file: internal/auditretention/chain.go
// tier: L1
// verify: go test ./internal/auditretention/
package auditretention

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"sort"
	"strings"
	"time"
)

var (
	ErrChainBroken   = errors.New("auditretention: hash chain broken")
	ErrChainSequence = errors.New("auditretention: non-contiguous sequence")
)

// Entry is one audit record. Fields carries structured details and is part of
// the hash; the map is cloned on append so a caller cannot mutate history.
type Entry struct {
	Seq    uint64
	At     time.Time
	Class  Class
	Actor  string
	Action string
	Object string
	Fields map[string]string
	Prev   string
	Hash   string
}

// Chain is an append-only in-memory chain. Production would persist the same
// fields in an append-only store, but the verification rule is identical:
// each hash covers its own content and the previous hash.
type Chain struct {
	entries []Entry
}

func NewChain() *Chain {
	return &Chain{}
}

var genesis = strings.Repeat("0", sha256.Size*2)

func (c *Chain) Append(at time.Time, class Class, actor, action, object string, fields map[string]string) Entry {
	prev := genesis
	if n := len(c.entries); n > 0 {
		prev = c.entries[n-1].Hash
	}
	e := Entry{
		Seq:    uint64(len(c.entries) + 1),
		At:     at.UTC(),
		Class:  class,
		Actor:  actor,
		Action: action,
		Object: object,
		Fields: cloneFields(fields),
		Prev:   prev,
	}
	e.Hash = hashEntry(e)
	c.entries = append(c.entries, e)
	return e
}

func (c *Chain) Entries() []Entry {
	out := make([]Entry, len(c.entries))
	for i, e := range c.entries {
		out[i] = e
		out[i].Fields = cloneFields(e.Fields)
	}
	return out
}

func cloneFields(in map[string]string) map[string]string {
	if in == nil {
		return map[string]string{}
	}
	out := make(map[string]string, len(in))
	for k, v := range in {
		out[k] = v
	}
	return out
}

func hashEntry(e Entry) string {
	h := sha256.New()
	fmt.Fprintf(h, "%d\n%s\n%s\n%s\n%s\n%s\n",
		e.Seq, e.At.UTC().Format(time.RFC3339Nano), e.Class, e.Actor, e.Action, e.Object)
	keys := make([]string, 0, len(e.Fields))
	for k := range e.Fields {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		fmt.Fprintf(h, "%s=%s\n", k, e.Fields[k])
	}
	return hex.EncodeToString(h.Sum(nil))
}

// Verify checks sequence continuity, previous-hash linkage and each record's
// own hash. Reporting the first broken index is enough for an operator to
// find the boundary; it is not a claim that later entries are intact.
func (c *Chain) Verify() error {
	for i, e := range c.entries {
		if e.Seq != uint64(i+1) {
			return fmt.Errorf("%w at index %d: seq=%d", ErrChainSequence, i, e.Seq)
		}
		wantPrev := genesis
		if i > 0 {
			wantPrev = c.entries[i-1].Hash
		}
		if e.Prev != wantPrev {
			return fmt.Errorf("%w at seq %d: prev mismatch", ErrChainBroken, e.Seq)
		}
		if e.Hash != hashEntry(e) {
			return fmt.Errorf("%w at seq %d: hash mismatch", ErrChainBroken, e.Seq)
		}
	}
	return nil
}

这段代码有几个刻意的选择。

第一,Append 克隆传入的字段 map。调用方在写入后修改原 map,不能改变已经落链的历史;Entries 返回的也是副本,读取方修改返回值不会污染链。

第二,哈希覆盖结构化字段的排序结果。字段 map 的遍历顺序不稳定,哈希前必须按 key 排序,否则同样的内容会算出不同哈希,校验永远失败。

第三,Verify 报出第一个坏点就停止。序号不连续、前向哈希不匹配、自身哈希不匹配是三类不同的问题,错误信息带序号,运维可以直接定位到批次。

同包的 retention.go 负责分类、分层与 hold,export.go 负责按主体和时间过滤、字段脱敏与清单摘要。三部分共用同一套类别定义,避免“留存按 A 分类、导出按 B 分类”的口径漂移。

失败模式与兜底

失败直接后果兜底
应用拥有 update 权限历史被静默改写权限收紧 + 链校验告警
哈希链断点未告警篡改长期不可见按批锚点 + 断点即停
归档保留期配置过短该留的日志提前消失配置校验 + 变更审批
hold 被定时任务解除保留义务被绕过hold 只能人工释放并留审计
导出未脱敏明文扩散到工单附件生成阶段脱敏 + 字段白名单
清单无摘要文件可被追加摘要随文件交付并登记

兜底的原则是“让异常可见,让权限可收”。日志系统不怕告警多,怕的是链断了没人知道、导出没记录、hold 被自动解除。每一次例外都应该留下一条审计记录,而不是在代码里加一个静默分支。

常见错误

第一,所有日志一个保留期。支付要长、战斗可短、行为可聚合,一刀切不是过度保留就是提前删除。

第二,把冷存储当备份。冷存储仍要可校验、可导出;只写不读的归档,等于把风险藏起来。

第三,哈希链只算内容不算前链。这样只能发现单条修改,发现不了删除和重排。

第四,校验失败后跳过坏记录继续。断点被抹平,事后无法判断影响范围。

第五,导出和报表共用接口。报表可以采样,取证不能;共用接口最终一定在某一侧做错。

第六,审批人和执行人可以是同一人。授权分离失效,导出记录再完整也只是自证。

小结

日志留存、审计与取证的落点,是三个分离:留存按类别分离,防篡改按机制分层,取证按授权分离。类别决定保留多久、放哪一层;追加写、哈希链和 WORM 各自挡住不同层级的改写;取证导出要能按主体和时间定位,做字段脱敏,并用清单摘要证明文件完整。

做到这里,合规与数据治理的三个工程面就齐了:入口处的规则判断、数据生命周期的分类与删除、日志的留存与取证。它们共同的前提是参数化——期限、年龄、区域和字段清单都来自外部确认,代码只负责执行和留痕。

阅读导航:上一节:10.2 隐私、数据出境与删除权 · 下一节:11.1 有状态服务的发布 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查