免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

用Gin+Gorm+Redis实现拉手网全栈项目:从架构到部署实战

用Gin+Gorm+Redis实现拉手网全栈项目:从架构到部署实战 1. 项目拆解为什么拿“拉手网”当练手项目1.1 拉手网到底是什么练它能学到什么拉手网这个名字老互联网人都知道早期团购模式的代表产品。它的核心业务模式并不复杂平台整合本地商家的优惠商品或服务餐饮套餐、电影票、美容体验等以明显低于门市价的价格卖给用户用户线上付款后到店消费商家凭核销凭证与平台结算。一句话概括这是一个典型的多角色、多状态、强交易的本地生活服务平台。用今天的话说它就是一个垂直领域的电商系统。之所以拿它当新手实战项目是因为它的业务链路覆盖了绝大多数后端开发的核心场景用户体系注册、登录、Token鉴权商品体系SPU/SKU、分类、上下架、库存订单体系下单、支付回调、订单状态流转核销体系券码生成、消费验证、过期处理内容展示首页、分类页、详情页、搜索这些模块加在一起恰好构成一个完整的前后端分离项目该有的复杂度。做一遍这个项目等于把CRUD、缓存、事务、并发控制、接口设计这些基本功全部过了一遍而且有明确的业务场景不会像做“某某管理系统”那样做完就忘。1.2 技术选型的思路为什么是 Gin Gorm Go-Redis很多新手问为什么不用Java Spring Boot或者Python Django我的看法很简单选型不是越“重”越好而是越“适合当前阶段”越好。Gin框架Go语言目前最主流的Web框架路由、中间件、参数绑定都封装得很好代码量少性能强。对新手来说源码量小调试方便遇到问题可以直接翻框架源码这是Spring Boot那种庞然大物给不了的“安全感”。GormGo生态里最常用的ORM库支持自动建表、关联查询、Hook回调能把新手从繁琐的SQL拼接里解放出来同时又不至于完全屏蔽SQL底层执行日志打开后你能清楚看到每条SQL长什么样这对理解数据库操作非常有帮助。Go-RedisGo语言操作Redis的官方推荐库API设计清晰支持连接池、分布式锁、消息队列等进阶玩法。在拉手网项目里Redis是个“魂”一样的存在后面你会看到它怎么扛住高并发查询和防超卖。前端选Vue 3 Vite Element Plus也是同样的逻辑。Vue的响应式数据流对新手友好Element Plus直接把后台管理界面的组件全给了你不需要花时间在UI上可以把精力聚焦在业务逻辑上。提示我不建议新手一上来就搞微服务、消息队列、分库分表。这些是“进阶后的痛”不是“入门时的菜”。拉手网项目用单体架构Redis缓存足以覆盖几万日活的业务量也能让你把核心链路吃透。1.3 核心业务模块与数据模型设计动手写代码之前先把业务模块和数据表设计清楚这比写代码本身更重要。我做这个项目时数据表经过三轮调整后才稳定下来这里直接给你一份可用的核心表结构清单用户模块usersid, mobile手机号登录, nickname, password_hash, avatar, status, created_at手机号密码是最简单的登录方式不引入短信验证码也能跑通流程后续要接验证码加一个字段就行商家与商品模块merchantsid, name, logo, address, phone, statuscategoriesid, name, parent_id, sortproductsid, merchant_id, category_id, title, subtitle, cover, detail_images, original_price, group_price, stock, sales, status, start_time, end_time订单与核销模块ordersid, order_no, user_id, product_id, quantity, total_amount, pay_amount, status, paid_at, created_atcouponsid, order_id, code, status, expire_at, consumed_at一单一码到店核销为什么就这几张表新手最容易犯的错是一上来就把表设计得特别细比如优惠券搞一堆类型、订单搞一堆快照字段、商品搞SKU维度。其实拉手网早期的团购模式一个商品就是一个SKU不存在颜色尺码这种维度所以商品表可以直接把价格库存放在一起。把核心链路跑通以后你自然知道哪些字段是冗余的到时候再加不迟。2. 初始化项目与环境搭建构建骨架的正确姿势2.1 后端工程结构布局我用Go Modules管理依赖推荐你按下面的目录结构组织代码它是我反复调整后觉得适合新手的“扁平但职责清晰”的布局la-shou-server/ ├── main.go // 入口加载配置、初始化DB/Redis、注册路由 ├── config/ │ ├── config.go // 配置结构体定义 │ └── config.yaml // 环境变量配置 ├── models/ // Gorm模型定义 │ ├── user.go │ ├── product.go │ ├── order.go │ └── coupon.go ├── dao/ // 数据访问层封装DB和Redis操作 │ ├── user_dao.go │ ├── product_dao.go │ └── order_dao.go ├── services/ // 业务逻辑层下单流程、支付回调等 │ ├── order_service.go │ └── product_service.go ├── handlers/ // HTTP处理层参数校验、调用Service、返回JSON │ ├── user_handler.go │ ├── product_handler.go │ └── order_handler.go ├── middleware/ // 中间件鉴权、日志、跨域、异常恢复 │ ├── auth.go │ ├── cors.go │ └── logger.go ├── pkg/ // 工具包 │ ├── response.go // 统一响应格式 │ ├── jwt.go │ └── utils.go └── router/ └── router.go // 路由注册统一管理这个结构的分层逻辑很直白handler只负责“接客”和“传话”service只负责“干正事”业务规则、事务边界dao只负责“存取数据”。新手最容易踩的坑是把所有代码堆在handler里看起来很快但一旦加需求就全乱了。2.2 数据库初始化与Gorm迁移数据库我建议直接用MySQL 8.0Docker一键拉起最省事docker run -d --name la-shou-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDroot123 \ -e MYSQL_DATABASElashou \ mysql:8.0连接数据库后用Gorm的自动迁移功能建表。注意自动迁移适合开发环境生产环境一定要用版本化迁移工具但新手阶段自动迁移足够package main import ( gorm.io/driver/mysql gorm.io/gorm lashou/models ) func InitDB() *gorm.DB { dsn : root:root123tcp(127.0.0.1:3306)/lashou?charsetutf8mb4parseTimeTruelocLocal db, err : gorm.Open(mysql.Open(dsn), gorm.Config{ Logger: logger.Default.LogMode(logger.Info), // 打印SQL日志新手一定要开 }) if err ! nil { panic(err) } // 自动迁移开发期神器 db.AutoMigrate(models.User{}, models.Merchant{}, models.Category{}, models.Product{}, models.Order{}, models.Coupon{}) return db }有一个小细节MySQL 8.0默认字符集是utf8mb4charsetutf8mb4必须写对否则中文乱码没跑。parseTimeTrue一定要加不加Gorm扫描DATETIME字段到Go的time.Time类型会报错。2.3 统一响应格式与接口规范前后端分离项目最怕接口返回格式不统一前端解析要写一堆分支判断。我在项目里一开始就定了统一的响应结构{ code: 0, message: success, data: {} }package pkg type Response struct { Code int json:code Message string json:message Data interface{} json:data } func Success(c *gin.Context, data interface{}) { c.JSON(http.StatusOK, Response{Code: 0, Message: success, Data: data}) } func Fail(c *gin.Context, code int, message string) { c.JSON(http.StatusOK, Response{Code: code, Message: message, Data: nil}) } func FailWithStatus(c *gin.Context, httpStatus int, code int, message string) { c.JSON(httpStatus, Response{Code: code, Message: message, Data: nil}) }业务错误码我从1000开始递增1001参数错误、1002未登录、1003无权限、2001商品不存在、2002库存不足、3001订单状态异常。错误码字典一定要在项目初期就维护好不然联调时你会被前端同事追着骂。3. 商品模块实战从接口设计到Redis缓存3.1 商品列表与详情接口拉手网首页展示的就是团购商品列表核心要求是响应快、数据准。商品列表接口我设计成支持分类筛选、分页、排序默认按销量倒序func (h *ProductHandler) List(c *gin.Context) { var req ListProductRequest if err : c.ShouldBindQuery(req); err ! nil { pkg.Fail(c, 1001, 参数错误) return } products, total, err : h.svc.List(c.Request.Context(), req.CategoryID, req.Page, req.PageSize) if err ! nil { pkg.Fail(c, 5000, 服务器开小差了) return } pkg.Success(c, gin.H{ list: products, total: total, page: req.Page, }) }Service层就不贴代码了核心就是一个带条件的分页查询。需要注意的点是列表接口返回的字段要做裁剪detail_images这种大字段在列表里不要返回只返回cover减少传输体积sales销量字段用int类型没问题但要注意它和真实订单量的同步策略后面会说排序字段用白名单方式校验防止前端传任意字段导致SQL注入或异常3.2 Redis缓存策略缓存穿透、击穿、雪崩商品详情页是拉手网流量最大的页面之一一个商品可能被几千人同时点击。如果把每个请求都打到MySQL上数据库根本扛不住。这里我用Redis做了三层缓存策略第一层商品详情缓存func (s *ProductService) GetDetail(ctx context.Context, id int64) (*models.Product, error) { cacheKey : fmt.Sprintf(product:detail:%d, id) // 先查缓存 var product models.Product val, err : s.redis.Get(ctx, cacheKey).Bytes() if err nil { json.Unmarshal(val, product) return product, nil } // 缓存未命中查数据库 if err : s.db.First(product, id).Error; err ! nil { return nil, err } // 序列化写入缓存设置随机过期时间防雪崩 data, _ : json.Marshal(product) expire : 10*time.Minute time.Duration(rand.Intn(60))*time.Second s.redis.Set(ctx, cacheKey, data, expire) return product, nil }商品价格、标题、详情这种非实时数据缓存10分钟到半小时没问题。加随机过期时间是为了防止所有key同时失效导致数据库被瞬时打满缓存雪崩。第二层热点商品主动更新如果某个商品秒杀活动开始瞬时流量巨大缓存刚一过期几千个请求同时打到数据库就是缓存击穿。我的方案是用singleflight合并并发请求import golang.org/x/sync/singleflight var sf singleflight.Group func (s *ProductService) GetDetailSafe(ctx context.Context, id int64) (*models.Product, error) { v, err, _ : sf.Do(fmt.Sprintf(product:%d, id), func() (interface{}, error) { return s.GetDetail(ctx, id) }) if err ! nil { return nil, err } return v.(*models.Product), nil }singleflight的原理很简单同一时刻同一个key的请求只有第一个真正去查数据库其他的都“等待”并复用第一个的结果。这是解决缓存击穿成本最低、效果最明显的方案比分布式锁轻量得多。第三层空值缓存为了防止“查询一个不存在的商品ID”这种恶意请求直接打到数据库缓存穿透我会对“查不到”的结果也做缓存if errors.Is(err, gorm.ErrRecordNotFound) { s.redis.Set(ctx, cacheKey, []byte(), 2*time.Minute) return nil, ErrProductNotFound }这个2分钟的空值缓存能挡掉大量无效请求不过要注意“空值key”的业务含义要区分缓存里拿到空字符串意味着“商品不存在”而不是“查询失败”。3.3 秒杀场景下的库存防超卖拉手网的爆款商品经常做限时抢购这就涉及整个项目中最容易出现线上事故的地方并发下单导致超卖。假如库存只有10件100个人同时下单如果代码是“先查库存再扣库存再生成订单”那妥妥超卖因为中间有时间差。正确做法之一是利用数据库行锁// 用 UPDATE ... WHERE stock 0 原子扣减防止超卖 func (s *ProductService) DeductStock(ctx context.Context, productID int64, num int) error { res : s.db.Model(models.Product{}). Where(id ? AND stock ?, productID, num). UpdateColumn(stock, gorm.Expr(stock - ?, num)) if res.RowsAffected 0 { return ErrInsufficientStock } return res.Error }这个方案非常朴素性能比“乐观锁CAS”差一些但正确性最高。对新手项目来说数据库单行更新在MySQL的行级锁机制下是串行执行的只要这条SQL执行成功就绝对不会超卖。高并发下每秒能抗几百到上千订单对练手项目完全够用。更进一步用Redis做预热库存Lua脚本扣减可以大幅提升秒杀接口吞吐量。我实战时用了一段简单的Luaif redis.call(get, KEYS[1]) tonumber(ARGV[1]) - 1 then return -1 end redis.call(decrby, KEYS[1], ARGV[1]) return 1通过go-redis的Eval方法调用把“判断库存扣减库存”作为一个原子操作扣减成功后异步发MQ消息让后台服务去生成数据库订单。这个方案的QPS能做到Redis的极限但复杂度也上来了新手可以放到第二阶段再优化。心得我在做这个项目时先实现了数据库原子扣减版跑通后再替换成Redis预热版。如果你时间有限只做数据库原子扣减版也完全没问题在业务代码里加个“查询库存不足则提示”就足够体验核心流程了。不要为了炫技把第一版搞太复杂先稳住正确性。4. 用户与订单模块登录鉴权和核心交易链路4.1 JWT登录鉴权完整流程用户模块我用手机号密码登录密码不是明文存储的用bcrypt加密比MD5安全太多自带盐import golang.org/x/crypto/bcrypt // 注册密码加密后入库 hash, _ : bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) user : models.User{Mobile: req.Mobile, PasswordHash: string(hash), Nickname: req.Nickname}// 登录比对密码签发JWT func (s *UserService) Login(ctx context.Context, mobile, password string) (string, error) { var user models.User if err : s.db.Where(mobile ?, mobile).First(user).Error; err ! nil { return , ErrUserNotFound } if err : bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err ! nil { return , ErrPasswordWrong } // 签发JWT有效期24小时 token, err : pkg.GenerateJWT(user.ID, user.Nickname) return token, err }JWT的GenerateJWT我用标准库加一个SecretKey实现核心就是把用户ID、过期时间、签名算法打包成三段字符串。鉴权中间件在每个需要登录的接口前解析Token把用户ID塞进gin.Contextfunc AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { token : c.GetHeader(Authorization) if token || !strings.HasPrefix(token, Bearer ) { pkg.FailWithStatus(c, http.StatusUnauthorized, 1002, 未登录) c.Abort() return } claims, err : pkg.ParseJWT(strings.TrimPrefix(token, Bearer )) if err ! nil { pkg.FailWithStatus(c, http.StatusUnauthorized, 1002, 登录已过期) c.Abort() return } c.Set(user_id, claims.UserID) c.Next() } }这里有个坑大家可能都踩过JWT在服务端是“无状态”的一旦签发在有效期内没法主动让它失效。所以拉手网项目里如果要做“强制下线”功能比如用户改密码就得在黑名单方案上做文章。简单做法是用Redis维护一个jwt:blacklist:{jti}过期时间等于Token剩余有效期这算个不错的练手进阶点。4.2 下单接口事务、锁、状态机下单是整个项目里业务规则最密集的环节。我把下单流程拆成这几步参数校验商品是否存在、是否在抢购时间窗内、购买数量是否合法原子扣减库存前面提到的UPDATE ... WHERE stock num创建订单状态待支付生成核销券码一单一码清理相关缓存这里每一步都不能出岔子所以整个流程必须放在数据库事务里func (s *OrderService) CreateOrder(ctx context.Context, userID int64, productID int64, quantity int) (*models.Order, error) { var order models.Order err : s.db.Transaction(func(tx *gorm.DB) error { // 1. 查商品需要带行锁 var product models.Product if err : tx.Clauses(clause.Locking{Strength: UPDATE}).First(product, productID).Error; err ! nil { return ErrProductNotFound } // 2. 校验购买时间和库存 if product.StartTime.After(time.Now()) || product.EndTime.Before(time.Now()) { return ErrNotInSaleTime } // 3. 原子扣库存 res : tx.Model(models.Product{}). Where(id ? AND stock ?, productID, quantity). UpdateColumn(stock, gorm.Expr(stock - ?, quantity)) if res.RowsAffected 0 { return ErrInsufficientStock } // 4. 创建订单 order models.Order{ OrderNo: generateOrderNo(), UserID: userID, ProductID: productID, Quantity: quantity, TotalAmount: product.GroupPrice * int64(quantity), PayAmount: product.GroupPrice * int64(quantity), Status: models.OrderStatusUnpaid, } if err : tx.Create(order).Error; err ! nil { return err } // 5. 生成核销券码 coupon : models.Coupon{ OrderID: order.ID, Code: generateCouponCode(), Status: models.CouponStatusUnused, } if err : tx.Create(coupon).Error; err ! nil { return err } return nil }) if err ! nil { return nil, err } // 事务成功后删除商品详情缓存 s.redis.Del(ctx, fmt.Sprintf(product:detail:%d, productID)) return order, nil }下单流程走完后前端跳到“收银台”模拟支付。真实场景对接微信支付/支付宝需要商户号、证书、回调验签对新手来说链路繁琐。我的做法是提供一个内部“模拟支付接口”把订单状态从“待支付”改成“已支付”从业者的角度上完全够用。4.3 订单状态机什么时候能取消、什么时候能退款订单状态管理是新手最容易写乱的地方。我一开始用一堆if else判断状态流转后来重构成了状态机模式清晰多了。核心订单状态有这些订单状态unpaid待支付创建订单后进入paid已支付模拟支付回调后进入used已核销用户在商家出示券码商家核销后进入cancelled已取消用户支付前可取消refunded已退款支付后规定时间内可申请状态流转规则unpaid→cancelled用户取消unpaid→paid支付成功paid→refunded用户申请退款paid→used商家核销其他状态转移一律非法var allowedTransitions map[OrderStatus]map[OrderStatus]bool{ OrderStatusUnpaid: { OrderStatusCancelled: true, OrderStatusPaid: true, }, OrderStatusPaid: { OrderStatusRefunded: true, OrderStatusUsed: true, }, } func CanTransition(from, to OrderStatus) bool { if targets, ok : allowedTransitions[from]; ok { return targets[to] } return false }订单状态的每一次变更建议都记录一条流水表order_logs字段包括订单ID、操作前状态、操作后状态、操作人、操作时间、备注。这既是排查问题的第一手资料也是将来做数据分析的基础。新手经常会忽略流水表等到线上订单状态对不上时再来查就晚了。5. 前端页面开发与前后端联调把后端能力“可视化”5.1 Vue 3 Vite项目初始化前端我用Vue 3 Vite Vue Router Pinia Element Plus。创建工程用官方的create-vue脚手架几秒钟拉起来npm create vuelatest la-shou-web cd la-shou-web npm install npm install element-plus axios pinia项目目录结构按业务模块划分views/home首页、views/product商品详情、views/order订单列表/确认、views/user个人中心/登录注册、views/admin商家后台导航。路由用懒加载() import(...)配置这样首屏包体小加载更快const routes [ { path: /, name: home, component: () import(/views/home/index.vue) }, { path: /product/:id, name: product-detail, component: () import(/views/product/detail.vue) }, { path: /login, name: login, component: () import(/views/user/login.vue) }, ]5.2 Axios封装与Token携带前端对接后端接口最核心的一件事就是把axios封装好基础URL指向后端服务开发环境用/api前缀 Vite代理转发避免跨域问题请求拦截器里从localStorage取出JWT塞进Authorization: Bearer xxx响应拦截器统一处理错误码code 401时跳转登录页code 2001这类业务错误直接ElMessage提示// src/utils/request.ts import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000, }) request.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( (response) { const res response.data if (res.code ! 0) { if (res.code 1002) { router.push(/login) } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, (error) { ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } ) export default request5.3 跨域问题别再被本地联调卡住前端跑在http://localhost:5173后端跑在http://localhost:8080两个端口不同浏览器的同源策略会拦下所有请求。解决方案用Vite的代理配置最干净// vite.config.ts export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }, })这样前端的/api/v1/products请求会被Vite服务转发到后端的http://localhost:8080/api/v1/products浏览器里看不到跨域问题后端也不用配CORS。如果后端也要支持外部调用那就再加CORS中间件设置Access-Control-Allow-Origin、Allow-Headers、Allow-Methods这几个响应头让浏览器放行。注意Vite代理只在开发环境生效生产环境的“代理”由Nginx反向代理完成。部署时前后端最好挂在同一个域名下https://example.com/给前端静态文件https://example.com/api/转给后端服务这样天然没有跨域问题。6. 部署上线与性能优化从能跑到能扛6.1 用Docker打包后端服务拉手网项目开发完成后用Docker部署是最省心的方案。写一个后端DockerfileFROM golang:1.21-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -o lashou-server main.go FROM alpine:latest WORKDIR /root/ COPY --frombuilder /app/lashou-server . COPY config/config.yaml ./config/ EXPOSE 8080 CMD [./lashou-server]多阶段构建的好处是最终镜像只包含编译后的二进制和配置文件体积小、启动快、漏洞面小。构建命令docker build -t lashou-server:latest .前端构建成静态文件后直接交给Nginx托管。生产环境用docker-compose把MySQL、Redis、后端、前端Nginx编排在一起一套命令全部拉起version: 3.8 services: mysql: image: mysql:8.0 restart: always environment: - MYSQL_ROOT_PASSWORDroot123 - MYSQL_DATABASElashou volumes: - ./mysql-data:/var/lib/mysql ports: - 3306:3306 redis: image: redis:7-alpine restart: always ports: - 6379:6379 server: image: lashou-server:latest restart: always depends_on: - mysql - redis ports: - 8080:8080 web: image: nginx:alpine restart: always ports: - 80:80 volumes: - ./web-dist:/usr/share/nginx/html - ./nginx.conf:/etc/nginx/conf.d/default.conf depends_on: - server6.2 接口性能压测用wrk看真实吞吐量部署完成之后别急着发朋友圈先跑一轮压测看看接口的真实承载力。我用wrk压过商品列表接口wrk -t4 -c100 -d30s http://localhost:8080/api/v1/products?page1page_size10在加了Redis缓存的前提下单机Gin的商品列表接口QPS可以轻松到几千上万而直查MySQL可能只有几百。压测数据出来后你能直观感受到缓存的价值这种“看见性能差距”的过程比背十篇八股文都管用。压测时重点关注两个指标QPS每秒钟能处理多少请求P99延迟99%的请求在多少毫秒内返回如果P99超过200ms就要考虑是不是有慢查询、缓存失效风暴或者Nginx配置问题。新手阶段不用追求极致性能稳定、可控、可观测比盲目调参更重要。6.3 日志与监控出问题能定位项目必须要有日志不然后期维护寸步难行。我的做法是Gin框架接入统一的访问日志中间件记录请求方法、路径、状态码、耗时关键业务下单、支付回调、核销打Info日志带上订单号错误和异常打Error日志带调用栈本地开发时日志打到控制台就够了服务器上建议按天切割落盘。进阶视角里可以接入Prometheus采集Go运行指标、Redis命中率、请求耗时直方图但对当前项目来说先把“有问题时日志能对得上号”做到位。7. 踩坑与经验总结新手做项目最常见的坎7.1 时间字段时区问题我遇到过最隐蔽的Bug用户下单后订单的created_at比当前时间快了8小时。原因是MySQL驱动默认使用locLocal而容器内的系统时区是UTC。解决方式是在连接字符串里明确指定locLocal同时数据库JDBC连接串也统一dsn : root:root123tcp(127.0.0.1:3306)/lashou?charsetutf8mb4parseTimeTruelocLocal时间字段统一用time.Time类型存数据库后是datetime读出时Gorm自动转成time.Time。前后端交互时统一用时间戳毫秒或ISO 8601字符串避免“YYYY-MM-DD HH:mm:ss”在不同时区被解析错乱。7.2 库存扣减与订单创建的原子性刚开始我把“扣库存”和“创建订单”拆成了两次独立的数据库操作结果并发测试时发现库存扣了但订单没生成或者一个商品卖超了。后来统一放进Gorm的Transaction回调里并在同一个事务里完成所有写操作这个问题就消失了。事务的边界要圈住所有相关写操作不能只包住一半。还有一个细节事务里查询商品信息和扣减库存都用了clause.Locking{Strength: UPDATE}SELECT ... FOR UPDATE这会让同一商品的并发下单串行化。上述这个方案虽然牺牲了一点并发度但保证正确性特别适合库存量小、单品热度高的场景。等你要做高并发秒杀时再换成Redis预热Lua。7.3 前端对接时最常见的“404”和“CORS”问题排查过无数次了404先查路由是否注册了、请求路径是否写对了、Nginx代理是否配置了CORS报错先看后端是否配置了跨域头开发环境用Vite代理就不用配接口返回500先看后端日志别只看浏览器控制台提交表单时Content-Type是application/json还是application/x-www-form-urlencodedGin对应的ShouldBindJSON和ShouldBind处理方式完全不同调试小技巧后端日志打开Gorm的SQL日志logger.Info级别前端Network面板看请求和响应的完整报文两边一对应绝大多数联调问题十分钟内能定位。7.4 功能做完了项目还没“完”练手项目做完核心链路只是第一步。我给新手的完整清单是这样的第一轮把登录、商品展示、下单、支付回调、商家核销全流程跑通第二轮给服务端加单元测试至少覆盖订单状态机、库存扣减这两个核心方法第三轮补充Redis缓存、接口节流、日志优化第四轮把代码推到GitHub写一份清晰的README包含项目介绍、技术栈、部署步骤、演示截图按照这个顺序推进等你把第四轮做完这份项目经验就已经能写进简历、能作为面试的谈资了。最后分享一点个人体会做完拉手网这个项目你会发现新手学后端最大的瓶颈不是语法和框架而是建模能力和边界意识——哪些属于事务、哪些属于缓存、哪些属于功耗可控的最终一致性这些判断力只能靠完整项目的“脏活累活”喂出来。做成一个像拉手网这样的全栈项目你踩过的每一个坑都会成为后面谈薪资和面试时的底气。
返回列表