后台操作日志千万别同步写库:从 ShiyuAdmin 看 Gin 异步审计日志设计

做后台管理系统,有一个功能很容易被忽略:

操作日志。

刚开始做项目的时候,很多人觉得日志很简单:

go 复制代码
log.Println("用户删除成功")

但真正上生产以后,老板突然问你:

昨天下午 3 点,是谁把这个用户删了?

或者:

谁修改了管理员角色的权限?

甚至:

为什么某个订单状态突然变了?

这个时候普通程序日志基本没什么用。

你真正需要的是一套:

text 复制代码
谁
在什么时间
从哪个 IP
调用了什么接口
操作了什么模块
执行了什么动作
成功还是失败
耗时多久

也就是:

操作审计日志

我最近在整理 ShiyuAdmin:

text 复制代码
https://github.com/Rodert/ShiyuAdmin

里面专门通过 Gin Middleware 做了一套操作日志。

今天就从这个功能往下拆。


一、程序日志和操作日志不是一回事

很多项目会把这两种日志混在一起。

比如:

go 复制代码
logger.Info(
    "delete user success",
    "userCode",
    userCode,
)

这是程序日志。

主要给开发人员排查问题。

例如:

text 复制代码
数据库连接失败
Redis 超时
请求耗时
SQL 执行失败
panic

但是操作日志解决的是另一件事:

text 复制代码
谁干了什么

比如:

text 复制代码
操作人:zhangsan

模块:system-user

动作:delete

请求:
DELETE /api/v1/system/users/U10001

IP:
192.168.1.12

结果:
success

耗时:
38ms

这两个日志的目标完全不同。

所以后台系统最好分开:

text 复制代码
Application Log
程序运行日志

+

Operation Log
用户操作审计日志

二、最简单的做法:Controller 里写日志

很多项目一开始会这么做。

删除用户:

go 复制代码
func DeleteUser(c *gin.Context) {

    userCode := c.Param("code")

    err := userService.Delete(
        c,
        userCode,
    )

    if err != nil {

        operationLogService.Create(
            c,
            &OperationLog{
                Action: "delete",
                Status: 0,
            },
        )

        return
    }

    operationLogService.Create(
        c,
        &OperationLog{
            Action: "delete",
            Status: 1,
        },
    )
}

看起来可以。

但是当系统有:

text 复制代码
100 个接口

你就得写:

text 复制代码
100 次操作日志代码

新增用户:

go 复制代码
CreateOperationLog(...)

修改用户:

go 复制代码
CreateOperationLog(...)

删除角色:

go 复制代码
CreateOperationLog(...)

修改菜单:

go 复制代码
CreateOperationLog(...)

最后 Controller 到处都是:

go 复制代码
logService.Create(...)

这明显属于重复代码。

而这些请求其实有一个共同点:

text 复制代码
它们全部经过 Gin Router

所以最合适的地方其实是:

Middleware


三、用 Gin Middleware 统一拦截写操作

首先我们并不需要记录所有请求。

例如:

http 复制代码
GET /users

如果每访问一次列表都写数据库:

text 复制代码
刷新一下页面
↓
一条操作日志

再次刷新
↓
又一条操作日志

很快日志表就会爆炸。

一般重点审计:

text 复制代码
POST
PUT
PATCH
DELETE

也就是:

text 复制代码
新增
修改
删除

判断很简单:

go 复制代码
func isWriteMethod(method string) bool {

    switch method {

    case http.MethodPost,
        http.MethodPut,
        http.MethodPatch,
        http.MethodDelete:

        return true

    default:

        return false
    }
}

然后写一个 Gin Middleware:

go 复制代码
func OperationLogger(
    logSvc OperationLogService,
) gin.HandlerFunc {

    return func(c *gin.Context) {

        if !isWriteMethod(
            c.Request.Method,
        ) {

            c.Next()

            return
        }

        // 记录开始时间
        start := time.Now()

        // 继续执行后面的业务
        c.Next()

        // Handler 执行结束后再记录
        saveOperationLog(
            c,
            logSvc,
            start,
        )
    }
}

这里一个非常关键的地方是:

go 复制代码
c.Next()

必须先执行。

为什么?

因为操作日志需要知道:

text 复制代码
最终 HTTP 状态码
最终是否成功
接口实际耗时
Handler 有没有报错

这些东西只有:

text 复制代码
业务代码执行完

之后才知道。


四、为什么日志要在 c.Next() 后面处理?

假设请求:

http 复制代码
DELETE /api/v1/system/users/U10001

开始:

go 复制代码
start := time.Now()

然后:

go 复制代码
c.Next()

请求进入:

text 复制代码
权限 Middleware
↓
Handler
↓
Service
↓
Repository
↓
MySQL

全部执行完成以后回来。

这时候:

go 复制代码
latency :=
    time.Since(start).Milliseconds()

就能得到:

text 复制代码
38ms

同时:

go 复制代码
statusCode :=
    c.Writer.Status()

可以知道结果:

text 复制代码
200

还是:

text 复制代码
400
403
404
500

于是操作日志就可以自动判断成功失败。

例如:

go 复制代码
status := 1

if statusCode >= 400 {
    status = 0
}

这比每个 Controller 手动写:

go 复制代码
success = true

靠谱很多。


五、从 JWT 里拿到操作人

操作日志最重要的一项当然是:

text 复制代码
谁操作的

前面的 Auth Middleware 已经解析过 JWT。

可以把用户信息放进:

go 复制代码
gin.Context

例如:

go 复制代码
c.Set(
    "currentUser",
    claims,
)

Operation Middleware 再读取:

go 复制代码
claimsValue, exists :=
    c.Get("currentUser")

然后:

go 复制代码
claims, ok :=
    claimsValue.(*Claims)

获取:

go 复制代码
userCode :=
    claims.UserCode

username :=
    claims.Username

于是整个链路变成:

text 复制代码
HTTP Request
      ↓
JWT Middleware
      ↓
解析 Token
      ↓
把 Claims 放入 Gin Context
      ↓
Permission Middleware
      ↓
Handler
      ↓
OperationLogger
      ↓
获取当前操作用户

这样业务 Handler 完全不需要关心日志。


六、操作日志表应该存什么?

一个比较实用的结构:

go 复制代码
type OperationLog struct {

    ID int64

    UserCode string

    Username string

    Module string

    Action string

    Method string

    Path string

    IP string

    Status int

    ErrorMsg string

    LatencyMs int64

    CreatedAt time.Time
}

分别看一下。


UserCode

业务用户唯一标识:

text 复制代码
U10001

Username

用户名:

text 复制代码
zhangsan

这里其实可以冗余一份。

因为未来:

text 复制代码
用户改名
用户被删除

你仍然希望历史日志能够显示:

text 复制代码
当时是谁执行的

审计日志适当做冗余数据其实很正常。


Module

模块:

text 复制代码
system-user
system-role
system-menu
system-dept

Action

操作:

text 复制代码
create
update
delete

Method

HTTP Method:

text 复制代码
POST
PUT
PATCH
DELETE

Path

例如:

text 复制代码
/api/v1/system/users/:code

IP

操作来源:

text 复制代码
192.168.1.10

公网系统可能是:

text 复制代码
103.xxx.xxx.xxx

Status

例如:

text 复制代码
1 = 成功
0 = 失败

ErrorMsg

错误摘要:

text 复制代码
权限不足
用户不存在
参数校验失败

建议限制长度。

例如:

go 复制代码
if len(errorMsg) > 500 {

    errorMsg =
        errorMsg[:500]
}

否则一条异常堆栈可能几 KB。

长期下来日志表会非常大。


LatencyMs

例如:

text 复制代码
38ms

这个字段非常有用。

因为操作日志除了做审计,还可以顺手发现:

text 复制代码
某个后台接口越来越慢

七、Module 和 Action 可以自动推导

如果每个接口都手动写:

go 复制代码
Module: "system-user"
Action: "delete"

还是有很多重复代码。

实际上可以从:

text 复制代码
HTTP Method
+
Path

自动计算。

例如:

http 复制代码
DELETE /api/v1/system/users/U10001

Method:

text 复制代码
DELETE

对应:

text 复制代码
delete

Path:

text 复制代码
/api/v1/system/users/:code

解析第一层业务资源:

text 复制代码
users

得到:

text 复制代码
system-users

例如:

go 复制代码
func deriveAction(
    method string,
) string {

    switch method {

    case http.MethodPost:

        return "create"

    case http.MethodPut,
        http.MethodPatch:

        return "update"

    case http.MethodDelete:

        return "delete"

    }

    return "other"
}

路径:

go 复制代码
func deriveModule(path string) string {

    const prefix =
        "/api/v1/system/"

    if !strings.HasPrefix(
        path,
        prefix,
    ) {

        return ""
    }

    rest :=
        strings.TrimPrefix(
            path,
            prefix,
        )

    parts :=
        strings.Split(
            rest,
            "/",
        )

    if len(parts) == 0 {
        return ""
    }

    return "system-" + parts[0]
}

最后:

text 复制代码
DELETE /api/v1/system/users/:code

自动得到:

text 复制代码
Module = system-users
Action = delete

这样新增接口时几乎不用额外处理日志。


八、真正的大坑:不要同步写操作日志

很多人的实现到这里就结束了:

go 复制代码
c.Next()

logService.Create(
    c,
    logEntry,
)

但是这里会有一个问题。

数据库写日志需要:

text 复制代码
5ms
10ms
20ms

那么用户每一次修改操作都会额外增加:

text 复制代码
数据库 INSERT 时间

本来业务接口:

text 复制代码
35ms

加入日志:

text 复制代码
35ms + 15ms

变成:

text 复制代码
50ms

更糟糕的是:

text 复制代码
日志数据库慢了

结果:

text 复制代码
业务接口也跟着变慢

这就非常不合理。

因为对于绝大多数后台:

text 复制代码
用户业务

优先级应该高于:

text 复制代码
审计日志实时落库

所以更合理的方案是:

异步写日志


九、最简单的异步方式:goroutine

可以直接:

go 复制代码
go func() {

    _ = logService.Create(
        ctx,
        logEntry,
    )

}()

主线程:

text 复制代码
业务执行完成
↓
启动 goroutine
↓
立即返回用户

后台:

text 复制代码
goroutine
↓
INSERT operation_log

看起来非常完美。

但这样又产生了第二个问题。


十、千万不要无限创建 goroutine

假设正常:

text 复制代码
100 QPS

问题不大。

但是突然来了:

text 复制代码
5000 QPS

而数据库又突然变慢。

每一个请求:

go 复制代码
go saveLog()

可能不断产生:

text 复制代码
goroutine
goroutine
goroutine
goroutine
goroutine
...

如果数据库迟迟处理不完:

text 复制代码
goroutine 数量持续上涨

最终:

text 复制代码
内存上涨
调度压力上涨
数据库连接池打满

严重时甚至可能:

text 复制代码
日志系统把主业务拖死

所以异步不是简单一句:

go 复制代码
go func()

就结束了。

必须做:

并发限制


十一、ShiyuAdmin 用 channel 做并发槽位

这是一个很适合 Go 的实现。

定义:

go 复制代码
var operationLogWorkers =
    make(chan struct{}, 256)

什么意思?

最多允许:

text 复制代码
256

个日志任务同时执行。

写日志前:

go 复制代码
select {

case operationLogWorkers <-
    struct{}{}:

    go saveLog()

default:

    // 放弃本次异步日志
}

goroutine 完成以后:

go 复制代码
defer func() {
    <-operationLogWorkers
}()

整个模型就像停车场。

一共:

text 复制代码
256 个停车位

正常情况:

text 复制代码
请求
 ↓
占一个位置
 ↓
写日志
 ↓
释放位置

如果 256 个位置全部占满:

text 复制代码
新日志任务
 ↓
没有位置
 ↓
直接丢弃

关键是:

text 复制代码
不能阻塞业务请求

十二、为什么宁可丢日志,也不阻塞业务?

这个设计其实值得讨论。

假设:

text 复制代码
日志 DB 已经挂了

如果坚持:

text 复制代码
日志必须写成功

请求会不断等待。

最终:

text 复制代码
日志系统故障

↓

整个后台业务不可用

显然不合理。

所以一般需要明确优先级:

text 复制代码
核心业务请求
>
普通操作审计日志

当系统已经过载:

text 复制代码
保护主请求

优先级更高。

这就是一种非常常见的:

Backpressure

思想。

系统承载不了的时候:

text 复制代码
不要无限接任务

而应该:

text 复制代码
限流
拒绝
降级
丢弃

当然,如果你的行业是:

text 复制代码
金融
支付
证券
医疗
强审计系统

操作日志可能不能丢。

这时就不应该用简单 goroutine。

而应该上:

text 复制代码
Kafka
RabbitMQ
RocketMQ
Pulsar

这样的消息系统。


十三、还有一个非常隐蔽的问题:Request Context

很多人会这么写:

go 复制代码
go func() {

    logService.Create(
        c.Request.Context(),
        logEntry,
    )

}()

看起来没问题。

但 HTTP 请求结束以后:

text 复制代码
Request Context

通常也会被取消。

于是异步 goroutine 可能刚开始:

text 复制代码
INSERT

请求已经返回。

Context:

text 复制代码
Canceled

数据库操作可能直接收到:

text 复制代码
context canceled

这就是异步任务特别容易踩的坑。


十四、context.WithoutCancel 是个很有意思的处理

ShiyuAdmin 当前做法是:

go 复制代码
ctx :=
    context.WithoutCancel(
        c.Request.Context(),
    )

然后再异步:

go 复制代码
go func(
    ctx context.Context,
    entry *OperationLog,
) {

    _ = logSvc.Create(
        ctx,
        entry,
    )

}(ctx, logEntry)

这样做的核心目的:

text 复制代码
继承原 Request Context 中的 Value

但:

text 复制代码
不要因为 HTTP 请求结束
就取消日志写入

这点非常重要。

否则:

text 复制代码
主请求返回成功

日志却可能:

text 复制代码
context canceled

一条都没写进去。


十五、但 WithoutCancel 以后也要注意超时

这里还能继续优化。

去掉 Request Cancel 以后:

text 复制代码
异步日志请求可能没有合理截止时间

如果数据库一直卡着:

text 复制代码
goroutine

可能长时间不退出。

所以更稳一点的做法是:

go 复制代码
baseCtx :=
    context.WithoutCancel(
        c.Request.Context(),
    )

ctx, cancel :=
    context.WithTimeout(
        baseCtx,
        3*time.Second,
    )

defer cancel()

完整:

go 复制代码
go func(
    base context.Context,
    entry *OperationLog,
) {

    defer func() {
        <-operationLogWorkers
    }()

    ctx, cancel :=
        context.WithTimeout(
            base,
            3*time.Second,
        )

    defer cancel()

    _ = logSvc.Create(
        ctx,
        entry,
    )

}(baseCtx, logEntry)

这样就变成:

text 复制代码
不跟随 HTTP Request 取消

但是

最多允许日志写 3 秒

通常更加合理。


十六、再加一个 TraceID,日志价值会高很多

ShiyuAdmin 本身已经有 Trace Middleware。

请求进来以后:

text 复制代码
检查 X-Trace-Id

如果客户端没有:

text 复制代码
自动生成 TraceID

类似:

text 复制代码
743bc812d93d4d64b4cf8056319d01c5

然后放进:

text 复制代码
Context

同时返回响应头:

http 复制代码
X-Trace-Id:
743bc812d93d4d64b4cf8056319d01c5

这样一次请求里的日志全部可以关联起来。

例如:

text 复制代码
trace_id = abc123

你可能看到:

text 复制代码
HTTP Request
trace_id=abc123

然后:

text 复制代码
permission check
trace_id=abc123

然后:

text 复制代码
database error
trace_id=abc123

最终:

text 复制代码
operation log
trace_id=abc123

这时候排查问题非常方便。


十七、OperationLog 也应该增加 TraceID

当前可以进一步把实体升级成:

go 复制代码
type OperationLog struct {

    ID int64

    TraceID string

    UserCode string

    Username string

    Module string

    Action string

    Method string

    Path string

    IP string

    Status int

    ErrorMsg string

    LatencyMs int64

    CreatedAt time.Time
}

数据库字段:

sql 复制代码
trace_id VARCHAR(64)

加索引:

sql 复制代码
CREATE INDEX
idx_operation_log_trace_id
ON sys_operation_logs(trace_id);

生成日志:

go 复制代码
traceID, _ :=
    c.Get("trace_id")

然后:

go 复制代码
logEntry := &OperationLog{

    TraceID:
        fmt.Sprint(traceID),

    UserCode:
        userCode,

    Username:
        username,

    Module:
        module,

    Action:
        action,

    Method:
        method,

    Path:
        path,

    IP:
        c.ClientIP(),

    Status:
        status,

    LatencyMs:
        latency,
}

以后看到:

text 复制代码
某条删除记录失败

直接:

sql 复制代码
SELECT *
FROM sys_operation_logs
WHERE trace_id = ?;

再去应用日志搜索相同:

text 复制代码
trace_id

整条请求链路就串起来了。


十八、请求日志还要特别注意密码泄露

还有一个非常值得提醒的问题。

很多 Request Logger 会记录:

text 复制代码
request_body

例如登录:

json 复制代码
{
  "username": "admin",
  "password": "123456"
}

如果直接写日志:

text 复制代码
password=123456

这就是严重安全问题。

不只是密码。

还有:

text 复制代码
token
access_token
refresh_token
secret
api_key
authorization
银行卡
身份证
手机号

都可能属于敏感信息。

所以日志系统最好增加:

text 复制代码
Sensitive Field Mask

例如:

go 复制代码
var sensitiveKeys =
    map[string]struct{}{

        "password":      {},
        "token":         {},
        "access_token":  {},
        "refresh_token": {},
        "secret":        {},
        "api_key":       {},
    }

递归脱敏:

go 复制代码
func maskSensitive(
    value any,
) {

    data, ok :=
        value.(map[string]any)

    if !ok {
        return
    }

    for key, value :=
        range data {

        lower :=
            strings.ToLower(key)

        if _, exists :=
            sensitiveKeys[lower];
            exists {

            data[key] = "******"

            continue
        }

        maskSensitive(value)
    }
}

日志最后应该是:

json 复制代码
{
  "username": "admin",
  "password": "******"
}

而不是保存明文密码。


十九、为什么请求 Body 要限制长度?

还有一种场景。

用户提交:

text 复制代码
100 KB JSON

甚至:

text 复制代码
5 MB 文本

如果完整写日志:

text 复制代码
一次请求就几 MB

日志磁盘很快就会爆。

所以一般会设置:

go 复制代码
const maxBodyLogLength = 2048

超过:

text 复制代码
2 KB

直接截断。

例如:

go 复制代码
if len(body) >
    maxBodyLogLength {

    body =
        body[:maxBodyLogLength] +
        "..."
}

操作日志里的:

text 复制代码
ErrorMsg

也一样。

可以控制:

text 复制代码
500 字符

核心原则:

日志不是数据备份。

不要想着把整个 Request 和 Response 原封不动塞进去。


二十、操作日志表也会越来越大

假设一天:

text 复制代码
100 万次写操作

一年:

text 复制代码
3.65 亿条日志

如果全部塞一张:

text 复制代码
sys_operation_logs

查询会越来越痛苦。

所以真实大系统还要继续考虑:

text 复制代码
日志归档
分区表
冷热分离
ES / OpenSearch
ClickHouse
对象存储

比如 MySQL 按时间分区:

sql 复制代码
PARTITION BY RANGE (
    TO_DAYS(created_at)
);

也可以:

text 复制代码
最近 30 天
    ↓
MySQL

历史日志
    ↓
ClickHouse / S3

普通后台当然没必要一开始就做这么复杂。

但表设计最好至少给:

text 复制代码
user_code
created_at
module
status

这些高频查询字段建立合适索引。


二十一、如果日志绝对不能丢,该怎么办?

前面说:

text 复制代码
channel 满了
↓
丢掉日志

这是为了保护主业务。

但如果需求是:

text 复制代码
所有管理员操作必须审计

就不能这么干。

可以升级:

text 复制代码
HTTP Request
     ↓
Operation Middleware
     ↓
发送 Kafka
     ↓
立即返回

消费者:

text 复制代码
Kafka Consumer
     ↓
批量写数据库

变成:

text 复制代码
Gin
 ↓
Kafka
 ↓
Consumer
 ↓
ClickHouse / MySQL

这样:

text 复制代码
主请求不需要等待数据库

同时:

text 复制代码
消息也不会轻易丢失

还可以批量 INSERT:

sql 复制代码
INSERT INTO operation_logs
VALUES
(...),
(...),
(...),
(...);

吞吐量会高很多。


二十二、普通后台没必要一开始就 Kafka

但也不要一看到异步就上 Kafka。

如果后台每天只有:

text 复制代码
几千
几万
几十万

次操作。

其实:

text 复制代码
Gin Middleware
+
goroutine
+
channel 并发限制
+
MySQL

完全够用。

架构选择一定要看规模。

可以这样演进:

text 复制代码
第一阶段

同步 INSERT

发现慢了:

text 复制代码
第二阶段

goroutine 异步

担心 goroutine 爆炸:

text 复制代码
第三阶段

channel 并发限制

要求日志可靠:

text 复制代码
第四阶段

Kafka

日志量特别大:

text 复制代码
第五阶段

Kafka
+
ClickHouse

这才是比较正常的工程演进。


二十三、最终完整链路

一条管理员请求:

http 复制代码
DELETE /api/v1/system/users/U10001

完整链路可以设计成:

text 复制代码
HTTP Request
      ↓
Trace Middleware
      ↓
生成 TraceID
      ↓
Request Logger
      ↓
JWT Middleware
      ↓
解析当前用户
      ↓
Permission Middleware
      ↓
检查删除权限
      ↓
Operation Logger
      ↓
记录开始时间
      ↓
Handler
      ↓
Service
      ↓
Repository
      ↓
MySQL
      ↓
返回结果
      ↓
Operation Logger
      ↓
获取
用户
IP
Method
Path
状态
耗时
TraceID
      ↓
尝试获取异步 Worker
      ↓
goroutine
      ↓
写入 sys_operation_logs
      ↓
HTTP Response

这样 Controller 里面甚至不需要知道:

text 复制代码
操作日志存在

业务代码仍然可以保持干净:

go 复制代码
func deleteUser(
    c *gin.Context,
    userSvc UserService,
) {

    code :=
        c.Param("code")

    if err :=
        userSvc.Delete(
            c,
            code,
        );
        err != nil {

        response.Error(
            c,
            500,
            err.Error(),
        )

        return
    }

    response.Success(
        c,
        gin.H{
            "deleted": true,
        },
    )
}

日志全部由:

text 复制代码
Middleware

统一完成。


最后

后台操作日志真正做起来,其实不只是:

go 复制代码
INSERT INTO log

里面至少包含几个值得学习的后端知识点:

text 复制代码
Gin Middleware
Context
JWT
TraceID
异步任务
goroutine
channel
并发控制
Backpressure
日志脱敏
数据库设计
可观测性

尤其有三个点,我觉得特别值得记住。

第一:

text 复制代码
横切逻辑尽量放 Middleware

不要让每一个 Controller 重复写日志。

第二:

text 复制代码
异步任务不能无限创建 goroutine

必须考虑系统过载时怎么办。

第三:

text 复制代码
日志系统不能反过来拖垮主业务

日志本质上是辅助系统。

除非业务明确要求强审计,否则它的故障不应该导致整个后台不可用。

ShiyuAdmin:

text 复制代码
https://github.com/Rodert/ShiyuAdmin

如果你正在学习 Go 后端,这个模块其实很适合单独拆出来练一次。

因为它看起来只是一个:

text 复制代码
操作日志

但往下挖,会发现里面全是实际项目才会遇到的问题。

相关推荐
王的宝库7 小时前
Gin 框架实战
golang·gin
Vespeng5 天前
打破传统 MVC:在 Go 中实践高内聚的业务驱动架构
架构·go·gin
念何架构之路9 天前
Gin辅助模块与学习路径
学习·gin
念何架构之路10 天前
Gin内置中间件详解
中间件·gin
灯澜忆梦11 天前
【基于GO的Web开发15】gin路由和路由组
前端·后端·golang·gin
灯澜忆梦11 天前
【基于GO的Web开发14】gin请求重定向
前端·后端·golang·gin
远游客071312 天前
为什么用「年×100+月」做比较
算法·gin
灯澜忆梦12 天前
【基于GO的Web开发11】gin获取URL‑Path 路径参数
前端·后端·golang·html·gin
灯澜忆梦12 天前
【基于GO的Web开发10】gin获取HTML-Form表单提交参数
前端·后端·golang·html·gin