做后台管理系统,有一个功能很容易被忽略:
操作日志。
刚开始做项目的时候,很多人觉得日志很简单:
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
操作日志
但往下挖,会发现里面全是实际项目才会遇到的问题。