ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Go商城读写分离实战:gin+gorm+redis+mysql主从架构与缓存优化

Go商城读写分离实战:gin+gorm+redis+mysql主从架构与缓存优化 简介这是一套基于 Gin、GORM、Redis 与 MySQL 读写分离架构的电子商城后端项目源码面向计算机专业毕业设计、课程设计及 Go 语言后端进阶学习者帮助解决高并发场景下数据库压力大、鉴权与日志链路不完善等实际问题。压缩包共 131 个文件约 666KB以 97 个 Go 源文件为核心业务与中间件实现辅以 14 个 SQL 建表与初始化脚本、2 个 YAML 及 1 个 YML 配置文件、Dockerfile 与 Makefile 等部署构建文件另有少量图片与说明文档结构清晰便于按模块研读。项目集成 JWT 鉴权、CORS 跨域、AES 对称加密并引入 ELK 日志体系、Jaeger 链路追踪与 SkyWalking 监控覆盖商品、用户、Kafka 消息等典型模块。目前已有 387 人学习下载适合希望掌握读写分离落地、可观测性建设与商城业务分层设计的读者参考复用。1. 从一张订单表说起为什么电子商城必须做读写分离单表订单量过百万之后最直观的感受不是磁盘不够而是每次大促开始商品详情页的 QPS 一冲上来数据库的 CPU 就先顶到 90% 以上。写请求其实没多少真正压垮 MySQL 的是那些反复查询商品、分类、库存的读请求。一个典型的电子商城读写比通常在 8:1 到 20:1 之间把所有流量都怼到一台主库上等于让一台机器同时干两件互相抢资源的事。这个标题里的 gingormredismysql 读写分离说的就是一套很务实的组合用 gin 做 HTTP 层gorm 做 ORMredis 扛热点缓存和分布式锁mysql 用主从结构把读和写拆开。它解决的不是“能不能跑起来”而是“跑起来之后读请求怎么不把主库拖死”。适合已经写过 Go Web 项目、手里有一台能装 Docker 的机器、准备把商城从 demo 推向能扛一点并发的人。下面我按自己搭过的一套结构把选型、落地、参数和踩过的坑讲清楚。2. gingormredismysql 的分层与读写分离选型2.1 为什么是 gorm 的 DBResolver 而不是手写双连接读写分离在 Go 里常见做法有两种一种是自己维护 master 和 slave 两个*gorm.DB在业务代码里手动选另一种是用 gorm 官方插件gorm.io/plugin/dbresolver把主从配置交给 ORM 层。我一般会选后者原因是业务代码里一旦出现db.Master().Where(...)这种调用读写分离的逻辑就散落到各个 service 里了后面加一个从库、改一次权重都得全局搜一遍。DBResolver 的工作方式是在 gorm 初始化时注册一组 sources主库和 replicas从库然后根据当前操作类型自动路由Create、Update、Delete、Exec走主库Find、First、Scan走从库。它还支持在单条语句上用Clauses(dbresolver.Write)强制走主库这对“写后立刻读”的场景很关键。选型上还有几个现实约束。MySQL 主从复制默认是异步的从库数据会比主库晚几十到几百毫秒所以任何“写完马上查”的逻辑都不能依赖从库。Redis 在这里的角色不是替代 MySQL而是把商品详情、分类树、库存余量这类读多写少的数据挡在数据库前面让读写分离的压力再降一个量级。2.2 用 Docker 起一套 MySQL 主从和 Redis先把环境跑起来再谈代码。下面这套 compose 结构是我在本地和测试环境都用过的主库 3306从库 3307Redis 6379。# docker-compose.yml version: 3.8 services: mysql-master: image: mysql:8.0 container_name: mysql-master environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: shop ports: - 3306:3306 volumes: - ./master-data:/var/lib/mysql - ./master.cnf:/etc/mysql/conf.d/master.cnf command: --server-id1 --log-binmysql-bin --binlog-formatROW mysql-slave: image: mysql:8.0 container_name: mysql-slave environment: MYSQL_ROOT_PASSWORD: root123 ports: - 3307:3306 volumes: - ./slave-data:/var/lib/mysql - ./slave.cnf:/etc/mysql/conf.d/slave.cnf command: --server-id2 --relay-logrelay-bin --read-only1 redis: image: redis:7.2 container_name: redis-shop ports: - 6379:6379 command: redis-server --appendonly yes --requirepass redis123主库配置master.cnf里至少要开log-bin和binlog-formatROW从库配置slave.cnf里设read-only1防止误写。启动之后进主库创建复制账号-- 在主库执行 CREATE USER repl% IDENTIFIED WITH mysql_native_password BY repl123; GRANT REPLICATION SLAVE ON *.* TO repl%; FLUSH PRIVILEGES; SHOW MASTER STATUS;记下File和Position然后进从库执行CHANGE MASTER TO把主库地址、账号、binlog 位置填进去再START SLAVE。用SHOW SLAVE STATUS\G看Slave_IO_Running和Slave_SQL_Running是否都是 Yes。这一步翻车最多的地方是 MySQL 8 默认认证插件复制账号必须显式用mysql_native_password否则从库连不上主库。2.3 gorm 接入主从DBResolver 配置与连接池参数环境起来之后Go 侧初始化 gorm 的代码大概是这样package db import ( time gorm.io/driver/mysql gorm.io/gorm gorm.io/plugin/dbresolver ) var DB *gorm.DB func Init() error { dsnMaster : root:root123tcp(127.0.0.1:3306)/shop?charsetutf8mb4parseTimeTruelocLocal dsnSlave : root:root123tcp(127.0.0.1:3307)/shop?charsetutf8mb4parseTimeTruelocLocal db, err : gorm.Open(mysql.Open(dsnMaster), gorm.Config{}) if err ! nil { return err } err db.Use(dbresolver.Register(dbresolver.Config{ Sources: []gorm.Dialector{mysql.Open(dsnMaster)}, Replicas: []gorm.Dialector{mysql.Open(dsnSlave)}, Policy: dbresolver.RandomPolicy{}, // 多从库时随机选 }).SetMaxOpenConns(100). SetMaxIdleConns(20). SetConnMaxLifetime(time.Hour)) if err ! nil { return err } DB db return nil }SetMaxOpenConns(100)是单实例连接池上限商城读多写少从库连接可以给大一点主库给 30 到 50 就够。SetConnMaxLifetime(time.Hour)是为了避开 MySQL 默认wait_timeout把空闲连接掐掉之后 gorm 还拿着死连接用的经典问题。Policy在多从库时用随机策略单从库可以省略。写后立刻读的场景比如下单成功后返回订单详情必须强制走主库// 下单后立即查询强制走主库避免主从延迟读到旧数据 var order Order err : db.DB.Clauses(dbresolver.Write). Where(order_no ?, orderNo). First(order).Error2.4 Redis 缓存商品详情key 设计与序列化选择商品详情是读请求里最重的一块缓存 key 我一般用shop:product:detail:{id}分类树用shop:category:tree库存用shop:stock:{sku_id}。序列化上Go 侧常见是 JSON 和 gob我倾向 JSON因为可读、跨语言、调试方便代价是比 gob 稍大一点但商品详情这种结构完全能接受。func GetProduct(ctx context.Context, rdb *redis.Client, id int64) (*Product, error) { key : fmt.Sprintf(shop:product:detail:%d, id) val, err : rdb.Get(ctx, key).Result() if err nil { var p Product if json.Unmarshal([]byte(val), p) nil { return p, nil } } if err ! redis.Nil { // 非 key 不存在的错误记录日志但继续回源 log.Printf(redis get error: %v, err) } var p Product if err : db.DB.First(p, id).Error; err ! nil { return nil, err } data, _ : json.Marshal(p) // 加随机过期时间避免同一时刻大量 key 同时失效 ttl : 10*time.Minute time.Duration(rand.Intn(120))*time.Second rdb.Set(ctx, key, data, ttl) return p, nil }TTL 加随机抖动是血泪经验固定 10 分钟会让一批 key 在同一秒集体过期然后所有请求同时打到从库从库瞬间被打穿。库存这种强一致要求高的数据不要只放 Redis要用 Redis 做扣减预判最终以 MySQL 主库的UPDATE ... WHERE stock n为准。3. 把商城核心链路接上读写分离3.1 商品列表与详情读走从库、缓存挡在前面商品列表接口是 QPS 最高的入口我的处理顺序是先查 Redis 缓存命中直接返回未命中查从库从库查完写回缓存。列表页缓存按分页维度做 key比如shop:product:list:cat:{catID}:page:{page}TTL 设短一点3 到 5 分钟因为列表对实时性要求比详情低。func ListProducts(ctx context.Context, rdb *redis.Client, catID int64, page, size int) ([]Product, error) { key : fmt.Sprintf(shop:product:list:cat:%d:page:%d, catID, page) if val, err : rdb.Get(ctx, key).Result(); err nil { var list []Product if json.Unmarshal([]byte(val), list) nil { return list, nil } } var list []Product // 这里没有 Clauses(Write)gorm 会自动路由到从库 err : db.DB.Where(category_id ?, catID). Order(id desc). Offset((page - 1) * size). Limit(size). Find(list).Error if err ! nil { return nil, err } data, _ : json.Marshal(list) rdb.Set(ctx, key, data, 3*time.Minute) return list, nil }注意Offset分页在深分页时性能会崩商品列表超过几百页之后建议改成基于id lastID的游标分页这是从库也救不了的 SQL 层问题。3.2 下单扣库存主库写、Redis 锁、事务边界下单是写链路必须走主库。库存扣减用 Redis 分布式锁把同一 SKU 的并发请求串起来再在主库事务里做条件更新。func CreateOrder(ctx context.Context, rdb *redis.Client, req OrderReq) error { lockKey : fmt.Sprintf(shop:lock:stock:%d, req.SkuID) // 锁值用随机串释放时校验防止误删别人的锁 lockVal : uuid.New().String() ok, err : rdb.SetNX(ctx, lockKey, lockVal, 5*time.Second).Result() if err ! nil || !ok { return errors.New(系统繁忙请重试) } defer func() { // Lua 脚本保证校验和删除的原子性 script : redis.NewScript( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end) script.Run(ctx, rdb, []string{lockKey}, lockVal) }() return db.DB.Transaction(func(tx *gorm.DB) error { // 条件更新库存不足时 RowsAffected 为 0 res : tx.Exec(UPDATE product_sku SET stock stock - ? WHERE id ? AND stock ?, req.Quantity, req.SkuID, req.Quantity) if res.Error ! nil { return res.Error } if res.RowsAffected 0 { return errors.New(库存不足) } order : Order{OrderNo: req.OrderNo, SkuID: req.SkuID, Quantity: req.Quantity} return tx.Create(order).Error }) }锁的过期时间设 5 秒是因为正常扣库存事务在几十毫秒内完成5 秒足够覆盖同时避免持锁进程挂掉后锁一直不释放。释放锁必须用 Lua 脚本先GET再DEL的两步操作在并发下会误删。3.3 订单查询与主从延迟哪些读必须强制走主库订单列表、订单详情这类查询如果用户刚下完单就跳转到订单页从库很可能还没同步到这条记录页面会显示“暂无订单”。我的做法是下单接口返回的订单号在后续 3 秒内的查询强制走主库超过 3 秒再走从库。func GetOrder(ctx context.Context, orderNo string, createdAt time.Time) (*Order, error) { var order Order q : db.DB.Where(order_no ?, orderNo) // 下单 3 秒内强制走主库避开主从复制延迟窗口 if time.Since(createdAt) 3*time.Second { q q.Clauses(dbresolver.Write) } err : q.First(order).Error return order, err }这个 3 秒不是拍脑袋是观察SHOW SLAVE STATUS里Seconds_Behind_Master在正常负载下的波动范围后定的。如果从库延迟经常超过 3 秒说明复制本身有问题要去查主库 binlog 写入量和从库回放速度而不是把窗口无限放大。4. 读写分离落地时的避坑与排查4.1 写完立刻读从库返回旧数据现象用户下单成功后跳转订单详情偶尔显示“订单不存在”刷新一下又有了。原因MySQL 主从异步复制写主库后从库还没回放完 binlog读请求被 gorm 路由到了从库。解决对写后立即读的查询用Clauses(dbresolver.Write)强制走主库或者按时间窗口判断下单后短时间内都走主库。4.2 从库被误写主从数据不一致现象从库上出现了主库没有的数据或者SHOW SLAVE STATUS里报Error 1290。原因从库没有设read-only1或者应用配置里把从库 DSN 错配到了 Sources 里。解决从库启动参数加--read-only1同时检查 gorm 的dbresolver.Config确认从库 DSN 只出现在Replicas里。另外read-only对 root 用户不生效复制账号和应用账号要分开。4.3 Redis 缓存与数据库不一致现象后台改了商品价格前台详情页还是旧价格要等 TTL 过期才更新。原因更新数据库后没有主动删缓存或者删缓存和更新数据库的先后顺序反了。解决采用“先更新数据库再删除缓存”的顺序删除失败时把 key 投递到消息队列重试。不要用“更新缓存”代替“删除缓存”并发下更新缓存更容易写出脏数据。4.4 连接池耗尽请求排队超时现象压测时接口大量超时日志里出现too many connections或connection pool timeout。原因SetMaxOpenConns设得过大超过 MySQL 的max_connections或者从库连接泄漏事务没提交导致连接不释放。解决主库max_connections默认 151应用侧SetMaxOpenConns要按实例数分摊比如 3 个实例每个设 40。同时检查所有Transaction是否有 panic 未捕获导致连接不归还。4.5 分布式锁过期时间设太短业务还没跑完锁就没了现象并发下单时出现超卖日志显示同一 SKU 被两个请求同时扣减成功。原因锁的过期时间设成了 1 秒但事务里还有远程调用实际执行超过 1 秒锁自动释放后第二个请求拿到锁。解决锁过期时间要大于业务最大执行时间同时用看门狗机制在业务未完成时续期。更稳妥的做法是库存扣减最终以主库UPDATE ... WHERE stock n的条件更新为准锁只是减少冲突不是唯一防线。5. 用压测数据验证读写分离到底有没有用搭完之后别急着上线先用wrk或hey压一轮看从库到底分担了多少。我一般会对比三个场景全部走主库、读写分离但不开缓存、读写分离加 Redis 缓存。下面是我在一台 4 核 8G 机器上跑商品列表接口的记录数据只代表这套配置的量级不是绝对标准。场景并发QPSP99 延迟主库 CPU全走主库2001800320ms85%读写分离2003400180ms42%读写分离缓存200920065ms18%从数据能看出来读写分离把主库 CPU 从 85% 降到 42%QPS 翻了近一倍加上 Redis 缓存之后QPS 再翻两倍多主库基本没压力。验证从库是否真的在扛读请求可以在从库上开general_log看几秒或者直接看SHOW GLOBAL STATUS LIKE Com_select的增长速度。还有一个我常做的验证故意把从库停掉看应用是否还能正常提供写服务。如果从库挂了之后整个商城都不可用说明代码里有读请求没走 gorm 的路由而是直接用了主库连接或者缓存击穿后所有请求都压到了主库。这种故障演练比看监控更能暴露问题。最后说个习惯每次改完读写分离配置我都会在从库上执行SHOW SLAVE STATUS\G确认Seconds_Behind_Master为 0 再继续。主从延迟是这套架构里最容易被忽略的变量它不报错但会在某个并发高峰悄悄把脏数据返回给用户。希望帮到你。本文还有配套的精品资源点击获取
返回列表