bluebell 论坛项目复盘报告
| 技术栈:Go + Gin + MySQL + Redis + Viper + Zap + JWT + Docker
这份报告按:先说它是什么、怎么搭的,然后重点拆 5 个有含金量的实现和 5 个真实踩过的坑,最后直白地指出不足和下一步。看不懂的术语我都在第一次出现时解释了。下面所有代码引用都相对项目根目录。
文章目录
- [bluebell 论坛项目复盘报告](#bluebell 论坛项目复盘报告)
-
- [1. 项目简介](#1. 项目简介)
- [2. 技术选型](#2. 技术选型)
- [3. 架构设计](#3. 架构设计)
-
- [3.1 目录职责逐个说](#3.1 目录职责逐个说)
- [3.2 层与层调用关系](#3.2 层与层调用关系)
- [3.3 这是按层分层还是按功能模块?](#3.3 这是按层分层还是按功能模块?)
- [3.4 数据模型](#3.4 数据模型)
- [4. 关键技术点(重点)](#4. 关键技术点(重点))
-
- [4.1 JWT 认证链路:登录签发 → 中间件校验 → 过期之后怎么办](#4.1 JWT 认证链路:登录签发 → 中间件校验 → 过期之后怎么办)
- [4.2 投票功能:Redis 数据结构设计与那个没解决的竞态](#4.2 投票功能:Redis 数据结构设计与那个没解决的竞态)
- [4.3 帖子列表与分页:Redis 排序 + MySQL 取详情的双段查询](#4.3 帖子列表与分页:Redis 排序 + MySQL 取详情的双段查询)
- [4.4 统一错误码与响应封装](#4.4 统一错误码与响应封装)
- [4.5 并发安全与请求生命周期:从 Recovery 到优雅关闭](#4.5 并发安全与请求生命周期:从 Recovery 到优雅关闭)
- [5. 踩坑与教训](#5. 踩坑与教训)
-
- [5.1 一个残留的 `import "C"`,让 Docker 构建报了一屏 "undefined"](#5.1 一个残留的
import "C",让 Docker 构建报了一屏 "undefined") - [5.2 照抄教程的 Dockerfile:4 个 COPY 全 "not found",构建上下文 150MB](#5.2 照抄教程的 Dockerfile:4 个 COPY 全 "not found",构建上下文 150MB)
- [5.3 MySQL 两连坑:`--init-file` 的执行时机 + 匿名卷里的"鬼数据"](#5.3 MySQL 两连坑:
--init-file的执行时机 + 匿名卷里的"鬼数据") - [5.4 登录成功,token 却"出生即过期"](#5.4 登录成功,token 却"出生即过期")
- [5.5 压测 1.5 万投票全 "服务繁忙":一次暴露三个问题](#5.5 压测 1.5 万投票全 "服务繁忙":一次暴露三个问题)
- [5.1 一个残留的 `import "C"`,让 Docker 构建报了一屏 "undefined"](#5.1 一个残留的
- [6. 工程化](#6. 工程化)
-
- [6.1 测试](#6.1 测试)
- [6.2 部署](#6.2 部署)
- [6.3 配置](#6.3 配置)
- [6.4 日志](#6.4 日志)
- [7. 不足与优化(按优先级排序)](#7. 不足与优化(按优先级排序))
-
- [一档:正确性 / 并发安全 / 安全漏洞](#一档:正确性 / 并发安全 / 安全漏洞)
- [二档:可维护性 / 可测试性 / 性能](#二档:可维护性 / 可测试性 / 性能)
- 三档:工程化进阶
- [8. 收获与下一步](#8. 收获与下一步)
-
- 这个项目已经帮我练到的技能
- 还缺的生产级能力(按学习顺序)
- [如果继续迭代这个项目,下一步最该做的 3 件事](#如果继续迭代这个项目,下一步最该做的 3 件事)
1. 项目简介
一句话说清
bluebell 是一个面向小型兴趣社区(如篮球、英雄联盟板块)的论坛后端 API :用户注册登录后,在感兴趣的板块里发帖、给帖子投赞成/反对票,首页按"最新发布"或"热度分数"浏览帖子列表。它解决的核心问题是:给一个垂直社区提供"发帖 + 投票 + 热度榜单"的最小可用后端。
注意:它是纯后端 API,没有 HTML 页面(早期教程版本有 templates/static,本项目已删掉),所有接口返回 JSON。
核心功能列表
- 用户体系:注册(用户名唯一、两次密码一致性校验)→ 登录(密码校验 + 签发 JWT)→ 受保护接口的 JWT 鉴权。
- 社区板块:查询全部社区列表、单个社区详情(MySQL 里预置了篮球 / 英雄联盟 / CS:GO / 云顶之弈 4 个板块)。
- 帖子:发布帖子(雪花算法生成 ID)、帖子详情(聚合作者名 + 社区信息)、帖子列表(支持按时间或热度排序、分页、按社区筛选)。
- 投票:帖子发布后一周内可投赞成(+1)/ 反对(-1)票,支持改票和取消投票,票数实时改变帖子的热度排序分。
- 基础设施:统一响应格式与错误码、Zap 结构化日志 + 访问日志、panic 兜底、pprof 性能分析入口、优雅关闭、Docker Compose 一键拉起 MySQL + Redis + 应用三个容器。
完整数据流:一个请求是怎么走完的
以"用户给帖子投票"为例(POST /api/v1/vote),链路是:
- 进入路由 :请求到达
gin.Engine(Gin 是 Go 最常用的 Web 框架,Engine负责路由匹配)。匹配到router/router.go里注册的v1.POST("/vote", controller.PostVoteController)。 - 全局中间件 :按注册顺序先过
logger.GinLogger()(记录访问日志:方法、路径、状态码、耗时、IP)和logger.GinRecovery()(兜底接住 panic,记日志并返回 500,防止一个请求打挂整个进程)。"中间件"就是夹在"收到请求"和"业务处理"之间的一层函数,可以做日志、认证、限流这类横切逻辑。 - 认证中间件 :
/api/v1分组在注册完 signup/login 之后调用了v1.Use(middlewares.JWTAuthMiddleware()),所以/vote会先被 JWT 中间件拦截:从请求头取Authorization: Bearer <token>,解析出user_id塞进gin.Context(c.Set("user_id", ...)),失败直接返回 1006/1007 错误码并Abort。 - controller 层 :
controller/vote.go的PostVoteController用c.ShouldBindJSON把请求体绑到models.ParamVoteData,触发binding:"required,oneof=1 0 -1"标签校验(校验失败返回 1001),然后从 Context 取出user_id。 - logic 层 :
logic.VoteForPost只做一件事------把userID / postID / direction透传给 dao 层(是的,这层现在很薄,后面第 7 部分会说)。 - dao 层 :
dao/redis/vote.go的VoteForPost先ZScore查帖子发布时间判断是否过期、查用户旧投票值判断是否重复,然后开一个 Redis 事务管道(TxPipeline)做ZIncrBy(更新帖子热度分)+ZAdd/ZRem(记录或删除用户的投票)。 - 返回响应 :controller 调
ResponseSuccess(c, nil),统一包成{"code":1000,"msg":"Success"}返回。任何一层出错则ResponseError包成对应错误码。
用 Mermaid 画出来:
#mermaid-svg-drOzlZq4Xif4HR2A{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-drOzlZq4Xif4HR2A .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-drOzlZq4Xif4HR2A .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-drOzlZq4Xif4HR2A .error-icon{fill:#552222;}#mermaid-svg-drOzlZq4Xif4HR2A .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-drOzlZq4Xif4HR2A .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-drOzlZq4Xif4HR2A .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-drOzlZq4Xif4HR2A .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-drOzlZq4Xif4HR2A .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-drOzlZq4Xif4HR2A .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-drOzlZq4Xif4HR2A .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-drOzlZq4Xif4HR2A .marker{fill:#333333;stroke:#333333;}#mermaid-svg-drOzlZq4Xif4HR2A .marker.cross{stroke:#333333;}#mermaid-svg-drOzlZq4Xif4HR2A svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-drOzlZq4Xif4HR2A p{margin:0;}#mermaid-svg-drOzlZq4Xif4HR2A .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-drOzlZq4Xif4HR2A .cluster-label text{fill:#333;}#mermaid-svg-drOzlZq4Xif4HR2A .cluster-label span{color:#333;}#mermaid-svg-drOzlZq4Xif4HR2A .cluster-label span p{background-color:transparent;}#mermaid-svg-drOzlZq4Xif4HR2A .label text,#mermaid-svg-drOzlZq4Xif4HR2A span{fill:#333;color:#333;}#mermaid-svg-drOzlZq4Xif4HR2A .node rect,#mermaid-svg-drOzlZq4Xif4HR2A .node circle,#mermaid-svg-drOzlZq4Xif4HR2A .node ellipse,#mermaid-svg-drOzlZq4Xif4HR2A .node polygon,#mermaid-svg-drOzlZq4Xif4HR2A .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-drOzlZq4Xif4HR2A .rough-node .label text,#mermaid-svg-drOzlZq4Xif4HR2A .node .label text,#mermaid-svg-drOzlZq4Xif4HR2A .image-shape .label,#mermaid-svg-drOzlZq4Xif4HR2A .icon-shape .label{text-anchor:middle;}#mermaid-svg-drOzlZq4Xif4HR2A .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-drOzlZq4Xif4HR2A .rough-node .label,#mermaid-svg-drOzlZq4Xif4HR2A .node .label,#mermaid-svg-drOzlZq4Xif4HR2A .image-shape .label,#mermaid-svg-drOzlZq4Xif4HR2A .icon-shape .label{text-align:center;}#mermaid-svg-drOzlZq4Xif4HR2A .node.clickable{cursor:pointer;}#mermaid-svg-drOzlZq4Xif4HR2A .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-drOzlZq4Xif4HR2A .arrowheadPath{fill:#333333;}#mermaid-svg-drOzlZq4Xif4HR2A .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-drOzlZq4Xif4HR2A .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-drOzlZq4Xif4HR2A .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-drOzlZq4Xif4HR2A .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-drOzlZq4Xif4HR2A .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-drOzlZq4Xif4HR2A .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-drOzlZq4Xif4HR2A .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-drOzlZq4Xif4HR2A .cluster text{fill:#333;}#mermaid-svg-drOzlZq4Xif4HR2A .cluster span{color:#333;}#mermaid-svg-drOzlZq4Xif4HR2A div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-drOzlZq4Xif4HR2A .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-drOzlZq4Xif4HR2A rect.text{fill:none;stroke-width:0;}#mermaid-svg-drOzlZq4Xif4HR2A .icon-shape,#mermaid-svg-drOzlZq4Xif4HR2A .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-drOzlZq4Xif4HR2A .icon-shape p,#mermaid-svg-drOzlZq4Xif4HR2A .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-drOzlZq4Xif4HR2A .icon-shape .label rect,#mermaid-svg-drOzlZq4Xif4HR2A .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-drOzlZq4Xif4HR2A .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-drOzlZq4Xif4HR2A .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-drOzlZq4Xif4HR2A :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} /ping、/debug/pprof
/api/v1/*
是(注册在 JWT 中间件之前)
否
缺失 → 1006
无效/过期 → 1007
解析成功,c.Set(user_id)
ShouldBindJSON / ShouldBindQuery
validator 参数校验失败 → 1001
客户端 HTTP 请求
gin.Engine 路由匹配
router/router.go
框架直接处理
GinLogger 访问日志中间件
GinRecovery panic 兜底中间件
是 signup / login 吗?
controller 业务 handler
JWTAuthMiddleware
解析 Authorization: Bearer token
ResponseError 返回错误码
logic 层:业务编排
logic/*.go
dao/mysql:sqlx 执行 SQL
dao/redis:go-redis 执行命令
MySQL
user / community / post
Redis
zset / set 数据结构
组装 models(如 ApiPostDetail)
ResponseSuccess / ResponseError
统一封装 {code, msg, data}
HTTP 200 + JSON 响应
划重点:整条链路的"骨架"就是 中间件链 → controller(参数校验)→ logic(业务编排)→ dao(数据访问) 四段式。你以后看任何 Go Web 项目,都是先找这四段在哪里。
2. 技术选型
| 选了什么 | 可能的备选 | 为什么选它 |
|---|---|---|
| Go 1.25(go.mod 声明) | Java / Node.js / Python | 一句话:为了学 Go 后端。技术上它也确实合适------goroutine(Go 的轻量级线程,内存开销 KB 级)写高并发 Web 服务成本低,编译成单个二进制文件,部署不用装运行时。 |
| Gin v1.12(Web 框架) | 标准库 net/http、Echo、Fiber | 裸用 net/http 要自己写路由匹配、参数绑定、校验,重复造轮子;Gin 是 Go 生态使用率最高的 Web 框架,中文资料多,ShouldBindJSON + validator 标签直接把"参数绑定 + 校验 + 错误返回"三件事压到一行。 |
| MySQL 8.0.19 + sqlx | GORM、原生 database/sql、MongoDB | 论坛是强关系数据(用户-帖子-社区),关系型数据库合适。不用 GORM 而用 sqlx:sqlx 只是 database/sql 的薄封装(结构体扫描 db.Get/Select、sqlx.In 批量展开),每条 SQL 都是自己手写的------对新手来说,ORM 的"黑盒"会挡住你理解 SQL 和索引的窗口,这个项目正适合手写 SQL。 |
| Redis 5.0.7 + go-redis v9 | Memcached、本地内存缓存 | 投票是典型的"高频写 + 需要按分数排序"场景。Redis 的 zset(有序集合,每个成员带一个可排序的 score)天然支持"按票数排名的榜单",ZIncrBy 原子自增分数;Memcached 只有简单 KV,没有带排序的数据结构。 |
| Viper(配置管理) | 环境变量 + os.Getenv、flag 包、etcd/Apollo | 单体应用用 YAML 配置文件 + Viper 就够了:自动把 YAML 反序列化进结构体,还支持 WatchConfig 热更新。etcd/Apollo 是分布式配置中心,这个阶段属于杀鸡用牛刀。 |
| Zap + lumberjack(日志) | 标准库 log/slog、logrus、zerolog | Zap 是 Go 结构化日志的事实标准:JSON 输出、性能高(写日志几乎不拖慢请求);lumberjack 负责日志文件按大小(200MB)/天数(30 天)/备份数滚动切割归档。slog 是 Go 1.21 才进标准库的新东西,能用,但生态示例少。 |
| golang-jwt/jwt v4(认证) | Session + Redis、OAuth2 | 选 JWT(JSON Web Token,一种"服务端签发、客户端携带、服务端验签"的令牌)是为了无状态:服务端不存会话,接口出去随便横向扩容。代价是 token 签发后在有效期内无法主动吊销------这个项目暂时接受该缺点(第 7 部分会给补救方案)。 |
| bwmarrin/snowflake(ID 生成) | MySQL 自增主键、UUID、美团 Leaf | 业务 ID 直接用自增主键会暴露总量、可被枚举爬取(别人拿到 post_id=100,就知道你总共 100 篇帖子);UUID 是无序字符串,做主键或唯一索引会拖慢 B+ 树插入。雪花算法输出趋势递增的 int64(64 位整数),分布式下不重复,且不暴露业务量。 |
| go-playground/validator v10 + 中文翻译器 | 手写 if-else 校验 | 声明式校验:结构体标签写 binding:"required,email",Gin 自动执行;再挂一个 translations/zh 翻译器,返回给前端的错误直接是"用户名不能为空"这种中文,不用自己拼错误信息。 |
| Docker + Docker Compose | 本机手装 MySQL/Redis、systemd | 一条 docker-compose up 同时编排 MySQL、Redis、应用三个服务,端口、依赖顺序、初始化 SQL 全部写进 yml;宿主机(你的 Windows)不用装任何数据库。生产部署可以直接复用这套镜像思路。 |
| Air(热重载) | go run 手动重启、fresh | 改完代码自动重新编译并重启进程,开发时不用手点。只在开发用,不进生产镜像。 |
| testify(测试断言) | 标准库 testing 手写断言 | 测试断言语义清晰(assert.Equal),业界通用。标准库也能写,但断言失败信息可读性差一些。 |
| gin-contrib/pprof(性能分析) | 无 | 把 Go 自带的 pprof 挂到 HTTP 路由上,浏览器/命令行就能抓 CPU、内存火焰图。但现在它没有鉴权就直接挂在线上路由里,是隐患,第 7 部分会讲。 |
| juju/ratelimit(限流) | golang.org/x/time/rate | 令牌桶限流中间件写了(middlewares/ratelimit.go),但目前在 router 里被注释掉没启用------代码在、功能不在,别误以为有限流。 |
| os/signal 优雅关闭、context 超时 | 框架内置(如 Kratos) | 标准库即可满足,不需要额外依赖。 |
3. 架构设计
3.1 目录职责逐个说
先说清楚:这是"教程版 bluebell"的组织方式,和你可能听说的"标准 Go 项目布局"(cmd/ + internal/)不完全一样,后面 3.3 节会对比。
| 目录/文件 | 职责 | 备注 |
|---|---|---|
main.go |
程序入口。按顺序:加载配置 → 初始化日志 → 连 MySQL → 连 Redis → 初始化雪花算法 → 初始化校验翻译器 → 注册路由 → 启动 HTTP 服务并监听退出信号做优雅关闭。 | 没有 cmd/ 目录,入口就在根包。 |
settings/ |
配置层。Viper 读 config.yaml 反序列化进 AppConfig 结构体(全局变量 Conf),并监听文件变化热更新。 |
对应你说的"setting"。 |
logger/ |
日志层。初始化 Zap(写文件 + 切割),并提供 GinLogger()(访问日志)和 GinRecovery()(panic 兜底)两个 Gin 中间件。 |
|
router/ |
路由层。注册 /ping、/api/v1 分组、pprof,装配全局中间件和 JWT 中间件。 |
你笔记里叫 routes/,这里叫 router/(单数)。 |
middlewares/ |
HTTP 中间件:auth.go(JWT 认证)、ratelimit.go(令牌桶限流,未启用)。 |
注意是复数 middlewares/。 |
controller/ |
HTTP 层。只干三件事:解析/校验请求参数、调 logic、用统一格式写响应。code.go 定义业务错误码,response.go 定义统一响应封装,request.go 放"从 Context 取当前用户/取分页参数"这类小工具,validator.go 初始化中文校验翻译器。 |
|
logic/ |
业务编排层。组织一次业务要调哪些 dao、怎么拼数据。例如发帖 = 生成 ID + 写 MySQL + 写 Redis。 | 目前部分函数很薄(如 vote),部分很厚(如 post 列表),见第 7 部分。 |
dao/mysql/、dao/redis/ |
数据访问层。每个 SQL/Redis 命令封装成函数。MySQL 侧用了全局 *sqlx.DB 连接池;Redis 侧用全局 *redis.Client。error_code.go 定义 dao 层的哨兵错误(如 ErrorUserExist)。 |
哨兵错误:预先定义的、可用 errors.Is 比较的包级 error 变量。 |
models/ |
数据模型。user.go/post.go/community.go 是数据库实体(带 db: 标签供 sqlx 扫描),params.go 是请求参数结构体(带 json:/form:/binding: 标签),create_table.sql 留了建表语句。 |
|
pkg/ |
内部工具包:jwt/(签发/解析 token)、snowflake/(ID 生成器封装)。 |
惯例上 pkg/ 放"可以被外部复用"的库,internal/ 放"仅本项目用"的代码;这里混用了,属于小问题。 |
conf/config.yaml |
容器内 使用的配置(连 mysql8019 / redis507 服务名,release 模式)。 |
构建镜像时 COPY 进 /conf,配合 Dockerfile 里 WORKDIR /conf 生效。 |
config.yaml(根目录) |
本地直跑 使用的配置(连 127.0.0.1,debug 模式)。 |
两套配置互不影响,别改混了。 |
Dockerfile / docker-compose.yml / init.sql / wait-for.sh / .dockerignore |
部署四件套 + 依赖等待脚本 + 构建忽略清单。 | 详见第 6 部分。 |
.air.conf / Makefile |
开发工具配置。Air 热重载;Makefile 目前只有 run/help 两个目标(BINARY := xx 是没用上的残留)。 |
3.2 层与层调用关系
#mermaid-svg-mkwlaT416RjYCEmz{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-mkwlaT416RjYCEmz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mkwlaT416RjYCEmz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mkwlaT416RjYCEmz .error-icon{fill:#552222;}#mermaid-svg-mkwlaT416RjYCEmz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mkwlaT416RjYCEmz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mkwlaT416RjYCEmz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mkwlaT416RjYCEmz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mkwlaT416RjYCEmz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mkwlaT416RjYCEmz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mkwlaT416RjYCEmz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mkwlaT416RjYCEmz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mkwlaT416RjYCEmz .marker.cross{stroke:#333333;}#mermaid-svg-mkwlaT416RjYCEmz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mkwlaT416RjYCEmz p{margin:0;}#mermaid-svg-mkwlaT416RjYCEmz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-mkwlaT416RjYCEmz .cluster-label text{fill:#333;}#mermaid-svg-mkwlaT416RjYCEmz .cluster-label span{color:#333;}#mermaid-svg-mkwlaT416RjYCEmz .cluster-label span p{background-color:transparent;}#mermaid-svg-mkwlaT416RjYCEmz .label text,#mermaid-svg-mkwlaT416RjYCEmz span{fill:#333;color:#333;}#mermaid-svg-mkwlaT416RjYCEmz .node rect,#mermaid-svg-mkwlaT416RjYCEmz .node circle,#mermaid-svg-mkwlaT416RjYCEmz .node ellipse,#mermaid-svg-mkwlaT416RjYCEmz .node polygon,#mermaid-svg-mkwlaT416RjYCEmz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-mkwlaT416RjYCEmz .rough-node .label text,#mermaid-svg-mkwlaT416RjYCEmz .node .label text,#mermaid-svg-mkwlaT416RjYCEmz .image-shape .label,#mermaid-svg-mkwlaT416RjYCEmz .icon-shape .label{text-anchor:middle;}#mermaid-svg-mkwlaT416RjYCEmz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-mkwlaT416RjYCEmz .rough-node .label,#mermaid-svg-mkwlaT416RjYCEmz .node .label,#mermaid-svg-mkwlaT416RjYCEmz .image-shape .label,#mermaid-svg-mkwlaT416RjYCEmz .icon-shape .label{text-align:center;}#mermaid-svg-mkwlaT416RjYCEmz .node.clickable{cursor:pointer;}#mermaid-svg-mkwlaT416RjYCEmz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-mkwlaT416RjYCEmz .arrowheadPath{fill:#333333;}#mermaid-svg-mkwlaT416RjYCEmz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-mkwlaT416RjYCEmz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-mkwlaT416RjYCEmz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mkwlaT416RjYCEmz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-mkwlaT416RjYCEmz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mkwlaT416RjYCEmz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-mkwlaT416RjYCEmz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-mkwlaT416RjYCEmz .cluster text{fill:#333;}#mermaid-svg-mkwlaT416RjYCEmz .cluster span{color:#333;}#mermaid-svg-mkwlaT416RjYCEmz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-mkwlaT416RjYCEmz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-mkwlaT416RjYCEmz rect.text{fill:none;stroke-width:0;}#mermaid-svg-mkwlaT416RjYCEmz .icon-shape,#mermaid-svg-mkwlaT416RjYCEmz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mkwlaT416RjYCEmz .icon-shape p,#mermaid-svg-mkwlaT416RjYCEmz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-mkwlaT416RjYCEmz .icon-shape .label rect,#mermaid-svg-mkwlaT416RjYCEmz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mkwlaT416RjYCEmz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-mkwlaT416RjYCEmz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-mkwlaT416RjYCEmz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 每个 HTTP 请求的调用链
main.go 启动装配(一次性)
main.go
settings.Init
(Viper 读配置)
logger.Init
(Zap)
mysql.Init
(sqlx 连接池)
redis.Init
(go-redis 客户端)
snowflake.Init
router.Setup
middlewares
(JWT 认证 / 限流)
controller
(参数校验 + 响应封装)
logic
(业务编排)
dao/mysql
dao/redis
pkg/jwt、pkg/snowflake
models(请求/响应结构体)
MySQL
Redis
关键点:调用只能自上而下------controller 不会直接碰 dao(都是经 logic),dao 不会反向依赖 logic,models 是被各层共享的最底层。这个方向性目前保持得不错。
3.3 这是按层分层还是按功能模块?
是按层分层(layered) :每一层一个包,所有业务功能(用户、帖子、投票)在同一层里按文件切分。不是按功能模块(那样应该是 post/、user/ 各自包含自己的 handler/service/repo)。
评价:
- 对当前规模(单一论坛、3 张表、10 来个接口)来说,按层分层完全合理,也是 Go 社区教程和中小项目最常见的做法,比强行上 DDD(领域驱动设计,一套按业务领域划分代码的方法论)务实得多。
- 和 Go 官方推崇的"标准布局"(
cmd/放入口、internal/放私有代码)相比,这个项目简化了:入口在根目录、包直接放根级。作为学习项目没问题 ,但如果以后代码量大了,internal/能防止被外部 import,cmd/能支持一个仓库出多个二进制(比如将来拆出定时任务进程)。 - 隐忧:等业务变复杂,
logic/post.go会越堆越大,那时再考虑把"按层"里的 post 相关代码聚拢成模块。现在不用急着改,知道这个演进方向就行。
3.4 数据模型
MySQL 表结构(建表语句见 models/create_table.sql 和 init.sql)
user 表(用户)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT AUTO_INCREMENT | 物理主键(无业务含义) |
| user_id | BIGINT 唯一索引 | 业务主键,雪花算法生成 |
| username | VARCHAR(64) 唯一索引 | 用户名,注册时查重 |
| password | VARCHAR(64) | 密码(MD5 哈希后存储,算法有问题,见第 7 部分) |
| email / gender | VARCHAR / TINYINT | 建了但代码从没用过(模型里都没这俩字段) |
| create_time / update_time | TIMESTAMP | 自动维护 |
community 表(社区板块)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INT AUTO_INCREMENT | 物理主键 |
| community_id | INT 唯一索引 | 业务主键 |
| community_name | VARCHAR(128) 唯一索引 | 板块名(篮球/英雄联盟...) |
| introduction | VARCHAR(256) | 板块介绍 |
| create_time / update_time | TIMESTAMP | 自动维护 |
post 表(帖子)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT AUTO_INCREMENT | 物理主键 |
| post_id | BIGINT 唯一索引 | 业务主键,雪花生成 |
| title / content | VARCHAR(128) / VARCHAR(8192) | 标题、内容 |
| author_id | BIGINT,无索引 | 关联 user.user_id |
| community_id | INT UNSIGNED,无索引 | 关联 community.community_id |
| status | TINYINT 默认 1 | 预留的状态位,代码未使用 |
| create_time / update_time | TIMESTAMP | 自动维护 |
表关系
#mermaid-svg-AdxvGO3kL36FqPrl{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AdxvGO3kL36FqPrl .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AdxvGO3kL36FqPrl .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AdxvGO3kL36FqPrl .error-icon{fill:#552222;}#mermaid-svg-AdxvGO3kL36FqPrl .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AdxvGO3kL36FqPrl .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AdxvGO3kL36FqPrl .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AdxvGO3kL36FqPrl .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AdxvGO3kL36FqPrl .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AdxvGO3kL36FqPrl .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AdxvGO3kL36FqPrl .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AdxvGO3kL36FqPrl .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AdxvGO3kL36FqPrl .marker.cross{stroke:#333333;}#mermaid-svg-AdxvGO3kL36FqPrl svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AdxvGO3kL36FqPrl p{margin:0;}#mermaid-svg-AdxvGO3kL36FqPrl .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-AdxvGO3kL36FqPrl .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-AdxvGO3kL36FqPrl .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-AdxvGO3kL36FqPrl .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-AdxvGO3kL36FqPrl .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-AdxvGO3kL36FqPrl .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AdxvGO3kL36FqPrl .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-AdxvGO3kL36FqPrl .node rect,#mermaid-svg-AdxvGO3kL36FqPrl .node circle,#mermaid-svg-AdxvGO3kL36FqPrl .node ellipse,#mermaid-svg-AdxvGO3kL36FqPrl .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AdxvGO3kL36FqPrl .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-AdxvGO3kL36FqPrl .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-AdxvGO3kL36FqPrl .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AdxvGO3kL36FqPrl .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-AdxvGO3kL36FqPrl .edgeLabel .label text{fill:#333;}#mermaid-svg-AdxvGO3kL36FqPrl :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} author_id 发帖
community_id 归属
投票(只存在 Redis,无表)
被投票(只存在 Redis,无表)
USER
POST
COMMUNITY
VOTE
Redis 数据结构(dao/redis/keys.go,统一 bluebell: 前缀)
| Key | 结构 | 含义 |
|---|---|---|
bluebell:post:time |
zset | 帖子和它的发布时间(score=发布时间戳),用于"按最新排序"和"投票是否超一周"判断 |
bluebell:post:score |
zset | 帖子和它的热度分(初始=发布时间,每张赞成票 +432),用于"按热度排序" |
bluebell:post:voted:{postID} |
zset | 某帖的投票记录:成员=userID,score=投票方向(1 / -1),用于判重、改票和统计赞成票数 |
bluebell:community:{communityID} |
set | 某社区下的帖子 ID 集合,用于按社区筛选帖子 |
设计是否合理?
合理的部分:
- 三张表都用了"自增物理主键 + 雪花业务主键"的双主键模式:物理主键保证 InnoDB 插入性能(自增不会页分裂),业务主键对外防枚举。这是互联网系统的常规做法。
- 用户名/社区名/业务 ID 都建了唯一索引,注册查重、按 ID 查详情都能走索引。
- 不用外键约束、靠应用层保证一致性:符合互联网应用的惯例(外键影响写入性能、不利后续拆库),当前代码里数据一致性由业务逻辑自己保证。
- 投票数据不落 MySQL、全放 Redis,是个有意为之的取舍 (
dao/redis/vote.go顶部注释写了后续计划:过了投票期再把票数归档回 MySQL)------高频写走内存,读热度走 zset。
不合理的部分(直说):
- post.author_id 和 post.community_id 没有索引。现在是"按社区查帖子"走了 Redis set 侥幸绕开,但一旦要加"查某用户的所有帖子"这种页面,就是全表扫描。
- 没有评论表 。你题目里问到了评论------项目里根本没有评论功能,这是需求上的缺失,不是"没做完",别在作品集里写它支持评论。
- email / gender / status 字段是摆设:建了列但全链路无代码使用。
- 投票数最终没有持久化:目前所有票数只活在 Redis,重启/清库就没了(Redis 在本项目 compose 里也没配持久化卷)。教程注释里规划了"过期后归档 MySQL",代码没实现。
- password 字段设计长度没问题,但存储值由 MD5 生成------这是安全问题,见第 7 部分一档。
4. 关键技术点(重点)
4.1 JWT 认证链路:登录签发 → 中间件校验 → 过期之后怎么办
问题
论坛里"谁在发帖、谁在投票"必须是可信的。传统做法是服务端存 session(会话),但那样每个接口都要查一次存储,且多实例部署要共享会话。目标:登录一次之后,后续请求能可靠识别身份,且服务端"无状态"。
难点
- 令牌放哪里、怎么传给服务端、格式怎么定?
- 令牌过期、被篡改、格式错误,要分别给出什么响应?
- 免登录接口(注册/登录)不能被鉴权中间件误伤。
- 过期后要不要"无感刷新"(refresh token)?这是新手最容易漏想的一环。
方案
① 登录签发 (logic/user.go + pkg/jwt/jwt.go):登录成功后把 user_id、username 放进自定义 claims(声明),用 HS256(一种对称签名算法)签名:
go
// pkg/jwt/jwt.go
var MySecret = []byte("夏天夏天悄悄过去") // ← 硬编码密钥,隐患见第 7 部分
func GenToken(userID int64, username string) (string, error) {
c := MyClaims{
UserId: userID,
Username: username,
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(
time.Duration(viper.GetInt("auth.jwt_expire")) * time.Hour).Unix(),
Issuer: "bluebell",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
return token.SignedString(MySecret)
}
② 免登录接口的注册顺序 (router/router.go)------这是本链路最巧妙的一处细节:
go
v1 := r.Group("/api/v1")
v1.POST("/signup", controller.SignUpHandler) // 先注册:不受 JWT 影响
v1.POST("/login", controller.LoginHandler)
v1.Use(middlewares.JWTAuthMiddleware()) // 从这一行之后注册的路由才被鉴权
{
v1.GET("/community", controller.CommunityHandler)
v1.POST("/vote", controller.PostVoteController)
...
}
Gin 里 v1.Use 只对调用之后注册在该分组上的路由生效,所以 signup/login 天然豁免,不需要在中间件里写白名单分支。
③ 中间件校验 (middlewares/auth.go):取 Authorization 头 → 必须是 Bearer <token> 两段式 → jwt.ParseToken 验签和过期时间 → 成功则 c.Set("user_id", mc.UserId) 供后续 handler 取用;失败分三种情况各有响应:没带头 → 1006(需要登录);格式不对 / 验签失败 / 已过期 → 1007(无效的Token)。
④ 过期与刷新 :没有刷新机制 。token 过期只能重新登录。config.yaml 里 jwt_expire: 8760(小时)= 一年------用"超长有效期"回避了刷新问题,代价是"账号泄露后一年内都无法吊销"。这是明确的欠账(改进见 7.1)。
结果
链路完整可用,四种边界情况(缺头/格式错/过期/篡改)都有明确响应。部署时踩过一次经典坑:容器配置漏写 auth.jwt_expire,viper.GetInt 读出 0 → 签发即过期 (详细复盘见第 5 部分)。调试技巧:把 token 贴到 jwt.io 看 exp 字段,与签发时刻接近就说明过期时间配置错了。
4.2 投票功能:Redis 数据结构设计与那个没解决的竞态
问题
每个用户可以对帖子投赞成/反对票,可改票、可取消,一周后截止。帖子列表要能按票数热度排序。
难点
- 投票是高频写操作,如果直接
UPDATE post SET score = score + 1,高并发下 MySQL 行锁排队,写放大严重; - 排序要"取分数 Top N",MySQL 每次都
ORDER BY score DESC LIMIT 10也扛不住; - 表达"每个用户对每个帖子只能有一票,且能表达三种意图(赞成/反对/没投)";
- 高并发下"读旧值 → 判重 → 写新值"这一套操作如果不原子,会重复计分。
方案
① 为什么用 Redis :zset 三个特性正中需求:成员唯一(天然按 userID 去重)、成员带可排序 score、ZINCRBY 原子自增。投票写内存、排序读内存,MySQL 完全不参与投票链路。
② 数据结构设计 (dao/redis/keys.go,四种 key 分工见 3.4 的表)。核心是热度分数模型:post:score 里分数初始值 = 发布时间戳 ,每张赞成票 +432 分。432 = 86400 ÷ 200,意思是"一张票让帖子的热度等效提前 432 秒",即需要 200 张净赞成票才能给帖子在榜上续一天------新帖靠大时间戳自然压过老帖,老帖靠票数续命。这是"以秒为单位的热度衰减模型",比各种加权公式直观得多。
③ 投票的六种转移 (dao/redis/vote.go 顶部注释梳理得很清楚):新投赞成、反对改赞成(diff=2)、新投反对、赞成改反对(diff=2)、取消赞成、取消反对。代码用统一公式处理:
go
// 计算旧值和目标值的差
diff := math.Abs(ov - value)
pipeline := client.TxPipeline()
pipeline.ZIncrBy(c, GetRedisKey(keyPostScoreZSet), op*diff*scorePerVote, postID)
if value == 0 {
pipeline.ZRem(c, GetRedisKey(KeyPostVotedZSetPF+postID), userID) // 取消投票
} else {
pipeline.ZAdd(c, GetRedisKey(KeyPostVotedZSetPF+postID), redis.Z{Score: value, Member: userID})
}
_, err := pipeline.Exec(c)
④ 一致性怎么保证:目前没有保证,这是最大的欠账 。票数只存在于 Redis,MySQL 的 post 表甚至没有 votes 列;vote.go 注释里规划的"过期后把票数归档到 MySQL"没有实现,compose 里 Redis 也没挂持久化卷。也就是说 Redis 一重启,全站票数清零。当前能自圆其说的只有"一周投票窗口 + 冷热数据分离"的意图。
⑤ 高并发下的竞态(已定位、待修复) :读取旧投票值 ZScore(...).Val() 在管道事务外面 ,写操作在里面------这两步不是原子的。同一用户并发投两次时,两个请求可能都读到 ov=0、都执行 +432 的 ZIncrBy(ZAdd 记录投票状态本身是幂等的------"幂等"指同一操作执行多次结果不变------但 ZIncrBy 加分不是,所以分数被重复累加)。改法是用 Lua 脚本把"读-判-写"整个搬进 Redis 里原子执行(Redis 单线程执行脚本,天然互斥):
lua
-- KEYS[1]=post:time KEYS[2]=post:score KEYS[3]=post:voted:{postID}
-- ARGV[1]=postID ARGV[2]=userID ARGV[3]=direction ARGV[4]=now ARGV[5]=604800
local postTime = redis.call('ZSCORE', KEYS[1], ARGV[1])
if not postTime then return -3 end -- 帖子不存在
if tonumber(ARGV[4]) - tonumber(postTime) > tonumber(ARGV[5]) then
return -1 -- 投票时间已过
end
local ov = tonumber(redis.call('ZSCORE', KEYS[3], ARGV[2]) or '0')
local value = tonumber(ARGV[3])
if value == ov then return -2 end -- 重复投票
local op = (value > ov) and 1 or -1
redis.call('ZINCRBY', KEYS[2], op * math.abs(ov - value) * 432, ARGV[1])
if value == 0 then
redis.call('ZREM', KEYS[3], ARGV[2])
else
redis.call('ZADD', KEYS[3], value, ARGV[2])
end
return 1
Go 侧用 redis.NewScript 装载并传 KEYS/ARGV 调用,返回值映射成业务错误。
结果
功能层面:六种投票转移全部可用,热度榜单工作正常;架构层面:把最重的写操作完全隔离在 MySQL 之外。但压测暴露了真问题------1.5 万次投票请求全部返回 1005"服务繁忙",根因有三层(错误码一刀切、ZScore 错误被吞、读改写非原子),完整复盘见第 5 部分 5.5。"能跑"和"扛得住"之间隔着的就是这层竞态和错误处理。
4.3 帖子列表与分页:Redis 排序 + MySQL 取详情的双段查询
问题
帖子列表要支持:按最新/热度排序、翻页、按社区筛选,并且每篇帖子要展示作者名、社区信息、赞成票数。
难点
- 排序索引在 Redis(zset)、帖子正文在 MySQL,怎么优雅衔接?
- 列表里每篇帖子都要补作者名和社区信息,容易写成 N+1 查询;
- 分页的两种思路(offset 深分页性能退化 vs 游标分页)要选型;
- Redis 拿 ID、MySQL 拿数据的两次查询,结果集如何保持顺序一致。
方案
① 两段式查询(logic/post.go 的 GetPostList2):
第一段:Redis 只拿"排序好的帖子 ID 列表"
ZRevRange(bluebell:post:time 或 post:score, start, end) // dao/redis/post.go
第二段:MySQL 按 ID 批量取详情,并把顺序还原
SELECT ... FROM post WHERE post_id IN (?) ORDER BY FIELD(post_id, ?) // dao/mysql/post.go
FIELD(post_id, id1, id2, ...) 是 MySQL 的一个函数,返回"该值在参数列表中的位置"------ORDER BY FIELD(...) 就是把 IN 查出来的乱序结果按 Redis 给的 ID 顺序重新排好。这个技巧很实用,比在 Go 里排一遍省事。
② 票数批量统计(dao/redis/post.go 的 GetPostVoteData):先写了循环逐条 ZCount(统计 score=1 的赞成票数),后来改成 pipeline(把 N 条命令打包一次性发给 Redis,减少 N 次网络往返 RTT)------注释里留着这个演进痕迹,说明作者是有意识地优化过。
③ 按社区筛选的缓存 (GetCommunityPostIDsInOrder):用 ZInterStore 求"社区帖子集合 ∩ 排序 zset"的交集,结果缓存 1 小时。改进空间见第 7 部分(发帖后不失效缓存)。
④ N+1 问题(存在,未修) :GetPostList2 的循环里,每篇帖子查一次作者、查一次社区------10 篇帖子就是 2×10 次查询:
go
for idx, post := range posts {
user, err := mysql.GetUserById(post.AuthorID) // 每篇查一次 → N 次
communityDetail, err := mysql.GetCommunityDetailByID(post.CommunityID) // 再 N 次
...
}
应改为一对批量查询 + map 组装:
go
// 改造后:收集 ID → IN 批量查 → map 回填,总共 2 次查询
userMap, _ := mysql.GetUsersByIDs(authorIDs) // SELECT ... WHERE user_id IN (?)
communityMap, _ := mysql.GetCommunitiesByIDs(communityIDs)
for _, post := range posts {
postDetail := &models.ApiPostDetail{
AuthorName: userMap[post.AuthorID].Username,
CommunityDetail: communityMap[post.CommunityID],
Post: post,
}
data = append(data, postDetail)
}
⑤ 一处隐藏的顺序错位隐患 :voteData 是按 Redis 返回的 ids 顺序算出来的(长度=len(ids)),而循环 for idx, post := range posts 用的是 MySQL 返回的 posts 下标。如果某个 ID 在 Redis 里有、在 MySQL 里没有(帖子被删),posts 比 ids 短,从缺失位置开始,每篇帖子都会显示下一篇帖子的票数 。正确姿势是按 post.PostID 建 map 取值,而不是靠下标对齐。
结果
列表接口可用且比"纯 MySQL"版本快得多;旧的 MySQL offset 分页 GetPostList 已弃用(路由里 /posts 被注释、只暴露 /posts2)。深分页现状评估 :旧版 LIMIT ?, ? 的 offset 分页要扫掉前面全部行才返回,页码越深越慢,弃用是对的;现在的 ZRevRange 复杂度是 O(log N + M),深分页不退化,但代价是帖子 ID 常驻内存,规模上百万时要另行设计。N+1 和票数错位这两个问题在数据量小的时候看不出来,一旦列表变长、帖子被删就会暴露,属于第 7 部分二档清单。
4.4 统一错误码与响应封装
问题
几十个接口,如果每个接口自己拼返回值(有的返回字符串、有的返回 map、有的直接 500),前端没法统一处理。需要一个"错误码 → 文案"的映射和三个统一的响应出口。
难点
- 错误码怎么分配、文案怎么和码绑定?
- 业务错误和基础设施错误怎么区分?(这个项目一开始没区分好,见下)
- HTTP 状态码用不用?
方案
① controller/code.go:iota 从 1000 连续分配业务码,codeMsgMap 绑定文案,Msg() 方法对未注册的码兜底返回"服务繁忙":
go
type ResCode int64
const (
CodeSuccess ResCode = 1000 + iota
CodeInvalidParams // 1001
CodeUserExist // 1002
CodeUserNotExist // 1003
CodeInvalidPassword // 1004
CodeServerBusy // 1005
CodeNeedLogin // 1006
CodeInvalidToken // 1007
)
② controller/response.go:所有出口收敛到三个函数,响应体恒为 {code, msg, data}:
go
func ResponseError(c *gin.Context, code ResCode) // 返回码+标准文案
func ResponseErrorWithMsg(c *gin.Context, code ResCode, msg interface{}) // 返回码+自定义文案(校验错误详情)
func ResponseSuccess(c *gin.Context, data interface{})
参数校验失败时用 RemoveTopStruct 把 validator 报错里的结构体前缀去掉(ParamSignUp.Password → password),前端拿到的 key 和提交字段名一致。
③ 反例(这段必须单独点名) :controller/vote.go 没有做错误分类,logic.VoteForPost 返回的任何错误(包括"重复投票""投票时间已过"这种纯业务拒绝)都被粗暴映射成 1005"服务繁忙":
go
if err = logic.VoteForPost(c, userID, p); err != nil {
zap.L().Error("logic.VoteForPost failed", zap.Error(err))
ResponseError(c, CodeServerBusy) // ← 一刀切:业务错误被伪装成系统故障
return
}
正确写法是加两个业务错误码(如 1008 投票时间已过、1009 重复投票),用 errors.Is 分流:
go
switch {
case errors.Is(err, redis.ErrVoteTimeExpire):
ResponseError(c, CodeVoteTimeExpire)
case errors.Is(err, redis.ErrVoteRepeated):
ResponseError(c, CodeVoteRepeated)
default:
ResponseError(c, CodeServerBusy)
}
结果
响应格式统一,前端可以只写一个拦截器按 code 分流;但"错误分类"只做了一半------校验错误分得细,业务错误分得粗。"1005 服务繁忙"在真实系统里应当意味着"需要人工介入",如果它同时代表"用户点重复了",监控告警就永远在狼来了。 这也是压测事件的第一层根因。
4.5 并发安全与请求生命周期:从 Recovery 到优雅关闭
问题
并发场景下三条防线:单个请求 panic 不能打挂进程、写入操作要防竞态、进程退出不能把在途请求杀掉。
难点
- Go 里 panic 沿 goroutine 栈向上传播,HTTP 处理 goroutine 里 panic 会终止整个进程(除非恰好有 recover);
- Redis"读-改-写"天然不是原子的(4.2 已详述);
- 优雅关闭要在"等未完成请求"和"及时退出"之间取平衡。
方案
① panic 兜底 (logger/logger.go 的 GinRecovery):defer + recover 捕获 panic,区分"客户端断连"(broken pipe,打错误日志不记堆栈)和"真 panic"(记请求原文 + 堆栈),最后 AbortWithStatus(500)。而且这个兜底真实地救过一次场 :controller/user.go 的 LoginHandler 漏了一个 return(见 7.1-1),密码输错时会走到 ResponseSuccess(c, gin.H{"user_id": user.UserId...}),对 nil 的 user 解引用直接 panic------没有 Recovery 的话整个服务就挂了,有了它只是这一单请求失败 + 日志留痕。
② Redis 事务管道 (TxPipeline):MULTI/EXEC 打包多条命令,保证命令连续执行不被其他客户端命令插入、并减少 RTT。注意:它不是并发安全手段(读-改-写竞态照旧),真正原子化要用 4.2 的 Lua。
③ 雪花 ID 并发唯一 :bwmarrin/snowflake 内部用锁保证同一进程并发 Generate() 不重复,跨进程靠 machine_id 区分------集群部署时每台机器必须配不同 machine_id,本项目配置里固定为 1,扩容时这是个必须改的点。
④ 优雅关闭 (main.go):signal.Notify 监听 SIGINT/SIGTERM → srv.Shutdown 在 5 秒窗口内等存量请求处理完 → 关闭 MySQL/Redis 连接(defer)→ zap.L().Sync() 把缓冲日志刷盘(注释里专门解释了不 Sync 会丢日志)。
⑤ 限流 :middlewares/ratelimit.go 令牌桶 TakeAvailable(1) 写好了,但 router 里被注释掉没启用;且实现是"全局单桶",不区分 IP/用户------真要上生产得改成按 key(IP 或 user_id)限流。
结果
服务对 panic、部署信号都有兜底,这是超出大多数新手教程完成度的部分;但真正的并发正确性(投票竞态)还没解决------Recovery 是"事故不至于全灭"的最后一道网,不是正确性的替代品。
5. 踩坑与教训
这些都是从项目自带的排障记录(Bluebell问题排障与Docker部署修复记录.md)和代码里实锤验证过的坑,不是想象出来的。
5.1 一个残留的 import "C",让 Docker 构建报了一屏 "undefined"
现象 :docker-compose build 推进到编译阶段失败:
controller/Community.go:18:3: undefined: ResponseError
controller/Community.go:21:2: undefined: ResponseSuccess
... too many errors
而同样的代码在本地 Windows 下 go build 完全正常。
原因 :controller/response.go 顶部残留了一行与业务毫无关系的 import "C"。Go 有个规则:文件中出现 import "C" 就被当作 cgo 文件 (cgo 是 Go 调用 C 代码的机制);而 Dockerfile 里设置了 CGO_ENABLED=0,构建时 cgo 文件被整体排除出编译 ------于是 response.go 里定义的 ResponseData / ResponseError / ResponseSuccess 全部"消失",其它文件引用它们就报 undefined。本地能过,是因为本地 cgo 默认开启,这个文件正常参与编译,问题被掩盖。
怎么解决的 :删掉那一行 import "C";全项目扫一遍确认无残留;重新构建通过。
如果重来我会怎么做:
- 提交/构建前至少跑一次与 Docker 同参数的构建:
CGO_ENABLED=0 go build ./...,让"本地好、容器坏"在几秒内暴露; - 搞懂 cgo 的两个开关(
import "C"触发 +CGO_ENABLED门控)到底是什么含义------这行代码多半是当时乱改留下的,写代码时对"看不懂的导入"立刻删掉,别让它活到构建期; - 记住这类"本地好、环境坏"问题的排查方向:先对比编译条件差异(cgo、构建标签、GOOS/GOARCH),再怀疑代码。
5.2 照抄教程的 Dockerfile:4 个 COPY 全 "not found",构建上下文 150MB
现象 :docker-compose build 一上来就四连炸:
COPY ./wait-for.sh / ERROR: "/wait-for.sh": not found
COPY ./static /static ERROR: "/static": not found
COPY ./conf /conf ERROR: "/conf": not found
COPY ./templates /templates ERROR: "/templates": not found
原因 :Dockerfile 是照抄教程"原版项目"的,那套假设项目里有前端页面(templates/、static/)和已有的 wait-for.sh、conf/;而本项目是纯 API------这四个路径一个都不存在。连带发现一串"想当然":compose 里的启动命令写的二进制名是 bluebell(实际产物叫 bluebell_app 才行)、配置文件写的是 config.ini(实际是 config.yaml,而且 settings.Init() 用 viper 按"文件名 + 当前工作目录"找配置,根本不读命令行参数 ,传路径也是白传);还有 debian:stretch-slim 已 EOL 导致 apt 源 404;构建上下文 ~150MB(69MB 的 bluebell.log 和 tmp/ 编译产物被整个打包传给 Docker daemon)。
怎么解决的 :对照实际目录重写 Dockerfile;新建 wait-for.sh(项目原本缺失)、conf/(容器专用配置目录)、.dockerignore;stretch 换 bookworm;镜像内 WORKDIR /conf 让 viper 能找到 config.yaml。
如果重来我会怎么做:
- 写 Dockerfile 前先
ls对照实际目录结构------教程代码是参考,不是粘贴素材;每一行 COPY 都要能说出"它在拷什么、拷去哪、为什么"; .dockerignore第一版就建(.git / .idea / tmp / *.log),构建上下文从 150MB 降到几 KB,"构建慢"有一半是这种原因;- 明白"程序在哪找配置"是运行时行为(CWD、环境变量),不是构建问题------viper 的查找规则(文件名 + AddConfigPath)决定了部署目录结构,这层逻辑要在写 Dockerfile 前就想清楚。
5.3 MySQL 两连坑:--init-file 的执行时机 + 匿名卷里的"鬼数据"
现象 A :mysql 容器启动即退出(Exited(1)),日志:[ERROR] 1049 Unknown database 'bluebell'. The designated data directory /var/lib/mysql/ is unusable.
原因 A :compose 的 command 里带着 --init-file 参数。MySQL 官方镜像的 entrypoint 在初始化数据目录阶段 会把整条 command 原样带上执行------而那个时刻 MYSQL_DATABASE=bluebell 这个库还没被创建 (它由 entrypoint 初始化完成后才建)。于是 init.sql 第一行 USE bluebell; 直接报错,初始化中断,数据目录进入不可用状态。
现象 B :改用官方标准的 /docker-entrypoint-initdb.d/ 挂载机制后,应用容器仍然连不上:Error 1130: Host '172.19.0.4' is not allowed to connect to this MySQL server。
原因 B :mysql 镜像声明了 VOLUME /var/lib/mysql,未显式挂卷时 Docker 会创建匿名卷 ;docker-compose up 重建容器时默认复用匿名卷(为了不丢数据),于是新容器继承了之前"半初始化"的脏数据------初始化被跳过(日志里看不到 Initializing 字样)、root 密码不是新设的、库里没有表、远程授权不完整,应用就被拒之门外。
怎么解决的 :init.sql 改挂 /docker-entrypoint-initdb.d/(由 entrypoint 在建库之后执行,且只首次执行);然后 docker-compose down -v 把容器和匿名卷一起删掉重新初始化;验证三张表 + 种子数据 + 应用启动。
如果重来我会怎么做:
- 用官方镜像前先读 entrypoint 的行为(初始化阶段 vs 正常启动阶段的参数差异),别随手加
command参数; - 数据库数据卷显式命名 (
volumes: - mysql-data:/var/lib/mysql),不用神秘的匿名卷;执行down -v前明确知道会删什么; - 把"init 脚本仅首次初始化时执行、重置必须 down -v"这句写进项目 README------这次是靠日志现场悟出来的,下次不该再悟一遍。
5.4 登录成功,token 却"出生即过期"
现象 :容器里登录接口返回了 token,带着它请求受保护接口却返回 {"code":1007,"msg":"无效的Token"}。本地直跑没这问题。
原因 :pkg/jwt/jwt.go 的 GenToken 绕过 settings 结构体、直接用 viper.GetInt("auth.jwt_expire") 读配置 。本地 config.yaml 有 auth.jwt_expire: 8760;而容器用的 conf/config.yaml 漏写了 auth 段 → viper 读出 0 → 过期时间 = time.Now() + 0 小时 = 当前时刻 → 签发的 token 在生成的瞬间就已经过期 。更迷惑人的是 settings.AppConfig 结构体里根本没有 auth 字段,让人误以为"配置里不用写"。
怎么解决的 :conf/config.yaml 补上 auth.jwt_expire,重建应用镜像,重新登录换新 token,验证通过。
如果重来我会怎么做:
- 危险默认值要显式防御 :配置读取后加一道校验,
expire <= 0就启动报错(快速失败),而不是默默用 0 签出一批废 token; - 配置读取统一入口 :所有配置都建模进
settings.AppConfig(包括 auth 段),禁止各包各自viper.Get------这次的混乱(两套配置 + 绕过结构体)就是分散读取的代价; - 调试 JWT 先做一步机械动作:把 token 扔到 jwt.io 看
exp字段,与签发时刻接近即为这类问题。
5.5 压测 1.5 万投票全 "服务繁忙":一次暴露三个问题
现象 :压测时 15000 个投票请求全部 返回 {"code":1005,"msg":"服务繁忙"}。
原因(三层,按确定性排查):
- 错误码一刀切 :
controller/vote.go把所有错误(含"重复投票""投票时间已过"这类纯业务拒绝)统一映射成 1005 "服务繁忙"。压测脚本复用同一个 token 反复投同一个帖子,除第一条外全部命中"重复投票"------全都被伪装成了"系统故障"。 ZScore的错误被丢弃 :client.ZScore(...).Val()在命令出错时返回零值0。如果帖子 ID 不在post:timezset 里(帖子不存在、Redis 数据被清、用的伪造 ID),now - 0是十几亿秒,恒大于一周 → 每个请求 都返回ErrVoteTimeExpire→ 又全部显示 1005。所以"1.5 万全失败"还有这条独立成立的解释链。- 读改写非原子 :同一用户高并发投票时重复计分(4.2⑤ 详述)------这层最隐蔽:请求"成功"了,但数据是错的。
怎么解决的 :已定位、代码待修(修复清单:错误分类 + redis.Nil 特判 + Lua 原子化)。排查时最关键的一步是看 bluebell.log 里 logic.VoteForPost failed 的 err 字段真实内容,而不是只看响应体。
如果重来我会怎么做:
- 写"对外拒绝"类业务时,先列错误分类表(业务拒绝 vs 基础设施故障)再写代码,错误码先行;
- 全项目排查
.Val()吞 error 的写法,redis.Nil必须显式处理("key 不存在"和"值就是 0"是两回事); - 压测脚本要像真实用户(每个虚拟用户独立注册/登录、投票目标分散),且先压正确性再压性能(重复投票应返回 1009 而不是 1005);
- 固化成原则:响应体是给用户看的,日志里的 err 是给你排查用的,两者都要如实。
附赠一个小插曲:中途用 PowerShell 测登录接口返回 1001"参数错误",排查半天发现是 PowerShell 在命令行传参时把 JSON 的双引号剥掉了(服务端日志明确显示收到畸形 JSON)------假警报。测 API 用 GoLand 内置 HTTP Client 或 Postman 更稳。
6. 工程化
6.1 测试
现在做到了什么程度:项目里有两个测试文件,正好代表两种类型。
第一种,HTTP handler 单测(controller/post_test.go):用 httptest(标准库的"假 HTTP 服务",不占真实端口)把 CreatePostHandler 挂到一个测试路由上,构造"不带登录态创建帖子"的请求,断言返回 1006(需要登录):
go
func TestCreatePostHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.Default()
r.POST("/api/v1/post", CreatePostHandler)
req, _ := http.NewRequest("POST", "/api/v1/post", bytes.NewReader([]byte(body)))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
res := new(ResponseData)
_ = json.Unmarshal(w.Body.Bytes(), res)
assert.Equal(t, res.Code, CodeNeedLogin)
}
第二种,dao 集成测试(dao/mysql/post_test.go):init() 里直连真实 MySQL,测试函数插入一条固定 ID 的帖子。三个问题都值得点名:① 硬编码个人数据库密码;② 不可重复运行 ------post_id=10 撞唯一索引,第二次跑必报 Duplicate entry;③ 污染真实数据库(跑完库里多一条测试帖)。
缺什么:logic 层零测试(业务编排是核心却没覆盖);dao/redis 零测试(应该用 miniredis 这类内存版 Redis);没有表驱动测试;没有 CI 在跑这些测试(集成测试依赖本地 MySQL,CI 环境本来就跑不起来);没有覆盖率统计。
离生产还差什么 :差一整套"测试体系"。生产路线是:纯逻辑用单测(不依赖外部环境)→ dao 用 miniredis/sqlmock 或 testcontainers(每次起干净实例)→ handler 用 httptest 表驱动 → CI 里 go test ./... 必须全绿才允许合码。现在只是"有测试的意识",还不是"有测试的守门员"。
6.2 部署
现在做到了什么程度:
- 多阶段构建 (
Dockerfile):builder 阶段用 golang:alpine(GOPROXY 换国内源、CGO_ENABLED=0静态编译),运行阶段用 debian:bookworm-slim,只拷二进制 + wait-for.sh + conf/,WORKDIR /conf让 viper 按工作目录找到容器配置,EXPOSE 8084。 - 依赖等待 (
wait-for.sh):depends_on只保证容器"启动顺序",不保证"服务就绪"(MySQL 首次初始化要几十秒)。镜像的 CMD 是/wait-for.sh mysql8019:3306 redis507:6379 -- /bluebell_app------nc 轮询端口,就绪后才拉应用。这几十行脚本至少值半小时的调试时间。 - 编排 (
docker-compose.yml):mysql:8.0.19(33061:3306,init.sql挂到/docker-entrypoint-initdb.d/自动建表 + 种子数据)、redis:5.0.7(26379:6379)、bluebell_app(8888:8084)。 - 构建上下文控制 :
.dockerignore排除.git/.idea/tmp/*.log,上下文从 ~150MB 降到几 KB。
离生产还差什么 :没有 healthcheck(启动顺序有脚本保证,但运行中容器挂了没人发现、不会自动重启);镜像只有 latest 没有版本 tag、没有推仓库;容器以 root 运行(生产应换成非 root 用户);没有资源限制(mem_limit/cpus);没有 dev/prod 两套 compose 文件;数据库密码明文写在 compose 和配置里。对学习项目这些不是错,但要知道 docker-compose up 离"生产可运维"还有一大截。
6.3 配置
现在做到了什么程度 :settings.Init() 用 viper "按文件名 config + 当前工作目录"找 YAML,反序列化进全局 AppConfig 结构体;还挂了 WatchConfig 监听文件变更热更新。双环境机制:本地直跑 读根目录 config.yaml(127.0.0.1、debug、本机密码),容器运行 读 conf/config.yaml(服务名 mysql8019/redis507、release、root1234)------两套隔离,互不影响。
离生产还差什么:
- 敏感信息全部明文 :MySQL 密码在 YAML 里、JWT 密钥("夏天夏天悄悄过去")硬编码在
pkg/jwt/jwt.go、MD5 盐("liwenzhou.com")硬编码在dao/mysql/user.go。生产必须收敛到环境变量/Secret 注入,代码和配置文件里绝不放真实凭证; - 缺配置校验 :
jwt_expire缺了读出 0 也能启动(还能签发废 token),对比 MySQL 连不上直接启动失败------关键配置值也需要一道"不合理就拒绝启动"的闸门; - 热更新是半成品 :
WatchConfig只更新了结构体,MySQL/Redis 连接池还是启动时的值,改了也不会重连------现在这个能力基本只有演示价值; - 多环境切换靠"不同目录的同名文件" + 构建时 COPY,能用但原始,标准玩法是
config.{env}.yaml+ 环境变量覆盖。
6.4 日志
现在做到了什么程度 :Zap + lumberjack:JSON 结构化输出(时间/级别/caller/字段)、按 200MB/30 天/7 备份轮转;Gin 侧接了 GinLogger(每个请求记录 method/path/query/ip/user-agent/status/cost)和 GinRecovery(区分 broken pipe 与真 panic,后者带堆栈和请求原文)。业务代码统一 zap.L(),错误日志带结构化字段(如 zap.Int64("pid", pid))。这个水平在个人项目里算中上。
离生产还差什么:
- 没有 request_id:一个请求穿过的访问日志、业务日志、错误日志现在只能靠时间戳拼------高并发下根本拼不出来。中间件生成一个 UUID 挂到 Context 和日志字段里,是最便宜的改造;
- 日志与链路追踪(OpenTelemetry)没打通;
- 本地调试想看终端输出时发现
logger.Init判断的是mode == "dev",而配置里写的是"debug"------字符串对不上,所以本地跑也看不到终端日志,只能翻文件(小问题,但影响日常体验); - 没有日志级别动态调整、没有错误日志采样去重(高并发报错会刷爆磁盘)。
7. 不足与优化(按优先级排序)
按"如果这是生产系统"的口气排:一档是必须修的 ,二档是应该修的 ,三档是迟早要有的。每条给出:问题 → 为什么是问题 → 具体怎么改。
一档:正确性 / 并发安全 / 安全漏洞
1. LoginHandler 缺 return,密码错误必然触发 panic------真实 bug
- 问题 :
controller/user.go里密码错误的处理分支ResponseError(c, CodeInvalidPassword)后面没有return,代码会继续执行ResponseSuccess(c, gin.H{"user_id": user.UserId, ...});而logic.Login失败时返回nil, err,对 nil 解引用 → panic(被 Recovery 兜住,但该请求以"先错误响应后 500"的畸形方式结束)。 - 为什么是问题 :这不需要并发、不需要极端输入,任何用户输错一次密码就必然触发。
- 怎么改 :所有错误分支补
return,顺便把user == nil也防御掉:
go
if err != nil {
zap.L().Error("logic.Login failed", zap.String("username", p.Username), zap.Error(err))
if errors.Is(err, mysql.ErrorUserNotExist) {
ResponseError(c, CodeUserNotExist)
} else {
ResponseError(c, CodeInvalidPassword)
}
return // ← 关键
}
2. 投票竞态 + Redis 错误被吞 + Login 死代码(三合一)
- 问题 :
dao/redis/vote.go的"读旧值→判重→写新值"非原子(同一用户并发可重复计分);ZScore(...).Val()吞掉redis.Nil等错误,把"帖子不存在"伪装成"投票时间已过";dao/mysql/user.go的Login里if err == sql.ErrNoRows写在return err之后 ,是死代码,导致ErrorUserNotExist永远不会返回、controller 里的对应分支永远是死分支。 - 为什么是问题:竞态是"请求成功但数据错了"------最阴的一类 bug;错误吞吐导致用户收到错误提示但原因完全是另一个;死代码让错误处理形同虚设。
- 怎么改 :投票改 Lua 原子脚本(4.2 已给出完整脚本,
EVAL内"读-判-写"天然互斥);ZScore的 err 显式处理(errors.Is(err, redis.Nil)→ 返回"帖子不存在"业务错误);Login的错误判断顺序修正。
3. 分页参数 size 失效(form 标签拼写错误)
- 问题 :
models/params.go里Size int64json:"size" from:"size"`` ------from拼错,gin 只认form标签,导致查询参数?size=20被静默忽略,永远用默认 10。 - 为什么是问题:功能"看起来正常",参数"悄悄失效"------比直接报错更难发现,测试一写就露馅(而测试恰好没写)。
- 怎么改 :改
form:"size",顺手全文搜索一遍from"/from:拼写。
4. 密码用 MD5 存储
- 问题 :
dao/mysql/user.go的encryptPassword用md5(secret+password)的变体,无逐用户盐;MD5 已被证明可 GPU 暴力破解(每秒几十亿次)。 - 为什么是问题:库一旦泄露(备份泄露、SQL 注入、内部人员),全站密码等同明文,还会外溢到用户其它站点(撞库)。
- 怎么改 :换
golang.org/x/crypto/bcrypt(成本因子 10-12):注册时bcrypt.GenerateFromPassword,登录时bcrypt.CompareHashAndPassword;存量数据用"登录时校验旧哈希、成功后重写为 bcrypt"渐进迁移。
5. JWT 的可撤销性与密钥管理
- 问题 :密钥硬编码在源码;
jwt_expire: 8760= 一年有效期;无刷新、无吊销机制;ParseToken的 keyFunc 没校验签名算法(没有断言token.Method是 HMAC 族)。 - 为什么是问题:token 泄露后一年内无法作废(用户"退出登录"也只是客户端删掉字符串)、密钥进了 git 就全环境通用。
- 怎么改 :secret 走配置/环境变量且各环境独立;access token 缩到 2 小时 + refresh token 换新;要能"立即吊销"就上 Redis 黑名单(jti)或白名单,登出/改密码时删除;keyFunc 里加
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method") },防算法混淆攻击。
6. pprof 无鉴权暴露
- 问题 :
router.go里pprof.Register(r)把/debug/pprof/*直接挂在业务端口上,任何人可访问(heap、goroutine、goroutine 阻塞、CPU profile)。 - 为什么是问题 :调用栈、内存内容、服务内部状态都是攻击者的情报;
/debug/pprof/profile还会实打实跑 30 秒采样,可被用作低成本 DoS。 - 怎么改:默认不注册;需要用时分环境(只有内网/开发开)+ IP 白名单 + BasicAuth,或挪到独立的内部端口。
二档:可维护性 / 可测试性 / 性能
7. N+1 查询 ------列表接口每篇帖子查一次作者、一次社区(10 条 = 2N+2 次查询)。为什么是问题:列表页 QPS 一高,MySQL 全是重复小查询,连接池被拖满。怎么改:批量 IN 查询 + map 组装(4.3④ 有前后对比代码)。
8. 票数数组与帖子错位隐患 ------voteData 按 Redis 的 ids 下标对齐、posts 按 MySQL 结果下标遍历,有帖子缺失时票数张冠李戴。怎么改:以 post.PostID 为 key 建 map,去掉一切下标对齐假设。
9. MySQL/Redis 双写无补偿 ------logic.CreatePost 先写 MySQL 再写 Redis,第二步失败则"帖子存在但不在榜单",且无重试无对账。怎么改:至少失败上报 + 定时对账任务(扫最近创建的帖子,补齐 Redis 里的时间/分数 zset 和社区 set);正规方案是本地消息表(outbox)异步投递。
10. 社区列表缓存不主动失效 ------ZInterStore 结果缓存 1 小时,发帖后新帖最长 1 小时才出现在社区列表。怎么改:发帖成功后 DEL 对应社区两个排序 key(cache-aside:写库后删缓存),或至少把 TTL 缩到 5 分钟。
11. 索引缺失 ------post.author_id、post.community_id 无索引。现在按社区查走了 Redis 侥幸没事,但 SQL 侧一旦出现"查某用户的帖子"就是全表扫描。怎么改:按未来查询模式补二级索引(同时评估写入代价)。
12. logic 层签名耦合 gin ------所有 logic/dao 函数接收 *gin.Context,业务层绑死在 Web 框架上;而且 *gin.Context 被直接当 context 传给 go-redis,客户端一断开、命令连带被取消。怎么改:签名统一改 context.Context(controller 层传 c.Request.Context()),gin 类型只出现在 controller/middleware。
13. 错误处理吞错 ------logic.GetPostById 第一个 err 没 return(帖子不存在还继续查作者 id=0);GetPostVoteData 的 err 被忽略(出错时返回 nil,后面 voteData[idx] 会越界 panic)。怎么改:逐个把"记了日志但没 return"的地方改成"该返回就返回";panic 风险处先判空。
14. 全局变量 + 隐式初始化顺序 ------db、client、node 都是包级全局,靠 main 里手动按顺序 Init,包间依赖是隐性的(漏了一个 init 就是 nil panic)。怎么改(不急):依赖注入(构造函数传参)、或至少给每个包加"未初始化"守卫。
三档:工程化进阶
15. 请求链路日志(request_id) :中间件生成 UUID 塞进 Context + 每行日志带 request_id 字段。这是可观测性的第一块砖,改造量半天以内。
16. CI/CD :GitHub Actions 上跑 go vet + go test -race + CGO_ENABLED=0 go build + 构建镜像。注意:第 5 部分那些坑(import "C"、COPY 路径错)在这种流水线下一次就暴露,根本走不到手工排查。
17. 压测与性能基线:k6 或 wrk 对登录、发帖、投票、列表四个接口打基准,用现有 pprof 抓火焰图。先建立基线数字(QPS、P99、错误率),再谈优化------比如投票 Lua 化前后对比。
18. 监控告警:Prometheus + Grafana 暴露 QPS、P99、错误率、Redis/MySQL 连接池指标;先把"1005 突增"配上告警------如果当初有这一条,压测问题不用人工发现。
19. 测试体系 :见 6.1 的路线图;特别加一条:CI 里跑 go test -race(Go 的竞态检测器)------投票竞态这种问题它有机会直接抓出。
20. 容器进阶:命名数据卷、healthcheck、非 root 用户、镜像 tag/仓库、资源限制。
8. 收获与下一步
这个项目已经帮我练到的技能
Go 语言基本功 :包组织与分层依赖方向、自定义错误 + errors.Is + 哨兵错误、defer 资源关闭、goroutine + channel + signal 的优雅关闭、结构体标签(json/form/db/binding)的实战、validator 中文翻译器。
后端工程能力 :Gin 路由分组 / 中间件链 / 参数绑定 / 统一响应封装;手写 SQL + sqlx(结构体扫描、sqlx.In 动态 IN、连接池参数);Redis 四类结构实战(zset 时间序与排行榜、zset 计票去重、set 归组、pipeline/TxPipeline 批处理)+ key 命名空间设计;JWT 全链路的签发、验签、身份注入;Viper 双环境配置;Zap 结构化日志 + 轮转 + Gin 接入;雪花算法接入(含 machine_id 的坑);Docker 多阶段构建 + Compose 三服务编排 + 依赖等待 + 数据卷;pprof 入口与日志排查。
最值钱的软技能:你留下了排障记录(第 5 部分大部分素材来自它)。"出现象→看日志→分层假设→逐层验证→修复→记录"这条排查链路,比任何单个技术点都值钱。
还缺的生产级能力(按学习顺序)
- 测试体系 (最优先):表驱动单测、testify、httptest、miniredis/sqlmock、
-race。没有测试的项目,改一行怕砸十处,你不敢重构。 - 并发正确性的系统方法:race detector、mutex/atomic 的适用场景、Lua 原子的边界、幂等设计。你已经被投票竞态咬过一次,要把"读-判-写"式代码识别成本能。
- 数据库进阶:EXPLAIN 看执行计划、联合索引与最左前缀、事务隔离级别、慢查询定位。从"SQL 能跑"进阶到"知道它为什么快/为什么慢"。
- 缓存一致性模式:cache-aside、延迟双删、击穿/穿透/雪崩的防护(互斥重建、布隆过滤器)。这个项目目前是"无策略缓存",正好是学习起点。
- 可观测性三件套:结构化日志 → 指标(Prometheus)→ 链路追踪(OpenTelemetry)。从 request_id 开始,那是最小代价的第一步。
- CI/CD 与容器进阶:从 lint+test+build 三件套的 GitHub Actions 起步。
- 安全基础:bcrypt/argon2、OWASP Top 10、JWT 最佳实践、最小权限原则。这个项目里的 MD5 和硬编码密钥就是现成的反面教材。
如果继续迭代这个项目,下一步最该做的 3 件事
- 修掉三个真实 bug + 投票原子化 (预计 1-2 天):LoginHandler 缺 return、
size的 form 标签、Login 死代码;投票换 Lua 脚本;错误码细分(1008 投票时间已过 / 1009 重复投票)。修完用 5.5 的压测方式复验:这次应该看到 1009 而不是 1005。 - 建立最小测试与 CI 闭环 (预计 2-3 天):给 logic 层补表驱动单测、给 dao/redis 用 miniredis 补测试、把 dao/mysql 的测试改成可重复执行;GitHub Actions 跑
go vet + go test -race + CGO_ENABLED=0 go build。让"改坏东西"在推送那一刻就被拦下。 - 可观测性入场券 + 第一次压测基线 (预计 1-2 天):request_id 贯穿日志、加
/healthz健康检查、暴露 Prometheus 指标;用 k6 打一版基线写进 README,之后每次架构改动都有对比依据。
做完这三步,这个项目就从"跟着教程做了一遍"变成"有工程痕迹、有故障复盘、有质量守门"的作品集项目------面试时能聊的东西完全是两个量级。