投票 V4 版本
2023/10/1大约 7 分钟
1.为验证码接口实现一个简单的限流
基于Redis的XYZ限流
大意:一个用户X秒内请求超过Y次封Z秒,用户已登录,我能到uid;用户未登录,ip+ua
什么是IP和UA
ip 就不用解释了,计算机网络最基本的知识
ua 指的是 user-agent 用户代理。里面有一些硬件数据。
问题:IP+UA 会出现什么问题?
假如,在你们有个人跟你同款手机,手机系统完全一样,用同一个版本的浏览器,访问同一个网站。ip相同,ua也相同。
提前给游客发一个uuid
参考代码
func checkXYZ(context *gin.Context) bool {
//拿到IP和UA
ip := context.ClientIP()
ua := context.GetHeader("user-agent")
fmt.Printf("ip:%s\nua:%s\n", ip, ua)
//转下MD5
hash := md5.New()
hash.Write([]byte(ip + ua))
hashBytes := hash.Sum(nil)
hashString := hex.EncodeToString(hashBytes)
//校验是否被ban
flag, _ := model.Rdb.Get(context, "ban-"+hashString).Bool()
if flag {
return false
}
i, _ := model.Rdb.Get(context, "xyz-"+hashString).Int()
fmt.Printf("i:%d\n", i)
if i > 5 {
model.Rdb.SetEx(context, "ban-"+hashString, true, 30*time.Second)
return false
}
model.Rdb.Incr(context, "xyz-"+hashString)
model.Rdb.Expire(context, "xyz-"+hashString, 50*time.Second)
return true
}
注意,这是思路最简单的一个限流策略。这个策略一般不会用到代码中,而是会放在网关层。比如:Nginx自己就有限流模块。
其他的限流策略
- 漏斗算法的限流
- 令牌桶算法的限流
- 滑动窗口算法的限流
- 基于服务器资源使用率的限流
常用的限流包:GO小三包里的 time/rate
2. 获取投票详情接口的优化
两次读取
我们原生SQL 就是使用的两次SQL读取。
使用Join读取
在MySQL中,“Join”就非常适合我们当前的业务场景。
select * from vote
join vote_opt
on vote.id = vote_opt.vote_id
where vote.id = 2;
// GetVoteV3 改为Join模式 数据量少的时候会用的方式
func GetVoteV3(id int64) (*VoteWithOpt, error) {
var ret VoteWithOpt
sql := "select vote.*,vote_opt.id as vid, vote_opt.name,vote_opt.count from vote join vote_opt on vote.id = vote_opt.vote_id where vote.id = ?"
//err := Conn.Raw(sql, id).Scan(&ret).Error //这样子是无法直接扫到结构体里的,我们可以提供两个方法:
//第一个 把ret 换成map
//ret1 := make(map[any]any)
//err := Conn.Raw(sql, id).Scan(&ret1).Error
//for a, a2 := range ret1 {
// //再把 a a2 转义到 VoteWithOpt中
//}
//第二种方法
rows, err := Conn.Raw(sql, id).Rows()
if err != nil {
return &ret, err
}
//opt := make([]VoteOpt, 0)
for rows.Next() {
//读取vote_opt数据
ret1 := make(map[string]interface{})
_ = Conn.ScanRows(rows, &ret1)
//再将map 的数据转存到结构体中,注意,这个方法非常难用,非常不好用。
//Gorm 还提供了一种自定义数据结构的方法,也不太好用
//将map先转为 json 再转为 结构体,也可以写一个反射 直接实现。
fmt.Printf("ret1:%+v\n", ret1)
}
return &ret, nil
}
为什么不建议你 数据量特别大情况下 去连表查?
GORM预加载
type Vote struct {
Id int64 `gorm:"column:id;primary_key;AUTO_INCREMENT;NOT NULL"`
Title string `gorm:"column:title;default:NULL"`
Type int32 `gorm:"column:type;default:NULL;comment:'0单选1多选'"`
Status int32 `gorm:"column:status;default:NULL;comment:'0正常1超时'"`
Time int64 `gorm:"column:time;default:NULL;comment:'有效时长'"`
UserId int64 `gorm:"column:user_id;default:NULL;comment:'创建人'"`
CreatedTime time.Time `gorm:"column:created_time;default:NULL"`
UpdatedTime time.Time `gorm:"column:updated_time;default:NULL"`
Opt []VoteOpt
}
func GetVoteV2(id int64) (*Vote, error) {
ret := &Vote{}
err := Conn.Preload("Opt").
Table("vote").Where("id = ?", id).
First(&ret).Error
if err != nil {
tools.Logger.Printf("[GetVoteV1]err:%s", err.Error())
return nil, err
}
return ret, nil
}
使用协程读取1
// GetVoteV4 改为并发模式1 绝对不会用的方式
func GetVoteV4(id int64) (*VoteWithOpt, error) {
var ret Vote
opt := make([]VoteOpt, 0)
ch := make(chan struct{}, 2)
go func() {
err := Conn.Raw("select * from vote where id = ?", id).Scan(&ret).Error
if err != nil {
tools.Logger.Printf("[GetVoteV1]err:%s", err.Error())
}
ch <- struct{}{}
}()
go func() {
err := Conn.Raw("select * from vote_opt where vote_id = ?", id).Scan(&opt).Error
if err != nil {
tools.Logger.Printf("[GetVoteV1]err:%s", err.Error())
}
ch <- struct{}{}
}()
var ini int
for _ = range ch {
ini++
if ini >= 2 {
break
}
}
return &VoteWithOpt{
Vote: ret,
Opt: opt,
}, nil
}
使用协程读取2
// GetVoteV5 改为并发模式2 最常用的方式
func GetVoteV5(id int64) (*VoteWithOpt, error) {
var ret Vote
opt := make([]VoteOpt, 0)
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
err := Conn.Raw("select * from vote where id = ?", id).Scan(&ret).Error
if err != nil {
tools.Logger.Printf("[GetVoteV1]err:%s", err.Error())
}
wg.Done()
}()
wg.Add(1)
go func() {
err := Conn.Raw("select * from vote_opt where vote_id = ?", id).Scan(&opt).Error
if err != nil {
tools.Logger.Printf("[GetVoteV1]err:%s", err.Error())
}
wg.Done()
}()
wg.Wait()
return &VoteWithOpt{
Vote: ret,
Opt: opt,
}, nil
}
注意,WaitGroup 也不是最优解,后续还有一个优化后的并发包,ErrGroup
3.使用雪花算法生成用户ID
https://github.com/bwmarrin/snowflake
Google 的 uuid 是一个string 不太适合作为用户或者订单的主键。雪花算法可以用来生成int类型的数据
//错误用法
func GetUid() int64 {
node, _ := snowflake.NewNode(1)
return node.Generate().Int64()
}
//正确用法
var snowNode *snowflake.Node
func GetUid() int64 {
if snowNode == nil {
snowNode, _ = snowflake.NewNode(1)
}
return snowNode.Generate().Int64()
}
雪花算法的原理也非常简单,它的源码非常适合新手学习。
//这个包的雪花算法存在一定的问题,并发存在重复
// sonyflake
4.投票接口优化,防止刷票
使用Redis记录投票缓存
func GetVoteHistoryV1(c context.Context, userId, voteId int64) []VoteOptUser {
ret := make([]VoteOptUser, 0)
//先查询缓存
k := fmt.Sprintf("vote-user-%d-%d", userId, voteId)
str, _ := Rdb.Get(c, k).Result()
fmt.Printf("str:%s\n", str)
if len(str) > 0 {
//将数据转化为struct
_ = json.Unmarshal([]byte(str), &ret)
return ret
}
//不存在就先查数据库再封装缓存
if err := Conn.Table("vote_opt_user").Where("user_id = ? and vote_id = ?", userId, voteId).Find(&ret).Error; err != nil {
fmt.Printf("err:%s", err.Error())
}
if len(ret) > 0 {
s, _ := json.Marshal(ret)
err := Rdb.Set(c, k, s, 3600*time.Second).Err()
if err != nil {
fmt.Printf("err1:%s\n", err.Error())
}
}
return ret
}
- 如果一直查一个不存在的值怎么办?
- 一个小时后,这个值过期了怎么办? 下次再拉进来。同时有很多请求,并发拉取数据怎么办。
什么是缓存穿透
如果我们查询肯定不存在的数据,那么redis 里一定不存在,请求一定会打到MySQL上,缓存就形同虚设了。
怎么解决?
5.登录接口优化
Session使用Redis存储
https://github.com/rbcervilla/redisstore
package model
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/rbcervilla/redisstore/v9"
)
var sessionStore *redisstore.RedisStore
var sessionNameV1 = "session-name-v1"
func GetSessionV1(c *gin.Context) map[interface{}]interface{} {
session, _ := sessionStore.Get(c.Request, sessionNameV1)
fmt.Printf("session:%+v\n", session.Values)
return session.Values
}
func SetSessionV1(c *gin.Context, name string, id int64) error {
session, _ := sessionStore.Get(c.Request, sessionNameV1)
session.Values["name"] = name
session.Values["id"] = id
return session.Save(c.Request, c.Writer)
}
func FlushSessionV1(c *gin.Context) error {
session, _ := sessionStore.Get(c.Request, sessionNameV1)
fmt.Printf("session : %+v\n", session.Values)
session.Values["name"] = ""
session.Values["id"] = ""
return session.Save(c.Request, c.Writer)
}
为什么要优化:
- 存在本机,会消耗本机的内存。
- 假如我们有多台服务器,你的session在A机器,但是我们请求走到了B机器。
cookie-》session-》session-redis-》验签服务,专门的分布式缓存-》jwt
引入JWT登录
package model
import (
"errors"
"github.com/golang-jwt/jwt/v5"
"time"
)
type UserToken struct {
Id int64
Name string
jwt.RegisteredClaims
}
// 签名密钥
const signKey = "香香编程喵喵喵"
func GetJwt(id int64, name string) (string, error) {
if id < 0 || name == "" {
return "", errors.New("参数错误")
}
token := &UserToken{
Id: id,
Name: name,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "香香编程", // 签发者
Subject: "后勤部秦师傅", // 签发对象
Audience: jwt.ClaimStrings{"Android", "IOS", "H5"}, //签发受众
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), //过期时间 1小时
NotBefore: jwt.NewNumericDate(time.Now().Add(time.Second * 10)), //最早使用时间 10秒之后
IssuedAt: jwt.NewNumericDate(time.Now()), //签发时间 当前时间
ID: "Test-1", // jwt ID,类似于盐值 最好是每次都随机
},
}
tokenStr, err := jwt.NewWithClaims(jwt.SigningMethodHS256, token).SignedString([]byte(signKey))
return tokenStr, err
}
func CheckJwt(tokenStr string) (*UserToken, error) {
token, err := jwt.ParseWithClaims(tokenStr, &UserToken{}, func(token *jwt.Token) (interface{}, error) {
return []byte(signKey), nil //返回签名密钥
})
if err != nil || !token.Valid {
return nil, errors.New("校验失败,TOKEN不合格")
}
claims, ok := token.Claims.(*UserToken)
if !ok {
return nil, errors.New("TOKEN转义失败!")
}
return claims, nil
}
- api接口使用,
- jwt+cookie 或者其他方式 使用JWT。