
简介基于 Gin 框架、GORM 与 Redis、MySQL 读写分离架构的电子商城项目资源面向 Go 后端学习者及毕业设计、课程设计人群可作为毕设或课设的完整参考用于快速搭建包含用户、商品、订单等核心模块的电商服务。压缩包共 131 个文件以 97 个 Go 源文件为主干另有 14 个 SQL 脚本、YAML/YML 配置、Dockerfile、Makefile 等覆盖数据库初始化、容器化部署与构建流程整体仅 666KB目录清晰便于查阅。目前已有 387 人学习适合用于项目实战与架构学习。项目实现了 JWT 鉴权、CORS 跨域、AES 对称加密并引入 ELK 日志体系、Jaeger 链路追踪与 SkyWalking 监控组件还涉及 Kafka 消息队列相关实现可从中获得完整源码、数据库初始化脚本、容器化部署方案及可观测性配置思路。1. 基于 gingormredismysql 读写分离的电子商城怎么从 zip 变成可压测的线上方案打开一个写着“电子商城.zip”的项目时我最关心的不是它有多少页面而是它的数据层有没有做读写分离。你很可能也遇到过这种场景商品详情接口在本地跑得好好的一上线上并发就慢查询刷屏。基于 gingormredismysql 读写分离的电子商城正是把 Gin 的 HTTP 能力、GORM 的数据库抽象、Redis 的缓存加速和 MySQL 主从架构组合在一起让读流量尽量不压主库。适合两类人一类是刚学完 Go Web 想做一个完整商城的同学另一类是已经有单体商城、想搞明白读写分离怎么落地的开发者。下面按我的实际操作顺序拆开讲架构怎么设计、代码怎么写、坑在哪里、怎么压测。2. 电子商城的读写分离架构为什么选这个组合数据流怎么走2.1 读写分离的适用边界商城哪些接口吃这套方案先明确一点读写分离不是银弹。商城业务大部分是读多写少商品列表、商品详情、订单查询这些接口读频率极高而真正写库的只有下单、支付回调、售后状态变更。读流量大了之后单库 MySQL 的 CPU、IO 和连接数都会成为瓶颈读写分离把只读查询分流到从库主库只留写操作等于把“读压力水平扩展”。但它解决不了写瓶颈——如果一场秒杀几万人同时下单瓶颈在写和锁再多的从库也帮不上忙。所以我一般会建议中小型商城的读 QPS 到 2000 以上再上读写分离几千到一两万读流量时这套架构非常够用但别指望它能处理每秒几万的写。要实现读写分离底层需要 MySQL 主从复制。主库把变更写进 binlog从库把 binlog 拉过来写到自己的 relay log再线性重放。这个过程的延迟通常只有几毫秒到几百毫秒但在商城场景里一个刚创建完的订单如果马上读从库可能恰好赶上延迟窗口这是后面第 4 章要处理的头号坑。所以架构设计的第一步不是写代码是先理解“读可能读到旧数据”这个边界。2.2 一次商品详情请求到底走了哪几条路我把流量分成三类写流量比如下单Gin 的 handler 解析参数后service 层调用 repositoryrepository 直接对主库执行 INSERT、UPDATE读流量比如商品详情先查 RedisRedis 没命中再查从库查到后回填 Redis一致性读流量比如用户支付后立刻查看自己的订单需要走主库避免主从延迟造成“订单消失”。这个分流逻辑在 GORM 里是通过 dbresolver 插件实现的。注册时指定 Sources 为主库、Replicas 为从库GORM 默认把 DDL、INSERT、UPDATE、DELETE 路由到 Sources把 SELECT 路由到 Replicas。但注意一个细节如果某个操作处于事务中事务内所有 SQL 都会固定在事务打开的连接上也就是说事务里先 UPDATE 再 SELECTSELECT 也会走主库。这其实是我们想要的因为事务内的读往往要求读到事务中已写入的值。了解这个行为后你就不会困惑“为什么我开了事务后读请求没有走从库”。Redis 的位置在主从之前。缓存命中的请求根本不会碰 MySQL只有缓存 miss 才打到从库。用 Cache Aside 模式读请求先查缓存miss 再查库并回填写请求更新主库成功后删除缓存而不是更新缓存。删除缓存的好处是避免并发写导致缓存里残留旧值代价是下一次读会 miss但 miss 一次回源可以接受。排障时经常提到的“redis 缓存治理”核心就是管好这个 miss 比例和过期策略。2.3 用 Docker 把 MySQL 主从搭起来一主一从的最小配置本地开发最好用 Docker别手动到 Windows 或 macOS 装两个 MySQL浪费时间。网上搜到的 mysql 安装教程很多但主从复制还是容器最省事。下面是我常用的 docker-compose 文件注意不是生产配置够本地跑通。version: 3.8 services: mysql-master: image: mysql:8.0 container_name: mysql-master ports: - 3306:3306 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: mall command: - --server-id1 - --log-binmysql-bin - --binlog-do-dbmall mysql-slave: image: mysql:8.0 container_name: mysql-slave ports: - 3307:3306 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: mall command: - --server-id2 - --relay-logmysql-relay-bin - --read-only1 depends_on: - mysql-master逻辑说明主库通过--log-binmysql-bin打开 binlog--binlog-do-dbmall只同步 mall 库从库--relay-logmysql-relay-bin接收中继日志--read-only1保证从库不会被误写入。容器起来后需要在从库里手动执行复制指令。CHANGE MASTER TO MASTER_HOSTmysql-master, MASTER_PORT3306, MASTER_USERroot, MASTER_PASSWORDroot, MASTER_LOG_FILEmysql-bin.000001, MASTER_LOG_POS154; START SLAVE; SHOW SLAVE STATUS\G参数说明MASTER_LOG_FILE和MASTER_LOG_POS必须在主库执行SHOW MASTER STATUS查到不是固定值MASTER_USER如果不想用 root可以单独建一个只拥有 REPLICATION SLAVE 权限的账号。注意Docker 容器之间用服务名mysql-master互相访问但宿主机上的 GORM 要连 127.0.0.1 和映射端口。如果从库一直显示Connecting多半是两个容器没在同一条网络或者 master 的 root 账号不允许来自 slave 容器的访问。排查时可以在 slave 容器里ping mysql-master试一下。3. 把商城核心链路落进 GinGORMRedis骨架、读写分离配置与缓存代码3.1 搭建 Gin 工程为什么要按 controller/service/repository 分层下载一个商城 zip第一步不是去看页面而是看目录是不是清晰的分层。我习惯把工程拆成 controller / service / repository / model 四层controller 层只做参数绑定、登录鉴权和响应封装service 层写业务规则repository 层收口所有 SQL 和 Redis 操作。这样做的原因是读写分离后SQL 的路由大概率要改如果 SQL 散落在 handler 里你会改到怀疑人生。用 gin 脚手架或者自己建目录都行下面是 main.go 的最小启动代码。package main import ( github.com/gin-gonic/gin gorm.io/gorm github.com/redis/go-redis/v9 ) func main() { r : gin.Default() db : InitDB() rdb : redis.NewClient(redis.Options{ Addr: 127.0.0.1:6379, Password: , DB: 0, }) productController : NewProductController(db, rdb) r.GET(/api/product/:id, productController.Detail) _ r.Run(:8080) }逻辑说明InitDB是后面要写的 GORM 初始化redis 客户端用 go-redis 的 v9 版本。Password 留空是因为本地 Redis 默认没有密码如果你在 macos 安装 redis 时设置了 requirepass这里要对应填。参数说明Addr 不要写http://连接池参数先用默认后面压测再调。3.2 GORM 注册主从数据源Sources 写Replicas 读这是整个项目最核心的配置。我用的插件是gorm.io/plugin/dbresolver它会在 GORM 的 Open 之后用Use挂上去。看代码import ( gorm.io/driver/mysql gorm.io/gorm gorm.io/plugin/dbresolver time ) func InitDB() *gorm.DB { mainDsn : root:roottcp(127.0.0.1:3306)/mall?charsetutf8mb4parseTimeTrue replicaDsn : root:roottcp(127.0.0.1:3307)/mall?charsetutf8mb4parseTimeTrue db, err : gorm.Open(mysql.Open(mainDsn), gorm.Config{ Logger: logger.Default.LogMode(logger.Info), }) if err ! nil { panic(err) } err db.Use(dbresolver.Register(dbresolver.Config{ Sources: []gorm.Dialector{mysql.Open(mainDsn)}, Replicas: []gorm.Dialector{mysql.Open(replicaDsn)}, Policy: dbresolver.RandomPolicy{}, })) if err ! nil { panic(err) } sqlDB, _ : db.DB() sqlDB.SetMaxOpenConns(200) sqlDB.SetMaxIdleConns(50) sqlDB.SetConnMaxLifetime(2 * time.Hour) return db }逻辑说明Sources对应主库Replicas对应从库。注册后GORM 的Create / Save / Delete会自动走 SourcesFind / First / Count会自动走 Replicas。Policy决定从库的选择策略RandomPolicy在两个从库时随机分配还可以用RoundRobinPolicy轮询。注意连接池参数是针对主库和从库共用的底层连接池不是分开的。参数说明parseTimeTrue必须加否则 DATETIME 字段会返回字符串charsetutf8mb4是为了支持表情符号。生产环境 DSN 里的账号密码一定用环境变量注入不要写死在配置文件里更不要提交进 git。这里踩过的最深的坑是dbresolver 只对注册后创建的会话生效如果某个模型查询用了db.Session(gorm.Session{NewDB: true})要确认是否还保留 resolver 配置。一般不要随便 NewDB容易把路由弄丢。3.3 Redis 缓存商品详情JSON 序列化和 10 分钟过期商品详情是商城读流量最大的一块我用 Redis 缓存整个 Product 结构体。用 JSON 序列化而非 Go 默认的 gob原因是 JSON 可读、跨语言而且在 Redis 可视化工具里看到的是明文排查问题方便。代码如下func (r *ProductRepo) GetDetail(ctx context.Context, id int) (*model.Product, error) { key : product:detail: strconv.Itoa(id) if data, err : r.rdb.Get(ctx, key).Bytes(); err nil { var p model.Product if json.Unmarshal(data, p) nil { return p, nil } } var p model.Product if err : r.db.WithContext(ctx).First(p, id).Error; err ! nil { return nil, err } cacheData, _ : json.Marshal(p) r.rdb.Set(ctx, key, cacheData, 10*time.Minute) return p, nil }逻辑说明先取缓存缓存里是 JSON 字节串反序列化成结构体没命中就查从库查到后回填过期时间 10 分钟。这个逻辑漏了缓存穿透防护——如果id根本不存在First返回ErrRecordNotFound我们不会写缓存下一次同样的请求还会打到数据库。防护方式是在查不到时写一个空值占位比如rdb.Set(ctx, key, []byte(), 1*time.Minute)但要注意不能把空字符串当合法值。参数说明过期时间 10 分钟是个平衡值。太短会导致 MySQL 读压力大太长会导致商品改价后用户还要最多 10 分钟才能看到新价格。如果想“改价立即生效”可以在后台改库成功后主动删除这个 key也就是后文下单时做的那样。这里还能看到 redis 数据类型的实际用法存字符串String而不是 Hash因为结构体整体序列化更简单如果要更新局部字段Hash 更合适但代码复杂度也上去。3.4 下单链路事务 行锁 删缓存下单是写主库的典型逻辑我一般这样写func (s *OrderService) Create(ctx context.Context, userID, productID, quantity int) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var p model.Product if err : tx.Clauses(clause.Locking{Strength: UPDATE}). First(p, productID).Error; err ! nil { return err } if p.Stock quantity { return errors.New(库存不足) } if err : tx.Model(model.Product{}).Where(id ?, productID). Update(stock, gorm.Expr(stock - ?, quantity)).Error; err ! nil { return err } order : model.Order{ UserID: userID, ProductID: productID, Quantity: quantity, } if err : tx.Create(order).Error; err ! nil { return err } _ s.rdb.Del(ctx, product:detail:strconv.Itoa(productID)) return nil }) }逻辑说明clause.Locking{Strength: UPDATE}会对该商品行加排他锁直到事务结束防止两个请求同时读到相同库存导致超卖。扣减库存使用gorm.Expr(stock - ?, quantity)把计算下推到 MySQL避免先查再改的并发风险。创建订单后删除商品详情缓存让下一次读重新回源。这里有一个取舍删除缓存放在事务内如果事务回滚缓存被提前删除但最多增加一次回源不会造成数据不一致如果放在事务后则要用 defer 或 AfterCommit 钩子保证“更新成功”与“删缓存”的顺序。参数说明Clauses会在 SQL 后面追加FOR UPDATE只对 InnoDB 有效。如果你的 MySQL 表是 MyISAM行锁不生效超卖风险还在。这也是“mysql 锁的分类”在实际项目中最直接的体现优先用 InnoDB 的行锁别依赖表锁。4. 读写分离商城上线的 5 个必踩坑主从延迟、缓存击穿、连接池与迁移坑永远比教程多。这一章我把最常见的、差点让我删库跑路的问题写成检查单每条都是现象先说再讲原因和解决。4.1 刚下的订单在列表里“消失了”主从延迟导致读不到主库数据现象用户下单成功跳转到“我的订单”页列表没有刚才那单刷新一次才出现。原因订单写入主库列表查询走从库主从 binlog 同步还没追上。延迟窗口在高并发下会放大也可能出现历史订单状态回退。解决对“新写入即读”的接口做一致性路由。常见做法是判断订单的创建时间如果最近几秒内就强制走主库。在 GORM 里可以用事务包裹或者在查询时显式指定使用主库连接另一个方案是用 Redis 存一个“新订单集合”只有命中的订单 ID 才走主库。我一般选时间判断逻辑简单且不用维护额外集合。4.2 Redis 热点 key 同时过期从库被瞬间打穿现象某个爆款商品详情页突然报错从库 CPU 100%日志里全是慢查询。原因缓存 key 过期后大量并发请求同时发现 miss同时回源到从库击穿缓存。解决给重建缓存加互斥锁。用 Redis 的SETNX作为锁拿到锁的人才允许查库回填拿不到锁的人短暂 sleep 后重试。注意锁必须设过期时间避免进程崩了导致死锁。释放锁时要先比较 value 再删除防止误删别人的锁这个可以写进 Lua 脚本在第 6 章给脚本。4.3 GORM AutoMigrate 跑到从库上从库报 read-only现象启动服务时调用AutoMigrate从库日志报The MySQL server is running with the --read-only option随后主从复制中断。原因从库配置了--read-only。AutoMigrate 执行 DDL如果连接被 dbresolver 路由到从库就会触发只读错误如果连接指向主库一般没事。问题往往出在连接配置顺序先使用仅包含从库信息的 DSN 打开了 GORM再注册主库导致默认连接是读连接。解决AutoMigrate 之前显式用主库连接执行迁移或者单独写一个cmd/migrate工具用主库 DSN 跑迁移不要每次启动都跑。生产环境建议用golang-migrate这类迁移工具管理结构变更不用 ORM 的 AutoMigrate。4.4 Redis 连接工具报 command timed out先看是不是有人在跑 KEYS现象本地连接 Redis 正常到了测试环境就报redis: command timed out或ERR Client sent AUTH, but no password is set。原因Redis 配置变更后需要重启很多 Windows 上安装的 Redis 默认不注册成服务进程可能会占用 6379或者测试环境配置了 requirepass但代码里 Password 没填。command timed out则说明连接已经建立但 Redis 阻塞比如有人执行了KEYS *或者慢命令占用了线程。解决先redis-cli ping能通再检查代码配置命令行INFO commandstats看慢命令。禁止在生产执行KEYS *用SCAN代替。这是 Redis 连接工具和客户端最常见的“假网络”问题。4.5 MySQL 连接池耗尽too many connections先看慢查询现象压测刚开始没问题20 秒后大量请求超时Go 协程数狂涨MySQL 日志显示Too many connections。原因GORM 默认连接池很小或者SetMaxOpenConns设置过大但 MySQL 的max_connections没调。慢查询越多连接被占得越久等排队连接超时。解决先用SHOW PROCESSLIST看是哪些 SQL 长期不释放把慢查询条件列的索引补上。同时把SetConnMaxLifetime设为 1~2 小时避免 MySQL 服务端主动断开后Go client 还在用旧连接。连接池不是越大越好超过 MySQL 最大连接数之后只会互相等待。一个相对保守的参数max_open_conns100max_idle_conns30压测时再调。5. 压测与验证怎么证明读写分离和缓存真的在生效代码写完不是结束要能证明读写分离真的把流量分出去了。我有三个验证手段从逻辑到压测。5.1 打开 GORM 日志确认 SELECT 落到 3307在 GORM 配置里把日志级别调成 Info就能在控制台看到每条 SQL 的前缀。如果你用的是mysql-master:3306那读请求仍然在主库。这时可以暂时把从库的端口映射改成 3308如果请求没有失败说明根本没有连从库。db, err : gorm.Open(mysql.Open(mainDsn), gorm.Config{ Logger: logger.Default.LogMode(logger.Info), })参数说明logger.Info会打印所有 SQL包括参数值生产环境建议logger.Warn只看慢查询。观察时执行docker exec -it mysql-slave mysql -uroot -proot -e SHOW PROCESSLIST如果看到来自应用 IP 的 SELECT说明从库确实在承担读请求。5.2 用 wrk 压测对比三种配置下的 QPS压测前先准备一个只读接口比如商品详情。数据量最好能覆盖大部分 Redis 缓存让 miss 比例比较真实。然后分三组跑wrk -t4 -c200 -d30s --latency http://127.0.0.1:8080/api/product/1 wrk -t4 -c200 -d30s --latency http://127.0.0.1:8080/api/product/2参数说明-t4是 4 个线程-c200保持 200 个连接-d30s压 30 秒。第一组关闭 Redis只走从库第二组打开 Redis 缓存第三组把从库去掉只连主库。对比三个结果中最重要的是打开缓存后吞吐提升明显说明 Redis 拦截生效读写分离相对单库的提升体现在同样 miss 条件下更大的并发能力而不是脚本里看到的数字。别只盯着绝对值同一台机器上相对值才有意义。5.3 用 Redis 的 INFO 看命中率给缓存治理提供依据缓存命中率不是越高越好太高说明决策层把太多数据塞进内存太低说明 key 设计有问题。用下面命令看每秒命中次数redis-cli info stats其中keyspace_hits和keyspace_misses是累计值做差再除以时间段内的总请求数就是该时间窗口的命中率。商城商品详情的命中率通常在 85% 以上如果低于 70%先看热点商品有没有被更新频繁删缓存再看是否没有设置过期时间造成 Redis 内存无限增长。注意这里说的是最基础的验证手段不需要一开始就上 Prometheus 和 Grafana。把日志、wrk、INFO 三条路跑通读写分离有没有生效心里就有底了。6. 从能跑到能上线Redis 分布式锁、缓存治理和商城演进最后聊一个能提升商品质量的具体技巧用 Redis 分布式锁保护秒杀库存以及释放锁的原子性。6.1 用 Redis 分布式锁保护秒杀库存-- compare and delete if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段 Lua 脚本在 Go 里通过redis.NewScript加载释放锁时先比较持有者标记再删除避免线程 A 超时后线程 B 获得锁A 的释放操作把 B 的锁删掉。获取锁时用SETNX key value EX 5value 用 UUID锁的过期时间要根据业务预留我自己习惯设 3 到 5 秒并且用单独的 goroutine 做续期防止业务没执行完锁先过期。如果你做秒杀更推荐直接把扣库存的更新语句放在事务里分布式锁只用来挡住流量峰值不是唯一防线。6.2 从单体商城到 go-zero 的演进路线这个商城项目的演进路线也很清晰单机 Gin GORM 可以跑但等用户量起来后要么换 go-zero 按 service 拆分用户、商品、订单要么继续用 Gin 但引入 gRPC 网关和消息队列。读写分离底座不会变Redis 缓存治理、主从延迟处理都还是那套方法论。如果你还没上手建议从 Docker 主从开始把数据库搭起来再跑通第一节的 main.go看到日志里 SELECT 出现在 3307 那一刻你就算真正入门了。这个方向值得投入因为商城是再经典不过的读多写少系统把读写分离搞明白换到任何业务都能复用。按我的习惯先跑通再谈优化不然你连报错都分不清是缓存还是主从的问题。希望帮到你。本文还有配套的精品资源点击获取