15--查漏补缺
Consumer事务修补
internal/mq/consumer.go 里的 CreateOrder 手动调用了 tx.Begin()
如果在 Update 或 Create 时发生了 panic,程序直接崩溃或被 recovery 捕获,tx.Rollback()
将永远不会被执行,导致数据库连接池中的连接被死锁或泄露。
我们把CreateOrder()改为这样
// CreateOrder 消费者调用,创建订单(GORM闭包事务,自动处理 panic recover)
func CreateOrder(msg *OrderMessage) error {
// 开启事务,保证库存扣减和订单创建原子性
err := DB.Transaction(func(tx *gorm.DB) error {
// 扣减商品真实库存(乐观锁,防止超卖)
// 条件: product_id匹配 且 stock > 0
result := tx.Model(&model.Product{}).
Where("product_id = ? AND stock > 0", msg.ProductId).
Update("stock", gorm.Expr("stock - 1"))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("stock not enough")
}
// 创建订单
order := &model.Order{
UserId: msg.UserId,
ProductId: msg.ProductId,
SeckillId: msg.SeckillId,
Amount: msg.Amount,
Status: 0, // 待支付
}
return tx.Create(order).Error
})
return err
}
使用GORM自带闭包逻辑优势:
tx.Begin()→DB.Transaction(func(tx) error):GORM 自动管理 commit/rollback- 移除了所有手动
tx.Rollback()调用 return error即回滚,return nil即提交- 闭包内 panic 会被 GORM 内部 recover 捕获并自动回滚
MQ 消费者的无限重试死循环危险
internal/mq/consumer.go中,如果CreateOrder失败,msg.Nack(false, true)会将消息重新入队。- 缺陷:如果是因为数据库宕机或 SQL 语法错误导致的失败,这条消息会被无限次重新消费,瞬间拉满CPU 并阻塞其他消息。
- 为此我们可以为
CreateOrder添加重试次数上限,保证不会无限次入队
首先给internal/mq/message.go中的消息结构体添加重试次数
// OrderMessage 定义发送到RabbitMQ的订单消息结构
type OrderMessage struct {
UserId uint `json:"user_id"` // 用户ID
SeckillId uint `json:"seckill_id"` // 秒杀活动ID
ProductId uint `json:"product_id"` // 商品ID
Amount float64 `json:"amount"` // 订单金额
RetryConut int `json:"retry_count"` // (新) 重试次数
}
回到 internal/mq/consumer.go 修改重试逻辑
// StartConsumer 启动消费者
func StartConsumer() {
// 订阅队列
msgs, err := Channel.Consume(
OrderQueue, // queue
"", // consumer
false, // auto-ack
false, // exclusive
false, // no-local
false, // no-wait
nil, // args
)
if err != nil {
log.Fatalf("Failed to register a consumer: %v", err)
}
const MaxRetryCount = 3 // 最大重试次数
// 启动协程处理消息
go func() {
for msg := range msgs { // 处理消息
var orderMsg OrderMessage
// 将消息反序列化为订单消息
if err := json.Unmarshal(msg.Body, &orderMsg); err != nil {
log.Printf("Failed to unmarshal message: %v", err)
msg.Nack(false, false) // 丢弃这条消息
continue
}
// 调用服务层创建订单
if err := CreateOrder(&orderMsg); err != nil {
log.Printf("Failed to create order: %v", err)
// 超过最大重试次数,丢弃消息并告警
if orderMsg.RetryConut >= MaxRetryCount {
log.Printf("FATAL: order message exceeded max retry, discarding: %+v", orderMsg)
msg.Nack(false, false) // 丢弃这条消息,不重试
continue
}
orderMsg.RetryConut++ // 重试次数加1
msg.Nack(false, true) // 重新入队
continue
}
msg.Ack(false) // 确认消息处理成功
log.Printf("Order created: user=%d, seckill=%d", orderMsg.UserId, orderMsg.SeckillId)
}
}()
}
这里我们并没有选择将重试次数更新到消息队列的消息结构体当中,只在日志里记录 RetryCount ,因为 RetryCount 对于我们实际的业务逻辑是冗余信息,完全可以剔除
列表分页
- 查看 internal/repository/product.go 和 order.go 可以发现,
GetProductList()和
GetOrderByUserID()都是直接使用DB.Find(&products)或DB.Where(...).Find(&orders)。 - 致命缺陷:这在实际工程中是绝对不允许的。如果商品表里有 10 万条数据,或者一个用户的历史订单有几千条,直接全表扫描并加载到内存中会瞬间引发
OOM(内存溢出)和数据库卡顿。
1. 分页DTO
internal/api/handlers/dto/common.go
package dto
// PageQuery 通用分页请求参数
type PageQuery struct {
Page int `form:"page" binding:"required,min=1"` // 页码,从1开始
Size int `form:"size" binding:"required,min=1,max=100"` // 每页条数,最大100
}
// PageResult 通用分页响应结构
type PageResult struct {
Data interface{} `json:"data"` // 当前页数据
Total int64 `json:"total"` // 总记录数
Page int `json:"page"` // 当前页
Size int `json:"size"` // 每页条数
TotalPage int `json:"total_page"` // 总页数
}
2. Repository
internal/repository/product.go
// GetProductListPaginated 分页获取商品列表
func GetProductListPaginated(page, size int) ([]*model.Product, int64, error) {
var products []*model.Product
var total int64
// 查询总数
DB.Model(&model.Product{}).Count(&total)
// 查询当前页数据
offset := (page - 1) * size
err := DB.Offset(offset).Limit(size).Find(&products).Error
return products, total, err
}
internal/repository/order.go
// GetOrderListPaginated 分页获取用户订单列表
func GetOrderListPaginated(userId uint, page, size int) ([]*model.Order, int64, error) {
var orders []*model.Order
var total int64
query := DB.Model(&model.Order{}).Where("user_id = ?", userId)
query.Count(&total)
offset := (page - 1) * size
err := query.Offset(offset).Limit(size).Order("created_at DESC").Find(&orders).Error
return orders, total, err
}
func GetOrderListByUserIDPaginated(userId uint, page, size int) ([]*model.Order, int64, error) {
var orders []*model.Order
var total int64
query := DB.Model(&model.Order{}).Where("user_id = ?", userId)
query.Count(&total)
offset := (page - 1) * size
err := query.Offset(offset).Limit(size).Order("created_at DESC").Find(&orders).Error
return orders, total, err
}
3. Service
internal/service/product.go
// GetProductListPaginated 分页获取商品列表
func GetProductListPaginated(page, size int) ([]*model.Product, int64, error) {
return repository.GetProductListPaginated(page, size)
}
internal/service/order.go
// GetOrderListPaginated 分页获取用户订单列表
func GetOrderListPaginated(userId, page, size int) ([]*model.Order, int64, error) {
return repository.GetOrderListPaginated(userId, page, size)
}
func GetOrderListByUserIDPaginated(userId, page, size int) ([]*model.Order, int64, error) {
return repository.GetOrderListByUserIDPaginated(userId, page, size)
}
修改Handler
internal/api/handlers/product.go
// GetProductList 获取商品列表(支持分页)
func GetProductList(c *gin.Context) {
var req dto.PageQuery
if err := c.ShouldBindQuery(&req); err != nil {
response.Fail(c, 400, "invalid page or size params")
return
}
products, total, err := service.GetProductListPaginated(req.Page, req.Size)
if err != nil {
response.Fail(c, 500, "failed to get product list")
return
}
response.Sucess(c, gin.H{
"data": products,
"total": total,
"page": req.Page,
"size": req.Size,
})
}
internal/api/handlers/order.go
// GetOrderList 获取当前用户订单列表(支持分页)
func GetOrderList(c *gin.Context) {
var req dto.PageQuery
if err := c.ShouldBindQuery(&req); err != nil {
response.Fail(c, 400, "invalid page or size params")
return
}
uid, _ := c.Get("uid")
orders, total, err := service.GetOrderListPaginated(uid.(uint), req.Page, req.Size)
if err != nil {
response.Fail(c, 500, "failed to get order list")
return
}
response.Sucess(c, gin.H{
"data": orders,
"total": total,
"page": req.Page,
"size": req.Size,
})
}
func GetActiveSeckillList(c *gin.Context) {
var req dto.PageQuery
if err := c.ShouldBindQuery(&req); err != nil {
req.Page, req.Size = 1, 10 // 默认值
}
list, total, err := service.GetActiveSeckillListPaginated(req.Page, req.Size)
if err != nil {
response.Fail(c, 500, "failed to get active seckill list")
return
}
response.Sucess(c, gin.H{"data": list, "total": total, "page": req.Page, "size": req.Size})
}
func GetAllSeckillList(c *gin.Context) {
var req dto.PageQuery
if err := c.ShouldBindQuery(&req); err != nil {
req.Page, req.Size = 1, 10
}
list, total, err := service.GetAllSeckillListPaginated(req.Page, req.Size)
if err != nil {
response.Fail(c, 500, "failed to get all seckill list")
return
}
response.Sucess(c, gin.H{"data": list, "total": total, "page": req.Page, "size": req.Size})
}
调用示例
# 商品列表
curl "http://localhost:8080/api/product/list?page=1&size=10" -H "Authorization: Bearer <TOKEN>"
# 进行中秒杀
curl "http://localhost:8080/api/seckill/list/active?page=1&size=10" -H "Authorization: Bearer <TOKEN>"
# admin 查询用户订单
curl "http://localhost:8080/api/order/list/1?page=1&size=10" -H "Authorization: Bearer <TOKEN>"
CRUD补充与软删除
- 目前只有批量拉取列表的接口(如 /api/product/list),但没有获取单条记录详情的接口。在真实的购物流程中,用户在列表中看到商品后,必定要点击进入“商品详情页”查看详细描述,然后才会发起秒杀或下单。
- 系统中除了状态流转(下单、取消、退款)涉及更新外,实体信息没有任何修改的入口。
- 商品侧:管理员发错了商品名称、定错了原价,或者需要补货普通的商品库存,目前只能去数据库里改,这不符合后台管理系统的基本 CRUD 逻辑。
- 用户侧:用户目前只能注册,无法“修改密码”。
- 实体创建后就永远存在,无法下架或删除。如果一个秒杀活动配置错误(比如价格少填了一个零),管理员目前无法强行终止或删除该活动;商品也无法下架。
1. DTO追加
internal/api/handlers/dto/product.go
// UpdateProductRequest 管理员修改商品
type UpdateProductRequest struct {
ProductId uint `json:"product_id" binding:"required"`
Name string `json:"name"`
Desc string `json:"description"`
Price float64 `json:"price"`
Stock int `json:"stock"`
}
// DeleteProductRequest 删除商品(软删除)
type DeleteProductRequest struct {
ProductId uint `json:"product_id" binding:"required"`
}
internal/api/handlers/dto/user.go
// UpdatePasswordRequest 修改密码
type UpdatePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
2. Model追加软删除字段
GORM 的软删除默认会用
WHERE deleted_at IS NULL过滤,DB.Delete()会自动改UPDATE deleted_at = now()而不是物理删除。
internal/model/product.go
// Product 定义了商品的结构体,包含商品ID、名称、描述、价格、库存数量和创建时间等字段
type Product struct {
ProductId uint `gorm:"primaryKey;column:product_id;autoIncrement" json:"product_id"` // 商品ID,主键,自增
Name string `gorm:"column:name;type:varchar(100);not null" json:"name"` // 商品名称,字符串类型,最大长度100,不能为空
Description string `gorm:"column:description;type:text" json:"description"` // 商品描述,文本类型
Price float64 `gorm:"column:price;type:decimal(10,2);not null" json:"price"` // 商品价格,十进制类型,精度10位,小数点后2位,不能为空
Stock int `gorm:"column:stock;type:int;not null" json:"stock"` // 商品库存数量,整数类型,不能为空
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` // 商品创建时间,自动设置为当前时间
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"` // (新) 商品删除时间(软删除)
}
internal/model/seckill.go
// Seckill 定义了秒杀活动的结构体,包含秒杀活动ID、关联的商品ID、秒杀活动名称、库存数量、开始时间、结束时间、状态和创建时间等字段
type Seckill struct {
SeckillId uint `gorm:"primaryKey;column:seckill_id;autoIncrement" json:"seckill_id"` // 秒杀活动ID,主键,自增
ProductId uint `gorm:"column:product_id;not null" json:"product_id"` // 关联的商品ID,不能为空
Name string `gorm:"column:name;type:varchar(200);not null" json:"name"` // 秒杀活动名称,字符串类型,最大长度200,不能为空
Price float64 `gorm:"column:price;type:decimal(10,2);not null" json:"price"` // 秒杀价格,十进制类型,精度10位,小数点后2位,不能为空
Stock int `gorm:"column:stock;not null" json:"stock"` // 秒杀活动库存数量,不能为空
StartTime time.Time `gorm:"column:start_time;not null" json:"start_time"` // 秒杀活动开始时间,不能为空
EndTime time.Time `gorm:"column:end_time;not null" json:"end_time"` // 秒杀活动结束时间,不能为空
Status int `gorm:"column:status;type:int;default:0" json:"status"` // 秒杀活动状态,整数类型,不能为空
// (0:未开始,1:进行中,2:已结束)
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` // 秒杀活动创建时间,自动设置为当前时间
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"` // (新) 秒杀活动删除时间(软删除)
}
3. Repository
注意:
DB.Delete在有gorm.DeletedAt字段的 Model 上会自动做软删除(设deleted_at),不会物理删除。GetProductByID默认不返回已软删除记录。
internal/repository/product.go
// UpdateProduct 管理员修改商品信息
func UpdateProduct(id uint, name string, desc string, price float64, stock int) error {
updates := map[string]interface{}{}
if name != "" {
updates["name"] = name
}
if desc != "" {
updates["description"] = desc
}
if price > 0 {
updates["price"] = price
}
if stock >= 0 {
updates["stock"] = stock
}
return DB.Model(&model.Product{}).Where("id = ?", id).Updates(updates).Error
}
// DeleteProduct 软删除商品
func DeleteProduct(id uint) error {
return DB.Delete(&model.Product{}, "product_id = ?", id).Error
}
internal/repository/user.go
// GetUserIdByUid 根据UID获取用户
func GetUserIdByUid(uid uint) (*model.User, error) {
var user model.User
err := DB.First(&user, uid).Error
return &user, err
}
// UpdatePassword 更新用户密码
func UpdatePassword(uid uint, hashedPwd string) error {
return DB.Model(&model.User{}).Where("uid = ?", uid).Update("password", hashedPwd).Error
}
4. Service
internal/service/product.go
// GetProductDetail 获取商品详情
func GetProductDetail(id uint) (*model.Product, error) {
return repository.GetProductByID(id)
}
// UpdateProduct 管理员修改商品
func UpdateProduct(req *dto.UpdateProductRequest) error {
return repository.UpdateProduct(req.ProductId, req.Name, req.Desc, req.Price, req.Stock)
}
// DeleteProduct 管理员下架商品(软删除)
func DeleteProduct(id uint) error {
return repository.DeleteProduct(id)
}
internal/service/user.go
// UpdatePassword 修改密码
func UpdatePassword(uid uint, oldPwd, newPwd string) error {
userId, err := repository.GetUserIdByUid(uid)
if err != nil {
return errors.New("user not found")
}
// 校验旧密码
if err := bcrypt.CompareHashAndPassword([]byte(userId.Password), []byte(oldPwd)); err != nil {
return errors.New("old password incorrect")
}
// 哈希新密码
hashed, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost)
if err != nil {
return err
}
// 更新密码
return repository.UpdatePassword(userId.Uid, string(hashed))
}
internal/service/order.go
func GetOrderDetail(orderId uint) (*model.Order, error) {
return repository.GetOrderByID(orderId)
}
5. Handlers
internal/api/handlers/product.go
// GetProductDetail 获取商品详情
func GetProductDetail(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.Fail(c, 400, "invalid product id")
return
}
product, err := service.GetProductDetail(uint(id))
if err != nil {
response.Fail(c, 500, "failed to get product detail")
return
}
response.Sucess(c, product)
}
// UpdateProduct 管理员修改商品
func UpdateProduct(c *gin.Context) {
var req dto.UpdateProductRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, 400, "invalid request")
return
}
if err := service.UpdateProduct(&req); err != nil {
response.Fail(c, 500, err.Error())
return
}
response.Sucess(c, gin.H{"message": "product updated"})
}
// DeleteProduct 管理员下架商品(软删除)
func DeleteProduct(c *gin.Context) {
var req dto.DeleteProductRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, 400, "invalid request")
return
}
if err := service.DeleteProduct(req.ProductId); err != nil {
response.Fail(c, 500, err.Error())
return
}
response.Sucess(c, gin.H{"message": "product deleted"})
}
internal/api/handlers/user.go
func UpdatePassword(c *gin.Context) {
var req dto.UpdatePasswordRequest
if err := c.ShouldBindBodyWithJSON(&req); err != nil {
response.Fail(c, 400, "invalid request")
return
}
uid, _ := c.Get("uid")
if err := service.UpdatePassword(uid.(uint), req.OldPassword, req.NewPassword); err != nil {
response.Fail(c, 500, err.Error())
return
}
response.Sucess(c, gin.H{"message": "password updated"})
}
internal/api/handlers/order.go
// GetOrderDetail 获取订单详情
func GetOrderDetail(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.Fail(c, 400, "invalid order id")
return
}
uid, _ := c.Get("uid")
role, _ := c.Get("role")
order, err := service.GetOrderDetail(uint(id))
if err != nil {
response.Fail(c, 404, "order not found")
return
}
// 普通用户只能查自己的订单
if role != "admin" && order.UserId != uid.(uint) {
response.Fail(c, 403, "permission denied")
return
}
response.Sucess(c, order)
}
6. 路由
cmd/scego-mall/main.go
// ...
// 需要鉴权的私有路由
protected := r.Group("/api")
protected.Use(middleware.JWTAuth()) // 使用JWT鉴权中间件
{
// ... 其余路由 ...
// 获取订单详情
protected.GET("/order/info/:id", handlers.GetOrderDetail)
// 商品管理
protected.GET("/product/info/:id", handlers.GetProductDetail) // 商品详情
protected.PUT("/product/update", middleware.RequireAdmin(), handlers.UpdateProduct) // 修改商品
protected.DELETE("/product/delete", middleware.RequireAdmin(), handlers.DeleteProduct) // 删除商品(软删除)
// 用户
protected.PUT("/user/password", handlers.UpdatePassword) // 修改密码
}
// ...
调用示例
商品详情
curl http://localhost:8080/api/product/info/1 \
-H "Authorization: Bearer <TOKEN>"
修改商品(admin)
curl -X PUT http://localhost:8080/api/product/update \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-d '{"product_id":1,"name":"iPhone 16 Pro","desc":"新配色","price":9999.00,"stock":50}'
删除商品(admin,软删除)
curl -X DELETE http://localhost:8080/api/product/delete \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-d '{"product_id":1}'
修改密码
curl -X PUT http://localhost:8080/api/user/password \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <TOKEN>" \
-d '{"old_password":"111111","new_password":"666666"}'
订单号的雪花算法
需求
在真实的秒杀系统中,订单号千万不能用数据库自增。
一是会暴露业务量(竞对下两单减一下就知道你一天卖多少);
二是分库分表时不方便;三是高并发下数据库自增锁可能成为瓶颈。
引入雪花算法 (Snowflake) 或类似的发号器。可以目录下实现一个 Snowflake ID 生成工具,在创建订单时手动赋给 OrderId。
Snowflake 原理
64 bit 分成三段:
- 41 bit:时间戳(毫秒),可用 69 年
- 10 bit:机器 ID(datacenter + worker)
- 12 bit:序列号,每毫秒最多 4096 个 ID
1. 雪花算法工具
pkg/snowflake/snowflake.go(新建)
package snowflake
import (
"errors"
"sync"
"time"
)
const (
epoch = int64(1609459200000) // 2021-01-01T00:00:00Z,毫秒级时间戳起点
datacenterBits = 5 // 数据中心占 5 bit
workerBits = 5 // 机器 ID 占 5 bit
sequenceBits = 12 // 序列号占 12 bit
maxDatacenter = int64(-1) ^ (int64(-1) << datacenterBits) // 31
maxWorker = int64(-1) ^ (int64(-1) << workerBits) // 31
maxSequence = int64(-1) ^ (int64(-1) << sequenceBits) // 4095
)
type Generator struct {
mu sync.Mutex
datacenter int64
worker int64
sequence int64
lastTime int64
}
var defaultGenerator *Generator
// Init 初始化全局雪花生成器
// datacenterId: 数据中心 ID(0-31)
// workerId: 机器 ID(0-31)
func Init(datacenterId, workerId int64) error {
if datacenterId < 0 || datacenterId > maxDatacenter {
return errors.New("datacenter ID out of range")
}
if workerId < 0 || workerId > maxWorker {
return errors.New("worker ID out of range")
}
defaultGenerator = &Generator{
datacenter: datacenterId,
worker: workerId,
lastTime: -1,
sequence: 0,
}
return nil
}
// Generate 生成一个新的雪花 ID
func Generate() int64 {
return defaultGenerator.generate()
}
func (g *Generator) generate() int64 {
g.mu.Lock()
defer g.mu.Unlock()
now := time.Now().UnixNano() / 1e6 // 毫秒时间戳
if now == g.lastTime {
g.sequence = (g.sequence + 1) & maxSequence
if g.sequence == 0 {
// 同一毫秒内序列号用完,等待下一毫秒
for now <= g.lastTime {
now = time.Now().UnixNano() / 1e6
}
}
} else {
g.sequence = 0
}
g.lastTime = now
// 拼装 ID:时间戳偏移到最高位 | 数据中心 | 机器ID | 序列号
id := (now-epoch)<<(datacenterBits+workerBits+sequenceBits) |
g.datacenter<<(workerBits+sequenceBits) |
g.worker<<sequenceBits |
g.sequence
return id
}
注意:雪花 ID 是 int64,但数据库用无符号 uint 也能存得下,JSON 序列化时统一输出为十进制数字。
2. Model
internal/model/order.go
移除 autoIncrement,改为手动赋值:
type Order struct {
OrderId uint `gorm:"primaryKey;column:order_id" json:"order_id"` // 雪花算法,手动赋值
UserId uint `gorm:"column:user_id;not null" json:"user_id"`
ProductId uint `gorm:"column:product_id;not null" json:"product_id"`
SeckillId uint `gorm:"column:seckill_id;not null" json:"seckill_id"`
Amount float64 `gorm:"column:amount;type:decimal(10,2);not null" json:"amount"`
Status int `gorm:"column:status;type:int;default:0" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
}
3. Consumer中赋值
internal/mq/consumer.go
import "github.com/Chuan81/secgo-mall/pkg/snowflake"
// CreateOrder 内,创建订单前手动生成雪花 ID
order := &model.Order{
OrderId: uint(snowflake.Generate()), // 手动赋值雪花 ID
UserId: msg.UserId,
ProductId: msg.ProductId,
SeckillId: msg.SeckillId,
Amount: msg.Amount,
Status: 0,
}
4. 初始化
cmd/secgo-mall/main.go
import "github.com/Chuan81/secgo-mall/pkg/snowflake"
// database.InitMySQL() 之后添加
if err := snowflake.Init(1, 1); err != nil {
slog.Error("snowflake init failed", "error", err.Error())
os.Exit(1)
}
slog.Info("snowflake initialized", "datacenter", 1, "worker", 1)
验证方法
服务启动后,下单并查看数据库:
SELECT order_id FROM `order` ORDER BY order_id DESC LIMIT 10;
预期:order_id 是类似 1179304385089531904 的长整数,且单调递增(但无规律可猜)。
和自增 ID 的对比:
| 自增 ID | 雪花 ID | |
|---|---|---|
| 形如 | 1, 2, 3 |
1179304385089531904 |
| 可猜测 | ✅ | ❌ |
| 分库分表 | ❌ | ✅ |
| 暴露业务量 | ✅ | ❌ |
优雅启停
现状:cmd/secgo-mall/main.go 中直接 r.Run(addr) 阻塞到死。
缺陷:如果是在 K8s 或 Docker 中重新部署,发版时进程被杀,正在处理的 HTTP 请求、正在消费的
MQ 消息会被强制中断,导致数据不一致。
优化方案:使用 http.Server 配合 os.Signal 监听 SIGINT 和 SIGTERM,调用
srv.Shutdown(),并在关闭前关闭 DB 连接和 MQ Channel。这是后端开发的必备基操。
cmd/secgo-mall/main.go 修改 main函数末尾部分
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/Chuan81/secgo-mall/config"
"github.com/Chuan81/secgo-mall/internal/api/handlers"
"github.com/Chuan81/secgo-mall/internal/cache"
"github.com/Chuan81/secgo-mall/internal/middleware"
"github.com/Chuan81/secgo-mall/internal/mq"
database "github.com/Chuan81/secgo-mall/internal/repository"
"github.com/Chuan81/secgo-mall/internal/service"
"github.com/Chuan81/secgo-mall/pkg/response"
"github.com/Chuan81/secgo-mall/pkg/snowflake"
"github.com/gin-gonic/gin"
)
func main() {
// ... 路由注册 ...
addr := fmt.Sprintf(":%d", config.GlobalConfig.Server.Port)
srv := &http.Server{
Addr: addr,
Handler: r,
}
// goroutine中启动HTTP服务
go func() {
slog.Info("Secgo-Mall server starting", "addr", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("Secgo-Mall server error", "error", err.Error())
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("Secgo-Mall server shutting down")
// 给现有请求最多 5 秒超时完成
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 关闭 HTTP 服务(停止接收新请求,等待已有请求处理完毕)
if err := srv.Shutdown(ctx); err != nil {
slog.Error("server shutdown error", "error", err.Error())
}
// 关闭数据库连接
sqlDB, err := database.DB.DB()
if err == nil {
sqlDB.Close()
slog.Info("database connection closed")
}
// 关闭 RabbitMQ Channel
if mq.Channel != nil {
mq.Channel.Close()
slog.Info("RabbitMQ channel closed")
}
slog.Info("server exited")
// // 启动服务器,监听在8080端口
// if err := r.Run(addr); err != nil {
// log.Fatalf("Failed to start server: %v", err)
// }
}
| 原来 | 改造后 |
|---|---|
r.Run(addr) 阻塞直到进程终止 |
srv.ListenAndServe() 在 goroutine 中运行 |
| Ctrl+C 直接杀进程 | 信号被捕获,触发优雅关闭 |
| 无连接收尾 | 等已有请求完成(最多 5 秒),再关闭 DB 和 MQ |
TraceID
中间件
internal/middleware/trace.go
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func TraceID() gin.HandlerFunc {
return func(c *gin.Context) {
traceId := uuid.New().String()
c.Set("trace_id", traceId)
// 将 trace_id 注入响应头,方便排查
c.Header("X-Trace-ID", traceId)
c.Next()
}
}
注册到 main.go
在 protected.Use(middleware.JWTAuth()) 之前加一行:
protected.Use(middleware.TraceID()) // 链路追踪,需在最前面
protected.Use(middleware.JWTAuth())
顺序很重要,TraceID 要在最前面,这样后续所有中间件和 handler 都能从 Context 读到 trace_id。
验证
请求任意接口,响应头里会出现 X-Trace-ID:
curl -I http://localhost:8080/api/user/info \
-H "Authorization: Bearer <TOKEN>"
X-Trace-ID: a1b2c3d4-e5f6-...
服务日志里 Gin 的 Logger 会自动打出每个请求的 trace_id(因为 Gin 内置 Logger 在 TraceID 之后执行)。
如果想在 service 层日志里也带上 trace_id
每个 service 函数都要从 context 取 trace_id 再打日志,比较繁琐。面试时能说出"请求进来生成 UUID 塞 context,响应时带在日志里"这个思路就够了。

浙公网安备 33010602011771号