iis7 发布静态网站,无锡建设局网站一号通,服装网络营销方案策划,公司注册地址可以变更到外省吗6.14项目一话术 Rediit1.项目整体介绍2. 核心开发框架#xff1a;Gin Zap Viper3.在 Gin 当中集成 JWT 鉴权中间件实现无状态认证4.数据存储#xff1a;sqlx MySQL5.使用 Redis 缓存用户投票数6.限流和压测 Rediit
1.项目整体介绍
我独立开发了一个名为 Bluebell 的项目… 6.14项目一话术 Rediit1.项目整体介绍2. 核心开发框架Gin Zap Viper3.在 Gin 当中集成 JWT 鉴权中间件实现无状态认证4.数据存储sqlx MySQL5.使用 Redis 缓存用户投票数6.限流和压测 Rediit
1.项目整体介绍
我独立开发了一个名为 Bluebell 的项目它是一个功能完整的社交媒体平台核心功能类似于 Reddit。用户可以在平台上注册登录、创建和浏览帖子、加入不同的社区、并对帖子进行投票。
这个项目的目标是构建一个高并发、高可用且易于维护的后端服务。为此我选择了这套以 Go 语言为核心的技术栈包括使用 Gin 框架来处理 Web 请求MySQL 作为持久化数据库Redis 作为高性能缓存并计划通过 Docker 实现容器化部署。
补充问docker
在 Bluebell 这个项目中由于目前主要是我个人在开发和本地测试所以暂时还没有走完容器化部署的最后一步。不过我对 Docker 并不陌生在我之前的实验室项目中我就有比较深入的使用经验当时主要是用 Docker 来封装和运行我们的深度学习训练任务。
通过之前的经验我认识到 Docker 的价值远不止于隔离环境和方便移植我认为 Docker 的核心价值在于它将应用及其所有依赖打包成一个标准化的、自包含的单元——也就是镜像这个标准化的单元带来了几个巨大的好处
极大地简化了部署流程运维人员不需要关心我的应用是用 Go 写的依赖什么版本的库。他们只需要 docker run 我提供的镜像就可以了为自动化运维 (CI/CD持续集成/持续交付/持续部署) 铺平了道路在现代化的开发流程中我们可以设置一个 CI/CD 管道。当我向代码仓库比如 Git提交新代码后可以自动触发构建一个新的 Docker 镜像运行自动化测试测试通过后自动将新镜像部署到服务器上高效的资源利用相比于传统的虚拟机容器非常轻量启动速度是秒级
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服务器彼此之间也不用共享信息。
这样你想加几台后端服务器都可以大家都能独立处理请求不需要“互相沟通”谁存了哪个用户的登录状态。
详细的认证流程是怎样的 登录与签发用户使用账号密码登录。服务器验证通过后会生成两个 Token一个短生命周期的 Access Token例如 2 小时和一个长生命周期的 Refresh Token例如 7 天。项目中没有重复造轮子而是使用现成的github.com/dgrijalva/jwt-go来完成 JWT 的生成。 令牌传递服务器将这两个 Token 返回给客户端。客户端在后续请求中会将 Access Token 放在 HTTP 请求的 Authorization Header 中格式为 Bearer 。 鉴权中间件我编写了一个全局的 JWT 鉴权中间件。这个中间件会拦截所有需要登录才能访问的 API 请求比如发表帖子给帖子点赞。它会 解析 Header 中的 Access Token验证其签名和有效期。 如果验证通过就从 Token 的 Payload 中解析出 用户 ID 等信息。 关键一步为了避免在后续的业务逻辑中重复解析或查询用户信息我将解析出的用户 ID 存入 Gin 的 Context 中 (c.Set(controller.CtxUserIDKey, mc.UserID))。这样后续的 Controller 或 Service 层函数就可以直接从 Context 中获取当前登录用户的身份信息非常高效。 令牌刷新机制当 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 做缓存利用其基于内存的高速读写能力来优化性能。
具体实现细节
投票数据缓存当用户对帖子进行投票时我并不是直接更新 MySQL。而是先在 Redis 中记录投票信息。我使用了 Redis 的 ZSet 和 Set。例如 KeyPostTimeZSet “post:time” 是一个帖子按照发布时间排序的 ZSet有序集合用于实现项目中的按时间排序帖子KeyPostScoreZSet “post:score” 是一个帖子按照得分排序的ZSet用于实现项目中的根据热度投票分数排序帖子KeyPostVotedZSetPrefix “post:voted:” 是一个用于记录某个帖子下哪些用户投了票以及投的什么票的ZSetKeyCommunitySetPrefix “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(KeyCommunitySetPrefixstrconv.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 保留最大的分数通常其实只有一个来源//得到一个某社区下的帖子按时间/得分排序的新 ZSetpipeline.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(KeyPostVotedZSetPrefixpostID), 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(KeyPostVotedZSetPrefixpostID), userID)} else {//如果用户之前已经使用了 ZAdd 添加相同的 Member 到 Sorted Set那么本次 ZAdd 将会把之前的 Member 和 Score 覆盖掉//也就是说如果用户之前对该帖子投了up但是这次投了down那么redis KeyPostVotedZSetPrefix就只会记录最新的pipeline.ZAdd(getRedisKey(KeyPostVotedZSetPrefixpostID), redis.Z{Score: value,Member: userID,})}_, err : pipeline.Exec()return err
}在为帖子进行投票时首先要判断帖子是否已经过了投票时间如果超时直接返回。防止“挖坟”操作让首页展示的帖子尽可能富有时效性
之后我们进一步去 Redis 的ZSetbluebell:post:voted:查找当前用户是否已经为帖子投过票如果当前用户上一次投票行为和本次相同那么禁止重复投票。避免已经点赞下次还是点赞
然后根据用户的投票行为对 Redis 中保存的帖子分数进行修改。比如 第一次点赞ov0, value1 → diff1, op1 → 加 432 分 取消点赞ov1, value0 → diff1, op-1 → 减 432 分 反对变点赞ov-1, value1 → diff2, op1 → 加 864 分
由于帖子的初始分数与创建时间相关因此不断为帖子投票可以提高帖子的分数使帖子的分数权重上升。由于我们设置每票分数为 432因此 200 张赞成票可以为帖子续一天的热度。
最后在 Redis 中记录用户本次的投票行为。
帖子排序算法首页的帖子不能简单按票数或时间排序。为了让新的、受欢迎的帖子更容易被看到我实现了一个随时间权重下降的评价系统算法灵感来源于 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} Scorelog10(∣upvotes−downvotes∣)45000sign(upvotes−downvotes)⋅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 秒