当前位置: 首页 > ds >正文

6.14项目一话术

6.14项目一话术

  • Rediit
    • 1.项目整体介绍
    • 2. 核心开发框架:Gin + Zap + Viper
    • 3.在 Gin 当中集成 JWT 鉴权中间件实现无状态认证
    • 4.数据存储:sqlx + MySQL
    • 5.使用 Redis 缓存用户投票数
    • 6.限流和压测

Rediit

1.项目整体介绍

我独立开发了一个名为 Bluebell 的项目,它是一个功能完整的社交媒体平台,核心功能类似于 Reddit。用户可以在平台上注册登录、创建和浏览帖子、加入不同的社区、并对帖子进行投票。

这个项目的目标是构建一个高并发、高可用且易于维护的后端服务。为此,我选择了这套以 Go 语言为核心的技术栈,包括使用 Gin 框架来处理 Web 请求,MySQL 作为持久化数据库,Redis 作为高性能缓存,并计划通过 Docker 实现容器化部署。

补充:问docker

在 Bluebell 这个项目中,由于目前主要是我个人在开发和本地测试,所以暂时还没有走完容器化部署的最后一步。不过,我对 Docker 并不陌生,在我之前的实验室项目中,我就有比较深入的使用经验,当时主要是用 Docker 来封装和运行我们的深度学习训练任务。

通过之前的经验,我认识到 Docker 的价值远不止于隔离环境和方便移植,我认为 Docker 的核心价值在于它将应用及其所有依赖打包成一个标准化的、自包含的单元——也就是镜像,这个标准化的单元带来了几个巨大的好处:

  1. 极大地简化了部署流程:运维人员不需要关心我的应用是用 Go 写的,依赖什么版本的库。他们只需要 docker run 我提供的镜像就可以了
  2. 为自动化运维 (CI/CD,持续集成/持续交付/持续部署) 铺平了道路:在现代化的开发流程中,我们可以设置一个 CI/CD 管道。当我向代码仓库(比如 Git)提交新代码后,可以自动触发构建一个新的 Docker 镜像,运行自动化测试,测试通过后自动将新镜像部署到服务器上
  3. 高效的资源利用:相比于传统的虚拟机,容器非常轻量,启动速度是秒级

2. 核心开发框架:Gin + Zap + Viper

为什么选择Gin作为Web框架?

我选择 Gin 是因为它是一个基于 Radix 树路由的高性能 Web 框架,非常轻量级,中间件生态也很丰富。相比其他框架,它在提供强大功能的同时,几乎没有性能损耗,这对于需要处理大量并发请求的社交平台至关重要。

具体怎么用

在项目中,我遵循了 RESTful API 的设计原则。我将项目结构划分为清晰的层次:Routes (路由层)、Controllers (控制器/视图函数层)、Logic/Services (业务逻辑层) 和 DAO (数据访问层)。这种分层结构使得代码逻辑清晰,易于维护和测试。例如,一个发帖请求会先经过路由匹配,然后由 Controller 层校验基础参数,接着调用 Logic 层处理复杂的业务逻辑(如检查用户权限、内容审查等),最后通过 DAO 层与数据库交互。

为什么选择Zap管理日志?

对于一个线上项目来说,日志是排查问题的关键。我没有使用 Go 自带的 log 库,而是集成了 Uber 开源的 Zap 日志库。Zap 是一个结构化、高性能的日志库。它会将日志输出为 JSON 格式,而不是纯文本。

使用Zap日志库的好处

结构化日志最大的好处是机器可读。在生产环境中,这些日志可以被轻松地收集到 ELK (Elasticsearch, Logstash, Kibana) 中进行搜集、存储和可视化日志,极大地提高了问题排查的效率。

为什么选择 Viper管理配置?

为了实现配置与代码的分离,我使用了 Viper。它能够从多种来源(如 YAML 文件、环境变量、远程配置中心)读取配置,并支持配置热加载。

具体怎么用

我将应用信息、数据库地址、日志级别等配置信息都放在一个 conf.yaml 文件中。而不是硬编码在代码里。Viper 的主要好处是它能让我非常灵活地管理不同环境下的配置。比如,开发时我可以用本地的 MySQL 和 Redis 地址,而部署到生产环境时,我只需要修改 conf.yaml 文件中的 mysql.host 和 redis.host,不需要改动和重新编译任何一行代码。

3.在 Gin 当中集成 JWT 鉴权中间件实现无状态认证

为什么是JWT?

我采用了基于 JWT (JSON Web Token) 的无状态认证方案。相比于传统的 Session-Cookie 方案,JWT 不需要服务端存储用户的会话信息,这使得后端服务可以水平扩展(stateless),非常适合分布式和微服务架构。

补充:水平扩展(无状态,stateless)

在传统的 session-cookie 模式下:

  • 用户登录后,服务端要保存“会话信息(session)”;

  • 每次用户发请求,服务端要查一下这个 session 信息,看看是谁登录了;

  • 问题来了:如果你有多台服务器,这个 session 信息到底存在哪一台呢?不好统一管理,或者还得用专门的共享存储。

而 JWT 是“无状态”的,登录成功后用户拿到一个加密的 token(令牌),之后:

  • 用户自己带着这个 token 发请求;

  • 后端只需要校验这个 token 是否有效;

  • 不需要查数据库、不需要存 session,服务器彼此之间也不用共享信息。

这样你想加几台后端服务器都可以,大家都能独立处理请求,不需要“互相沟通”谁存了哪个用户的登录状态。

详细的认证流程是怎样的?

  1. 登录与签发:用户使用账号密码登录。服务器验证通过后,会生成两个 Token:一个短生命周期的 Access Token(例如 2 小时)和一个长生命周期的 Refresh Token(例如 7 天)。项目中没有重复造轮子,而是使用现成的"github.com/dgrijalva/jwt-go"来完成 JWT 的生成。

  2. 令牌传递:服务器将这两个 Token 返回给客户端。客户端在后续请求中,会将 Access Token 放在 HTTP 请求的 Authorization Header 中(格式为 Bearer )。

  3. 鉴权中间件:我编写了一个全局的 JWT 鉴权中间件。这个中间件会拦截所有需要登录才能访问的 API 请求(比如发表帖子,给帖子点赞)。它会:

    • 解析 Header 中的 Access Token,验证其签名和有效期。

    • 如果验证通过,就从 Token 的 Payload 中解析出 用户 ID 等信息。

    • 关键一步:为了避免在后续的业务逻辑中重复解析或查询用户信息,我将解析出的用户 ID 存入 Gin 的 Context 中 (c.Set(controller.CtxUserIDKey, mc.UserID))。这样,后续的 Controller 或 Service 层函数就可以直接从 Context 中获取当前登录用户的身份信息,非常高效。

  4. 令牌刷新机制:当 Access Token 过期后,客户端会使用 Refresh Token 向特定的刷新接口请求一对新的 Token,从而实现用户无感知的登录状态续期,提升了用户体验。

补充:限制一个账号只能在一个设备上登陆或者多台设备只能同时登录n个

核心思想:在用户每次登录并获取到双 token 后,服务器端记录该登录会话的信息,并在用户后续操作时进行校验。当登录设备数量达到上限时,拒绝新的登录请求,或者强制下线最老的登录会话。

4.数据存储:sqlx + MySQL

为什么选择 sqlx 而不是 GORM?

在数据库交互方面,我选择了 sqlx 库而不是像 GORM 这样的全功能 ORM。主要原因是 sqlx 是对 Go 原生 database/sql 包的一个轻量级扩展,它在提供便利性(如将查询结果直接扫描到结构体中)的同时,让我可以完全掌控 SQL 语句。对于复杂的查询,手写 SQL 往往比 ORM 生成的 SQL 更高效。这是一种在开发效率和底层控制之间的平衡。

如何组织的?

我创建了一个 DAO 层来专门负责所有数据库操作。比如,mysql文件夹下的community.go用来负责所有和社区相关的操作,像是展示社区列表,根据社区id查询社区分类详情,又比如redis文件夹下的vote.go负责帖子的投票操作。这样做的好处是业务逻辑层(Logic)不需要关心具体的 SQL 实现,实现了逻辑和数据的解耦。

5.使用 Redis 缓存用户投票数

为什么用 Redis?

对于社交平台,投票和帖子排序是读写非常频繁的操作。如果每次投票都直接读写 MySQL,会给数据库带来巨大压力。因此,我引入了 Redis 做缓存,利用其基于内存的高速读写能力来优化性能。

具体实现细节

  1. 投票数据缓存:当用户对帖子进行投票时,我并不是直接更新 MySQL。而是先在 Redis 中记录投票信息。我使用了 Redis 的 ZSet 和 Set。例如:
    • KeyPostTimeZSet = “post:time” 是一个帖子按照发布时间排序的 ZSet(有序集合),用于实现项目中的按时间排序帖子;
    • KeyPostScoreZSet = “post:score” 是一个帖子按照得分排序的ZSet,用于实现项目中的根据热度(投票分数)排序帖子;
    • KeyPostVotedZSetPrefix = “post:voted:” 是一个用于记录某个帖子下哪些用户投了票,以及投的什么票的ZSet;
    • KeyCommunitySetPrefix = “community:” 是一个用于记录每个社区下有哪些帖子的Set
package redis//redis key
//redis key尽量用命名空间的方式区分不同的keyconst (KeyPrefix              = "bluebell:"KeyPostTimeZSet        = "post:time"   //ZSet 帖子及发帖时间KeyPostScoreZSet       = "post:score"  //ZSet 帖子及投票的分数KeyPostVotedZSetPrefix = "post:voted:" //ZSet 记录用户及投票的类型,参数是post idKeyCommunitySetPrefix  = "community:"  //set  保存每个分区下帖子的id
)//给rediskey加前缀
func getRedisKey(key string) string {return KeyPrefix + key
}

当我们创建帖子时,需要同时保存帖子的创建时间、帖子的初始热度分数(与创建时间相等)、以及帖子的社区 ID:

func CreatePost(postID, community_id int64) error {pipeline := rdb.TxPipeline()//帖子时间pipeline.ZAdd(getRedisKey(KeyPostTimeZSet), redis.Z{Score:  float64(time.Now().Unix()),Member: strconv.FormatInt(postID, 10),})//帖子分数pipeline.ZAdd(getRedisKey(KeyPostScoreZSet), redis.Z{//初始的分数仍然与时间相关联,这与直觉相符,越新的帖子分数应该越高,使得新的帖子尽可能靠前显示Score:  float64(time.Now().Unix()),Member: strconv.FormatInt(postID, 10),})//补充 把帖子id加到社区的setpipeline.SAdd(getRedisKey(KeyCommunitySetPrefix+strconv.Itoa(int(community_id))), postID)_, err := pipeline.Exec()return err
}

第一个业务逻辑是根据社区 ID 获取帖子按发布时间分数或者得分热度分数降序排序的结果,其实现如下:

// GetCommunityPostIDsInOrder 按社区查询ids
func GetCommunityPostIDsInOrder(p *models.ParamPostlist) ([]string, error) {//使用zinterstore 把分区的帖子set与帖子分数的zset生成一个新的zset//针对新的zset按之前的逻辑取数据orderkey := getRedisKey(KeyPostTimeZSet)if p.Order == models.Orderscore {orderkey = getRedisKey(KeyPostScoreZSet)}ckey := getRedisKey(KeyCommunitySetPrefix + strconv.Itoa(int(p.CommunityID))) //社区的key//利用缓存key减少zinterstore执行的次数key := orderkey + strconv.Itoa(int(p.CommunityID))if rdb.Exists(key).Val() < 1 {//不存在,需要计算pipeline := rdb.Pipeline()//用 ZINTERSTORE 把社区下的帖子集合(Set)全局排序的 ZSet(时间/得分)的帖子ID做一个交集,生成一个新的 ZSet://key 是上面新拼的 key,分数来自原排序 ZSet,用 MAX 保留最大的分数(通常其实只有一个来源)//得到一个:"某社区下的帖子,按时间/得分排序的新 ZSet"pipeline.ZInterStore(key, redis.ZStore{Aggregate: "MAX",}, ckey, orderkey)pipeline.Expire(key, 60*time.Second)_, err := pipeline.Exec()if err != nil {return nil, err}}//存在的话就直接根据key查询idsreturn getIDsFormKey(key, p.Offset, p.Limit)
}

第二个业务逻辑是帖子的点赞功能,帖子点赞的功能实现如下:

const (oneWeekInSeconds = 7 * 24 * 3600scorePerVote     = 432 //每一票值多少分
)var (ErrorVoteTimeExpire = errors.New("投票时间已过")ErrorVoteRepeat     = errors.New("不许重复投票")
)func VoteForPost(userID, postID string, value float64) error {//1.判断投票限制//去redis取发布时间post_time := rdb.ZScore(getRedisKey(KeyPostTimeZSet), postID).Val()if float64(time.Now().Unix())-post_time > oneWeekInSeconds {return ErrorVoteTimeExpire}//2和3需要放到一个pipeline事务里面//2.更新帖子的分数//先查当前用户给当前帖子的投票记录ov := rdb.ZScore(getRedisKey(KeyPostVotedZSetPrefix+postID), userID).Val()//不能重复投相同的票if value == ov {return ErrorVoteRepeat}var symbol float64if value > ov {symbol = 1} else {symbol = -1}diff := math.Abs(ov - value)pipeline := rdb.TxPipeline()pipeline.ZIncrBy(getRedisKey(KeyPostScoreZSet), symbol*diff*scorePerVote, postID)//3.记录用户为该则帖子投票的数据if value == 0 {pipeline.ZRem(getRedisKey(KeyPostVotedZSetPrefix+postID), userID)} else {//如果用户之前已经使用了 ZAdd 添加相同的 Member 到 Sorted Set,那么本次 ZAdd 将会把之前的 Member 和 Score 覆盖掉//也就是说如果用户之前对该帖子投了up,但是这次投了down,那么redis KeyPostVotedZSetPrefix就只会记录最新的pipeline.ZAdd(getRedisKey(KeyPostVotedZSetPrefix+postID), redis.Z{Score:  value,Member: userID,})}_, err := pipeline.Exec()return err
}

在为帖子进行投票时,首先要判断帖子是否已经过了投票时间,如果超时直接返回。(防止“挖坟”操作,让首页展示的帖子尽可能富有时效性)

之后,我们进一步去 Redis 的ZSet:bluebell:post:voted:查找当前用户是否已经为帖子投过票,如果当前用户上一次投票行为和本次相同,那么禁止重复投票。(避免已经点赞下次还是点赞)

然后根据用户的投票行为,对 Redis 中保存的帖子分数进行修改。比如:

  • 第一次点赞:ov=0, value=1 → diff=1, op=1 → 加 432 分

  • 取消点赞:ov=1, value=0 → diff=1, op=-1 → 减 432 分

  • 反对变点赞:ov=-1, value=1 → diff=2, op=1 → 加 864 分

由于帖子的初始分数与创建时间相关,因此不断为帖子投票可以提高帖子的分数,使帖子的分数权重上升。由于我们设置每票分数为 432,因此 200 张赞成票可以为帖子续一天的热度。

最后在 Redis 中记录用户本次的投票行为。

  1. 帖子排序算法:首页的帖子不能简单按票数或时间排序。为了让新的、受欢迎的帖子更容易被看到,我实现了一个随时间权重下降的评价系统,算法灵感来源于 Reddit 的 Hot Ranking 算法。

可以简单介绍算法公式: Score = log ⁡ 10 ( ∣ upvotes − downvotes ∣ ) + sign ( upvotes − downvotes ) ⋅ t 45000 \text{Score} = \log_{10}(|\text{upvotes} - \text{downvotes}|) + \frac{\text{sign}(\text{upvotes} - \text{downvotes}) \cdot t}{45000} Score=log10(upvotesdownvotes)+45000sign(upvotesdownvotes)t
其中,t 是从一个固定的时间点(例如项目上线时间)到帖子发布时间的秒数差。

解释算法:“这个公式主要由两部分组成:第一部分是票数得分,通过取对数,可以使得票数从 10 票到 100 票的得分增长远大于从 1010 票到 1100 票的增长,避免了高票帖子霸榜。第二部分是时间权重,帖子的发布时间越新,t 值越大,得分也越高。这个分数会随着时间的流逝而自然下降。我通过一个定时的任务,周期性地计算热门帖子的分数,并将排好序的帖子 ID 列表缓存到 Redis 的 ZSET (Sorted Set) 中,获取热门帖子列表时,直接从 ZSET 中读取,速度极快。”

6.限流和压测

项目中限流的实现方式

使用 Token Bucket(令牌桶) 或 Leaky Bucket(漏桶) 原理的中间件,比如:

项目中使用了第三方库:github.com/juju/ratelimit

// gin.HandlerFunc 就是 func(*gin.Context) 的别名
// 在 RateLimitMiddleware 中,内部函数就“捕获”了在外部函数中创建的那个 bucket 变量
// 这意味着,无论有多少个请求经过这个中间件,每次执行内部函数时,它访问到的都是同一个 bucket 实例
// 这对于限流器来说是至关重要的,因为它需要追踪所有请求的状态
func RateLimitMiddleware(fillInterval time.Duration, cap int64) func(c *gin.Context) {bucket := ratelimit.NewBucket(fillInterval, cap)return func(c *gin.Context) {// 如果取不到令牌就中断本次请求返回 rate limit...if bucket.TakeAvailable(1) < 1 {c.String(http.StatusOK, "rate limit...")c.Abort()return}//取到令牌就放行c.Next()}
}

压测工具

为了验证限流是否生效,以及系统抗压能力, 项目常用以下压测工具

wrk(推荐):一个高性能 HTTP 压测工具,支持 Lua 脚本,适合测试高并发接口。

示例命令:

wrk -t4 -c100 -d30s http://localhost:8080/api/v1/post

说明:

-t4: 4 个线程

-c100: 100 个连接

-d30s: 压测 30 秒

http://www.xdnf.cn/news/14412.html

相关文章:

  • 四六级英语作文模版
  • LeetCode 第72题:编辑距离(巧妙的动态规划方法)
  • 同旺科技 USB TO SPI / I2C适配器(专业版)--EEPROM读写——C
  • uni-app项目实战笔记14--给全屏页面添加遮罩层
  • 深度学习中的激活函数:PyTorch中的ReLU及其应用
  • 人工智能学习14-Numpy-数组广播机制
  • AtCoder AT_abc410_e [ABC410E] Battles in a Row 题解
  • 如何识别并管理多项目环境下的潜在风险
  • 【Git】使用 SSH 协议 解决 Git 推送失败问题
  • 思科资料-思科交换机的常见配置(详细总结)
  • SCADA|KingSCADA对比显示任意几条实时曲线的方法
  • [特殊字符] Next.js Turbo 模式不支持 @svgr/webpack 的原因与解决方案
  • DataWhale-零基础网络爬虫技术(一)
  • 将 CSV 转换为 Shp 数据
  • 基于单片机的PT100温度变送器设计
  • CKA考试知识点分享(16)---cri-dockerd
  • 拓扑推理:把邻接矩阵和节点特征形式数据集转换为可以训练CNN等序列模型的数据集
  • 树莓派智能小车基本移动实验指导书
  • k8s使用私有harbor镜像源
  • Activiti初识
  • C/C++的OpenCV 地砖识别
  • Linux文件权限管理核心要点总结
  • 精准测量 MySQL 主从复制延迟—pt-heartbeat工具工作原理
  • 从零搭建MySQL主从复制并集成Spring Boot实现读写分离
  • Python3安装MySQL-python踩坑实录:从报错到完美解决的实战指南
  • 模块拆解:一览家政维修小程序的“功能蓝图”
  • Blender——建构、粒子、灯光、动画
  • 1.1 Linux 编译FFmpeg 4.4.1
  • import引入api报select.default is not a function异常解析
  • FreeRTOS任务优先级和中断的优先级