社区版 MongoDB 不支持字段级变更审计,必须由应用层实现;capped collection 仅适合高频写入的审计日志存储,因其自动滚动、插入快、无重写开销,但不支持更新、删除、唯一索引,且无法替代完整审计逻辑。

社区版 MongoDB 本身不支持字段级变更审计,必须靠应用层实现;固定集合(capped collection)适合存审计日志,但不能直接替代完整审计逻辑——它只解决“存得快、不爆盘”,不解决“记什么、怎么查、谁改了哪一字段”。
为什么用 capped collection 存 audit_logs?
固定集合天然适合高频写入的审计场景:自动按容量或条数滚动、插入性能接近普通集合、不触发文档移动(无重写开销)。但它有硬限制:capped 集合不支持 $set、updateOne 或删除单条记录,且无法建唯一索引。
适用前提:
- 你只要保留最近 N 条/MB 的操作快照,不要长期归档或按字段精确检索
- 审计日志写入是纯追加(append-only),从不修改旧记录
- 能接受日志丢失风险(如磁盘满时最老记录被覆盖)
创建示例:
db.createCollection("audit_logs", { capped: true, size: 104857600, max: 10000 })
注意:size 单位是字节,max 是文档上限,二者满足其一即触发覆盖;MongoDB 不保证严格 FIFO,但实践中基本按插入顺序淘汰。
敏感模型如何绑定审计日志写入?
不能依赖 MongoDB 自动触发,必须在 Laravel 模型事件中显式写入。重点不是“有没有日志”,而是“日志里有没有足够上下文”。
关键字段必须包含:
-
model:类名(如"App\Models\User"),避免硬编码字符串 -
model_id:原始文档_id(ObjectId或字符串),不是 Eloquent 的id属性别名 -
user_id:当前操作人,从auth()->id()取,**不能留空或 fallback 为 0** -
ip_address:从request()->ip()获取,代理环境需检查X-Forwarded-For -
changes:diff 结果,**必须排除敏感字段(如 password_hash)和无关元字段(如 updated_at)**
错误做法:json_encode($model->getChanges()) —— 它只返回本次 save() 修改的字段,漏掉 find+update 组合操作;正确做法是用 array_diff_assoc() 对比原始加载值与当前值(需在 retrieved 事件中缓存原始状态)。
audit_logs 集合上该建什么索引?
固定集合不支持后台建索引,所有索引必须在创建时或插入前建好。没索引的 capped 集合查起来就是全表扫,审计就失去意义。
必建索引:
-
{ "model": 1, "model_id": 1, "timestamp": -1 }:查某条记录的所有历史变更 -
{ "user_id": 1, "timestamp": -1 }:查某人全部操作,按时间倒序 -
{ "ns": 1, "atype": 1, "timestamp": -1 }:如果日志结构兼容 MongoDB 审计格式(比如从 mongos 分片节点同步过来)
禁止建复合索引含 changes 字段——它通常是嵌套对象,会导致索引膨胀严重;需要查具体字段变更时,用应用层过滤或导出后用 jq 处理。
事务内审计日志写入失败怎么办?
Laravel 的 DB::transaction() 默认不包含 audit_logs 集合,而 MongoDB 事务**不能跨集合写入固定集合**(capped 集合明确不支持事务)。这意味着:事务成功提交 ≠ 审计日志一定写入。
可靠方案只有两个:
- 放弃事务一致性,审计日志走单独连接(
new MongoDBClient()),写失败发告警但不中断主事务 - 改用普通集合 + TTL 索引(
{ "timestamp": 1 }+expireAfterSeconds),牺牲一点写入速度换可靠性
别信“先写日志再执行事务”的两阶段套路——如果事务中途失败,你拿到的是一条孤立的、无对应业务变更的日志,反而污染审计线索。
真正难的不是把日志塞进 capped collection,而是确保每条日志都能回溯到真实用户、真实 IP、真实业务动作,且不因 MongoDB 的限制(如事务隔离、索引限制、字段类型)导致关键信息丢失或查询失效。











