简介:面向毕业设计与课程设计场景的电子商城后端项目,完整使用 Gin、GORM、Redis 与 MySQL 构建并实现读写分离。项目覆盖 JWT 鉴权、CORS 跨域、AES 对称加密等安全与中间件处理,同时引入 ELK 体系便于日志查看,集成 Jaeger 与 SkyWalking 进行链路追踪与性能观测,适合学习 Go 服务端架构或快速搭建商城类项目的开发者。资源包共 131 个文件,压缩包约 666KB,以 97 个 Go 源码文件为主,另有 14 个 SQL 脚本、YAML/YML 配置、Dockerfile、Makefile 及若干图片说明,能直接反映项目目录结构和部署配置。目前已有 387 人学习下载。借助源码、数据库脚本与配置文件,可对照理解读写分离实现、用户与商品模块设计、日志与链路追踪接入方式,也可作为毕业设计或课程设计的参考起点。
1. 拿到这个商城项目 zip,先读懂标题里四个组件的位置
拿到「电子商城.zip」这个标题,我大概能猜到它想表达什么:一个用 Go 写的商城后端,Gin 负责 Web 接口,Gorm 操作 MySQL,Redis 在中间做缓存和并发控制。真正值得认真看的是「读写分离」四个字——商城天然读多写少,商品详情、订单列表、分类页全是读流量,如果查询全部压在主库,写入延迟会越来越难看。这套方案的核心,就是把读流量从主库剥到从库,让主库专心处理下单、支付这类写操作。适合谁?想从「会 CRUD」走向「会架构」的 Go 后端开发者,以及拿到这类项目看不懂路由和连接池配置的新人。全文不打空理论,直接按落地顺序讲。
2. 先让 MySQL 主从就位,再谈 Gorm 层的读写分离
读写分离其实是两层事情:MySQL 本身要先把主从复制搭起来,Gorm 才能根据语句类型把读请求和写请求分发到不同连接。很多人把注意力全放在代码上,忽略第一层,结果从库根本没同步到数据,查询一上去就是各种报错。这一章把两层拆开讲,每一层都能照着配。
2.1 从库不是加台机器就完事:主从复制的三个前提
主从复制是读写分离的地基。MySQL 主库把写操作记录到 binlog,从库拉取 binlog 并重放到本地,这个过程对业务层完全透明。但它能跑起来,有三个前提必须满足,缺一个,后面 Gorm 配置得再漂亮也白搭。
前提一:主库开启 binlog,并且格式要设成 ROW。STATEMENT 格式在某些带函数的 SQL 下会产生主从数据不一致,商城这种订单、库存场景经不起这种隐患。前提二:主库和从库的 server-id 必须不同,否则复制链路会互相干扰。前提三:从库需要一个复制专用账号,权限不需要给太大,够拉 binlog 就行。
# my.cnf(主库) [mysqld] server-id = 1 log-bin = mysql-bin binlog_format = ROW gtid_mode = ON-- 从库执行:指定主库地址与复制账号 CHANGE MASTER TO MASTER_HOST='192.168.1.10', MASTER_USER='repl_user', MASTER_PASSWORD='YourStrongPass', MASTER_AUTO_POSITION=1; START SLAVE; SHOW SLAVE STATUS\G这里有一个容易踩的细节:如果没用 GTID 模式,MASTER_AUTO_POSITION=1是起不来的。你需要先在主库执行SHOW MASTER STATUS,拿到 binlog 文件名和 Position 位点,再把上面两条换成MASTER_LOG_FILE和MASTER_LOG_POS来指定。判断复制是否正常,就看SHOW SLAVE STATUS\G输出里的Slave_IO_Running和Slave_SQL_Running是不是都是Yes,一个是拉取线程,一个是重放线程,任一不是 Yes,读写分离后面全是错。
2.2 在 Gorm 里挂上 Resolver:读写分离的最小配置
MySQL 层复制就位后,才轮到应用层决定每条 SQL 该往哪走。Gorm 官方提供的方案是gorm.io/plugin/dbresolver插件,通过注册写库和读库,让 CRUD 自动分流。常见做法是gorm.Open先用主库 DSN 建立默认连接,再用db.Use把 resolver 挂进去,读库可以一次性注册多个,做负载均衡。
import ( "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/plugin/dbresolver" ) dsnWrite := "root:pass@tcp(192.168.1.10:3306)/mall?charset=utf8mb4&parseTime=True&loc=Local" dsnRead := "root:pass@tcp(192.168.1.20:3306)/mall?charset=utf8mb4&parseTime=True&loc=Local" db, err := gorm.Open(mysql.Open(dsnWrite), &gorm.Config{}) if err != nil { panic(err) } err = db.Use(dbresolver.Register(dbresolver.Config{ Sources: []gorm.Dialector{mysql.Open(dsnWrite)}, // 写库 Replicas: []gorm.Dialector{mysql.Open(dsnRead)}, // 读库,可挂多个 Policy: dbresolver.RandomPolicy{}, // 多从库选择策略 })) if err != nil { panic(err) }这段代码的逻辑很直白:insert、update、delete 默认走Sources,也就是写库;事务外的 select 默认走Replicas,也就是读库。Policy决定多个从库之间的选择方式,RandomPolicy是随机,还有RoundRobinPolicy是轮询,商城场景我更倾向于随机,避免某个从库因为轮询顺序被固定打满。
| 参数 | 作用 | 常见误用 |
|---|---|---|
| Sources | 声明写库连接 | 不配置 Sources,写操作落回默认连接,路由语义混乱 |
| Replicas | 声明读库连接,可配多个 | 把主库也加进 Replicas,读流量回漏主库,读写分离失去意义 |
| Policy | 多从库分配策略 | 固定轮询忽略从库性能差异,一台慢拖垮整体 |
注册时还可以调用连接池方法,SetMaxOpenConns(100)、SetMaxIdleConns(10)、SetConnMaxLifetime(time.Hour)。注意这些参数是对每个从库实例分别计算的,比如两个从库各 100 个连接上限,总共理论上是 200,千万不能拿这个数直接去对 MySQL 的最大连接数。
2.3 事务、锁与强制主库:读写分离最容易误用的部分
事务是读取分离里最容易被绕晕的地方。默认情况下,事务一旦开启,事务里的所有语句都会落在主库连接上,哪怕你只是在事务里写了一个 select。原因是事务需要快照一致性,如果读走从库,那从库还没同步主库刚写的数据,同一个事务里读到的东西前后可能矛盾,这比性能问题更致命。
// 事务内所有语句都走主库,包括查询 err := db.Transaction(func(tx *gorm.DB) error { var count int64 tx.Model(&Order{}).Where("user_id = ?", userID).Count(&count) if err := tx.Create(&order).Error; err != nil { return err } return nil })这段代码里的Count最终执行的 SELECT 不会走到从库,而是留在主库。很多新手在这里纠结「事务里能不能让只读语句走从库」,我的建议是别做这种微优化,收益不大,却会增加连接管理复杂度和一致性风险。真有只读需求,就拆出事务,先提交写操作,再单独发起查询。
还有一个实际场景:用户刚提交订单,立刻跳转到订单详情页,这时候如果详情查询走了从库,而主从同步还没完成,页面会显示订单不存在。应对手段是给这类「写后立即读」的查询强制走主库,Gorm 提供db.Clauses(dbresolver.Write),可以绕过默认路由直接命中写库。这条经验后面讲避坑时还会展开。
3. Redis 的位置:商品缓存、库存锁与接口限流
MySQL 主从分离后,读压力从主库转移到了从库,但同一个热点商品被反复查询时,从库的磁盘和 CPU 也在白白消耗。标题里的 Redis 就是来解决这个问题的。商城场景里 Redis 最常见的岗位是三个:商品详情缓存、库存扣减的分布式锁、接口限流。这一章逐个说清楚。
3.1 商品详情的缓存方案:读取路径与失效时机
商品详情是商城读流量最大的一块。常见做法是 Read-Through,先查 Redis,没有就去数据库捞,再回填缓存。这里的数据库查询会被 dbresolver 自动路由到从库,主库一点不掺和。
func GetProduct(ctx context.Context, id int64) (Product, error) { var p Product key := fmt.Sprintf("mall:product:%d", id) // 先读缓存 cached, err := rdb.Get(ctx, key).Result() if err == nil { _ = json.Unmarshal([]byte(cached), &p) return p, nil } // 缓存未命中,回源到数据库,这条 SELECT 会走从库 if err := db.WithContext(ctx).First(&p, id).Error; err != nil { return p, err } // 回填缓存,10 分钟过期 data, _ := json.Marshal(p) rdb.Set(ctx, key, data, 10*time.Minute) return p, nil }逻辑就是三条路:缓存命中直接返回,缓存没命中查从库,查到后回填。过期时间 10 分钟是保守值,商品详情里的价格、库存就算延迟几分钟,大多数用户也感知不到。但如果后台改了商品名或价格,不能等缓存自然过期,必须在更新数据库后主动Del这个 key,否则用户看到的永远是旧数据。
这里有一个必须处理的边界:如果查的是一个不存在的商品 ID,First会返回错误,但函数直接返回了,没有写缓存。这样一来,攻击者只要不断用不存在的 ID 刷接口,每次都会穿透到从库,这就是缓存穿透。我一般会缓存空值,把空 JSON 也塞进 Redis,过期时间设 60 秒,让恶意流量打在缓存上而不是数据库上。
3.2 库存扣减的分布式锁:SETNX 的正确用法
商城秒杀和抢购场景,多个请求同时扣同一件商品的库存,如果不加控制,会出现超卖。Redis 分布式锁是常见解法,核心就是 SETNX 命令,同一个 key 只能被一个客户端成功写入。
// 加锁:SETNX 保证同一时刻只有一个客户端能写入成功 func AcquireLock(ctx context.Context, key, token string, expire time.Duration) (bool, error) { res, err := rdb.SetNX(ctx, key, token, expire).Result() if err != nil { return false, err } return res, nil } // 释放锁:用 LUA 脚本保证“判断 token 再删除”是原子操作 func ReleaseLock(ctx context.Context, key, token string) error { script := ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end` _, err := rdb.Eval(ctx, script, []string{key}, token).Result() return err }加锁参数里最关键的是 token 和 expire。token 必须是每个请求唯一的,比如用户 ID 加纳秒时间戳,释放锁时只有持有者自己才能删掉这把锁。如果用固定值,A 请求的锁被 B 请求释放,后面所有并发控制都会失效。expire 是兜底时间,防止持有锁的进程崩了导致锁永远不释放,但也不能设太短,业务还没执行完锁就过期,另一个请求就会挤进来。
释放锁必须用 LUA 脚本把 get 和 del 做成原子操作。分开写的话,先 get 判断 token 是自己,再 del,中间可能被另一个请求插进来,这条血泪经验后面避坑章还会再提。
3.3 用 Redis 挡住瞬间流量:接口限流与热点保护
即使加了缓存,总有一些场景会打到数据库,比如搜索接口、分类页接口。给这类读接口加一层简单限流,可以在流量瞬间暴涨时保护后端的从库。固定窗口是最容易实现的方案,用一个秒级 key 做计数器。
// 固定窗口限流:每个自然秒最多 100 次请求 key := fmt.Sprintf("mall:rate:products:%d", time.Now().Unix()) n, err := rdb.Incr(ctx, key).Result() if err == nil && n == 1 { rdb.Expire(ctx, key, time.Second) } if n > 100 { // 返回 429 或降级提示 }这个实现的优点是只需要两个命令,缺点是固定窗口在窗口切换的瞬间可能放过两倍流量,比如 99 和 100 恰好跨在秒级边界上。商城项目一般够用,想更严谨就换成滑动窗口,用 ZSET 记录时间戳,限制窗口内的去重计数,代价是内存开销更大。限流不是越严格越好,阈值要按从库压测结果反推,后面第 6 章会讲怎么验。
4. 下单链路走一遍:路由、事务与缓存顺序
商城最核心的业务是下单。标题里的四个组件在这条链路里全部交汇:Gin 收请求,Gorm 负责落库,Redis 锁库存,MySQL 读写分离决定每条语句去哪。上一章把组件单独拆开讲,这一章把它们组装成一条完整链路,照着这个顺序写,基本不会出大问题。
4.1 Gin 路由与鉴权中间件:读接口和写接口的分层
先看主入口。Gin 的写法很固定,路由分组配合中间件,把读接口和写接口放在同一组里统一鉴权。真正重要的不是路径写得多花哨,而是所有 handler 都能从 context 里拿到当前用户身份,不要每个接口自己解析一遍 token。
func main() { r := gin.Default() // 全局中间件:日志、异常恢复 r.Use(gin.Logger(), gin.Recovery()) v1 := r.Group("/api/v1") v1.Use(AuthMiddleware()) // JWT 鉴权,未登录直接 401 { v1.GET("/products", ListProducts) // 读多写少,走从库 v1.GET("/products/:id", GetProduct) // 商品详情,配合 Redis 缓存 v1.POST("/orders", CreateOrder) // 下单,写操作集中在这里 v1.GET("/orders", ListOrders) // 订单列表,读操作 } _ = r.Run(":8080") }gin.Default()自带 Logger 和 Recovery 两个中间件,日志会打印每个请求的耗时和状态码,排查问题时非常有价值。上线后可以把 Logger 替换成自定义版本,把 request ID 加进去,这样后面排查主从延迟问题时,能通过一个 request ID 串起 Redis 操作、数据库查询和日志三条线索。
4.2 下单事务的三步顺序:锁库存、建订单、删缓存
下单接口的核心流程我习惯拆成三段:先拿 Redis 锁,再开数据库事务扣库存、建订单,事务成功后删商品缓存。顺序不能乱,锁必须在事务之前拿,缓存删必须在事务之后做。
func CreateOrder(ctx context.Context, userID, skuID, num int64) error { lockKey := fmt.Sprintf("mall:stock:%d", skuID) token := fmt.Sprintf("%d-%d", userID, time.Now().UnixNano()) // 第一步:加分布式锁,避免同一 SKU 的并发下单 if ok, _ := AcquireLock(ctx, lockKey, token, 30*time.Second); !ok { return errors.New("操作太频繁,请稍后重试") } defer ReleaseLock(ctx, lockKey, token) // 第二步:事务内扣库存和建订单,自动走主库 err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 条件更新:stock >= num 不满足时影响行数为 0,防止超卖 res := tx.Model(&Inventory{}). Where("sku_id = ? AND stock >= ?", skuID, num). Update("stock", gorm.Expr("stock - ?", num)) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return ErrStockNotEnough } // 写入订单 return tx.Create(&Order{ UserID: userID, SkuID: skuID, Num: num, }).Error }) if err != nil { return err } // 第三步:删除商品缓存,下次读取重新加载最新数据 rdb.Del(ctx, fmt.Sprintf("mall:product:%d", skuID)) return nil }这段代码里最值得说的是库存扣减为什么用条件 UPDATE 而不是先 SELECT 再 UPDATE。WHERE stock >= ? AND SET stock = stock - ?是原子操作,数据库行锁会保证同一时刻只有一个事务在改这一行。如果你先查库存,判断够,再执行 UPDATE,两个请求同时读到库存 1,先后 UPDATE 后库存会变成 -1,这就是经典的丢更新问题。
Redis 分布式锁和数据库条件更新不是重复劳动,它们解决的问题不同:锁控制的是请求在应用层的排队,拦截掉大量重复点击;条件更新是最终防线,即使锁因为超时意外失效,数据库层也能兜住超卖。两条都在,库存才真正安全。
4.3 订单列表的查询:把读流量从主库剥离开
订单列表是典型的读多接口,用户翻页查自己的历史订单,没有任何写操作。显式声明走从库,能让这段代码在后续维护时一眼看懂意图。
func ListOrders(ctx context.Context, userID int64, page, size int) ([]Order, error) { var orders []Order // 显式走从库,避免默认路由行为被改动后影响该接口 err := db. Clauses(dbresolver.Read). WithContext(ctx). Where("user_id = ?", userID). Order("id DESC"). Offset((page - 1) * size). Limit(size). Find(&orders).Error return orders, err }Clauses(dbresolver.Read)是显式指定读库,即使后面有人把 dbresolver 默认配置改动了,这个接口的路由也不会变。分页参数必须校验,page最小为 1,size限制在 100 以内,否则恶意请求用一个特别大的 Offset 能让从库扫描大量行,CPU 被打满只是时间问题。需要算总数时就单独执行 Count 查询,同样走从库,但要留意这个查询在数据量大时也很重,后面避坑章会展开。
5. 读写分离商城避坑:最容易翻车的五个点
这一章是我认为全文最值钱的部分。读写分离本身的配置并不玄学,但真实场景里翻车的地方往往不在配置上,而在业务细节里。每一条都按「现象、原因、解决」的顺序写,对应你在压测和线上最容易撞见的五个坑。
5.1 刚下完单就查不到订单:主从延迟的尴尬
现象:用户刚提交订单,页面跳转到订单详情,显示订单不存在,用户刷新一下又能看到了。客服一天能收到好几条这样的投诉,但后端日志里订单确实已经写入成功。
原因:订单写入主库后,从库的 binlog 同步需要几十毫秒甚至更久。订单详情的查询默认走了从库,从库还没执行到这条写入,自然查不到。主从延迟在低峰期不明显,一旦赶上促销流量,从库重放线程繁忙,延迟可能到秒级。
解决:把「写后立即读」的查询强制走主库。我一般在创建订单成功后,把当前请求的 context 打上一个「刚写过」的标记,订单详情接口读到这个标记就走Clauses(dbresolver.Write),其他普通请求继续走从库。这样只影响下单用户自己的那几次查询,不会把大量读流量带回主库。
5.2 事务里的读操作把从库绕开了:这是设计不是 bug
现象:配置了 Replicas 之后,从库的 QPS 一直很低,主库的慢查询日志里反而全是 SELECT。检查代码发现这些 SELECT 都写在了事务里。
原因:事务开启后,dbresolver 为了保证一致性,会把事务内的所有语句固定在写库连接上,不区分读写。这是框架的既定行为,不是 bug。如果你在一个大事务里做了好几秒的查询,这些查询全都压在主库上。
解决:开事务之前把需要读的数据查好,事务里只保留写操作。实在需要事务里的最新数据做判断,就等事务提交后再查一次。我见过有人试图在一个事务里用两个连接分别走主库和从库,这种做法会破坏事务的原子性,一旦中途出错,数据一致性根本没法保证。
5.3 缓存穿透打到从库,从库 CPU 被打满
现象:某天从库 CPU 冲到 100%,主库正常,商品详情接口响应时间大幅上涨。检查从库 processlist,发现大量相同条件的 SELECT 在反复执行。
原因:有人在刷不存在的商品 ID,或者前端遍历 ID 时撞上了大量已下架商品。这些 ID 在缓存里没有值,每次请求都回源到数据库,从库被无效查询打满。
解决:缓存空值是最快的止血方案,把不存在的 ID 也写进 Redis,过期时间 60 秒。更彻底的方案是布隆过滤器,但维护成本高,我通常先上缓存空值,压测确认效果再考虑是否升级。同时给详情接口加上 3.3 节里的限流,双保险。
5.4 连接池参数乱调,从库一多就超时
现象:从库从 1 台扩到 3 台后,应用开始偶发 connect timeout,报错信息指向数据库连接池。查看数据库侧连接数并不高,但应用侧日志明显有连接获取超时。
原因:dbresolver 的连接池参数在注册时没有单独配置,走的是 gorm 默认值,连接数上限很低。并且连接池是按从库实例分开算的,参数看着够用,实际上被多个从库均分了。
解决:给 Resolver 注册连接池参数,SetMaxOpenConns(100)、SetMaxIdleConns(10)、SetConnMaxLifetime(time.Hour),layout 在 2.2 节。特别注意SetConnMaxLifetime必须小于数据库端的 wait_timeout,否则连接被 MySQL 侧回收后,应用还在用旧连接,报错会更隐蔽。
5.5 分布式锁过期导致重复扣减:后悔药不好吃
现象:压测下单接口,库存 100 件,最终成交 101 单,后台订单里出现了两条相同的扣减记录。
原因:锁过期时间设短了,业务线程还没执行完事务,锁先释放,第二个请求拿到锁进入事务。如果库存扣减写的是先 SELECT 再 UPDATE,第二个请求读到旧库存,执行 UPDATE 时把第一个请求的修改覆盖了,丢更新。
解决:加锁过期时间按接口耗时的上限乘 2 来设,不能只按平均耗时。库存扣减必须用UPDATE ... SET stock = stock - ? WHERE stock >= ?这种条件原子更新,把并发控制兜底交给数据库行锁。释放锁必须走 LUA 脚本校验 token,这三个动作缺一个,库存账单就会对不上。
6. 上线前做三件事:验证路由、压测从库、保留强制走主库的开关
最后这章讲三个具体动作,不是收尾仪式,是每次改动完读写分离之后我必做的检查项。做完这三件事,心里才有底说这套方案真的在按预期跑。
6.1 用 processlist 和 EXPLAIN 验证读写是否真的分流
代码配置完,第一件事是验证读流量到底有没有走到从库。打开 Gorm 的日志输出,让每条 SQL 打印到终端,然后在从库执行SHOW PROCESSLIST,观察是否有来自应用连接池的 SELECT。如果从库上什么都看不到,说明路由配置有误,回去查db.Use是否在打开连接后执行成功。
还有一个必做动作是 EXPLAIN 慢查询。像订单列表这类走从库的查询,用 EXPLAIN 看执行计划,确认是否命中了索引。ORDER BY id DESC在订单表有主键索引,没问题;但如果你写的是WHERE status = ? ORDER BY created_at DESC,没有联合索引的话,从库要 filesort,数据量一上来就扛不住。压测从库时重点看这类查询。
6.2 把「强制主库」做成请求级开关,而不是全局改代码
读写分离上线后,主从延迟问题一定会以某种形式冒出来。为了避免每个接口都写Clauses(dbresolver.Write),我把强制主库做成一个 context 级别的开关,在中间件里按需打开。这样后续面对具体业务场景,加一行标记就能解决,不用满仓库找查询代码。
// 用类型定义 context key,避免字符串冲突 type ctxKey string const forceMasterKey ctxKey = "force_master" // 请求级强制主库 func ForceMaster(ctx context.Context) context.Context { return context.WithValue(ctx, forceMasterKey, true) } // 统一从 context 判断本次查询是否需要走主库 func DBFrom(ctx context.Context) *gorm.DB { if v, ok := ctx.Value(forceMasterKey).(bool); ok && v { return db.Clauses(dbresolver.Write).WithContext(ctx) } return db.WithContext(ctx) }代码很简单,但它把「主从延迟补偿」从散落的业务代码收敛到了一个统一入口。每次新接一个接口,先在中间件里决定它是普通读、强一致读、还是写操作,不需要反复翻老代码。我最大的教训就是上线第二天就撞上了「刚下单查不到订单」的问题,那时候才明白主从延迟不是文档里的一句话,而是真实发生的用户体验事故。后来养成的习惯是:所有涉及钱的接口都默认留强制主库开关,所有列表查询都默认走从库,绝不混着用。这套骨架不难,难的是每一层都验证过再往上层堆,经验不值钱,验过一次才值钱。希望帮到你。
本文还有配套的精品资源,点击获取