一、 点赞状态管理实现
在探店笔记的互动场景中,点赞功能承载着高频的状态切换与实时反馈。传统关系型架构通常依赖一张独立的tb_blog_like关联表记录user_id与blog_id的映射关系。然而,在万级并发下,频繁的INSERT与DELETE将引发严重的行锁竞争与索引碎片,拖垮数据库写入吞吐。
1.1 数据结构选型与职责划分
引入 Redis 并非为了替代数据库的持久化能力,而是通过读写分离与语义映射优化交互路径。我们采用以下职责划分策略:
- MySQL 侧:仅维护
tb_blog表中的liked(点赞总数)整型字段。摒弃关联表,以单字段聚合替代多表 Join,大幅降低存储开销与写入锁竞争。 - Redis 侧:采用
Set结构维护blog:liked:{blog_id}集合,存储已点赞的用户 ID 列表。利用 Set 天然的元素唯一性与 O(1) 时间复杂度,承载“当前用户是否已点赞”的状态校验。
1.2 点赞切换流程与时序图
点赞操作并非单纯的缓存写入,而是 Redis 状态切换与数据库总数同步的协同过程。当用户触发点赞时,系统首先通过SISMEMBER校验当前用户在 Set 中的存在性。若未存在,则执行SADD将用户 ID 加入集合,并同步执行数据库UPDATE tb_blog SET liked = liked + 1;若已存在,则执行SREM移除用户 ID,并同步执行数据库UPDATE tb_blog SET liked = liked - 1。该设计将高频的状态判断下沉至内存,将低频的总数聚合保留在关系型存储中,形成清晰的读写边界。
1.3 生产级代码实现
importloggingfromfastapiimportFastAPI,HTTPException,Depends,Requestfromsqlalchemy.ext.asyncioimportAsyncSessionfromsqlalchemyimportupdateimportredis.asyncioasaioredisfromcommon.databaseimportget_dbfromcommon.authimportget_current_user_idfrommodule_blog.modelimportBlog logger=logging.getLogger(__name__)app=FastAPI()redis_client=aioredis.Redis(host="127.0.0.1",port=6379,db=0,decode_responses=True)defget_blog_liked_key(blog_id:int)->str:returnf"blog:liked:{blog_id}"@app.post("/blog/like/{blog_id}")asyncdeftoggle_blog_like(blog_id:int,request:Request,db:AsyncSession=Depends(get_db)):user_id=request.state.user_id blog=awaitdb.get(Blog,blog_id)ifnotblog:raiseHTTPException(status_code=404,detail="Blog not found")liked_key=get_blog_liked_key(blog_id)# 1. 原子判断当前用户是否已点赞is_liked=awaitredis_client.sismember(liked_key,str(user_id))ifis_liked:# 已点赞 -> 取消:移除集合元素 + 数据库总数递减awaitredis_client.srem(liked_key,str(user_id))awaitdb.execute(update(Blog).where(Blog.id==blog_id).values(liked=Blog.liked-1))action="unlike"new_is_like=Falseelse:# 未点赞 -> 点赞:添加集合元素 + 数据库总数递增awaitredis_client.sadd(liked_key,str(user_id))awaitdb.execute(update(Blog).where(Blog.id==blog_id).values(liked=Blog.liked+1))action="like"new_is_like=Trueawaitdb.commit()return{"blog_id":blog_id,"action":action,"is_like":new_is_like,"status":"success"}该方案彻底消除了传统关联表带来的写入放大与锁竞争问题。Redis Set 仅承载高频的 O(1) 状态校验,MySQL 仅负责低频的总数聚合。在详情查询时,SISMEMBER与SCARD的组合可将状态注入延迟压缩至微秒级。若 Redis 发生抖动,系统可直接降级读取 MySQLliked字段与分页查询用户点赞记录,保障核心链路可用性,而不会引发数据丢失。
二、 点赞排行榜功能的数据类型选择
笔记详情页需展示“最早点赞的 TopN 用户”,其核心语义包含两个维度:按时间先后严格排序与用户身份唯一性。在 Redis 提供的数据结构中,List、Set 与 SortedSet 均可存储用户 ID,但在排序方式、唯一性保障与查找效率上存在本质差异。需结合业务约束进行逐层推演。
2.1 数据结构多维度对比推演
List 结构的局限性
List 底层基于双向链表实现,其排序方式严格依赖元素的添加顺序。若用户先点赞、取消后再重新点赞,LPUSH会将该用户 ID 重新插入链表头部,原始时间序被彻底打乱。其次,List 不具备唯一性约束,同一用户误触多次将导致集合中出现重复 ID。在查找方式上,List 仅支持按索引或首尾检索,无法直接通过用户 ID 定位元素位置,取消点赞需依赖LREM遍历匹配,时间复杂度退化至 O(N)。因此,List 无法满足“按时间精确排序”与“高效去重”的双重诉求。
Set 结构的局限性
Set 底层基于哈希表实现,具备严格的唯一性约束,重复添加同一用户 ID 会被自动忽略。其查找方式同样基于元素哈希,支持 O(1) 的快速存在性校验。然而,Set 的核心缺陷在于完全无法排序。哈希表的随机分布特性使得元素存储顺序与插入时间毫无关联,执行SMEMBERS仅能获取无序集合,无法直接截取 TopN 或按时间倒序排列。若强行在应用层拉取全量 Set 后排序,将引发严重的网络传输开销与内存计算压力。
SortedSet 的精准契合
SortedSet(ZSet)在 Set 的去重基础上引入 Score(分值)维度,底层采用压缩列表或跳表(SkipList)实现。其排序方式严格依据 Score 值单调递增或递减排列,完美契合“点赞时间戳”的映射需求。唯一性方面,同一用户 ID 再次ZADD仅会覆盖原有 Score,不会破坏集合基数。查找方式支持按元素直接定位,且结合ZRANK与ZRANGE可实现 O(log N + M) 的排名查询与区间截取。三者对比之下,SortedSet 是唯一能够同时承载“时间序、唯一性、高效截取”语义的结构。
| 对比维度 | List 结构 | Set 结构 | SortedSet 结构 | 业务匹配度 |
|---|---|---|---|---|
| 排序方式 | 按添加顺序排序,重复操作打乱序列 | 底层哈希表无序,无法排序 | 根据 Score 值严格单调排序 | SortedSet 唯一支持时间序 |
| 唯一性 | 不唯一,需额外逻辑防重 | 唯一,自动去重 | 唯一,覆盖旧 Score | 三者均能满足(List 需补偿) |
| 查找方式 | 按索引或首尾查找,O(N) 遍历 | 根据元素哈希查找,O(1) | 根据元素查找,O(1) 定位 | SortedSet 兼顾排序与高效查询 |
结论:SortedSet 以微小的内存开销(每个元素额外存储 8 字节 Score 与跳表指针),换取了时间复杂度从 O(N) 到 O(log N) 的跨越,是构建时间序排行榜的唯一合理选型。
三、 排行榜功能具体实现
3.1 键值对设计与时间映射
基于上述推演,排行榜的键值映射遵循“业务域:功能域:主键”规范,Key 设计为blog:rank:{blog_id}。Value 中,Member 存储user_id,Score 存储点赞发生的毫秒级时间戳(time.time())。点赞时执行ZADD插入或覆盖,取消点赞执行ZREM原子移除,查询 TopN 执行ZRANGE按 Score 降序截取。
3.2 完整实现时序图
3.3 生产级代码实现
importtimefromtypingimportList,DictfromfastapiimportAPIRouter,DependsfrompydanticimportBaseModelfromsqlalchemy.ext.asyncioimportAsyncSessionfromsqlalchemyimportselectimportredis.asyncioasaioredisfromcommon.databaseimportget_dbfrommodule_user.modelimportUserfrommodule_blog.modelimportBlog router=APIRouter()redis_client=aioredis.Redis(host="127.0.0.1",port=6379,db=0,decode_responses=True)classRankUserDTO(BaseModel):id:intnickname:stravatar:strliked_time:str@router.get("/blog/likes/{blog_id}",response_model=List[RankUserDTO])asyncdefget_blog_likes_ranking(blog_id:int,limit:int=5,db:AsyncSession=Depends(get_db)):rank_key=f"blog:rank:{blog_id}"# 1. 按 Score(时间戳)降序截取 TopNmembers_with_scores=awaitredis_client.zrange(rank_key,0,limit-1,desc=True,withscores=True)ifnotmembers_with_scores:return[]user_ids=[int(uid)foruid,_inmembers_with_scores]# 2. 批量查询用户基础信息,避免 N+1 查询问题result=awaitdb.execute(select(User).where(User.id.in_(user_ids)))user_map={u.id:uforuinresult.scalars().all()}# 3. 保持 ZRANGE 原始顺序组装响应ranking_list=[]foruid,scoreinmembers_with_scores:user=user_map.get(int(uid))ifuser:ranking_list.append(RankUserDTO(id=user.id,nickname=user.nickname,avatar=user.avatar,liked_time=time.strftime("%Y-%m-%d %H:%M",time.localtime(score))))returnranking_listSortedSet 虽能高效承载排行榜,但需注意内存膨胀风险。单篇爆款笔记的点赞数可能突破十万级,若全量保留历史时间戳将占用可观内存。生产环境通常采用“冷热分离”策略:对ZCARD超过阈值(如 10000)的 Key,通过定时任务将尾部冷数据归档至 MySQL 历史表,并执行ZREMRANGEBYRANK截断,仅保留头部热数据。点赞总数的权威值始终以 MySQLliked字段为准,Redis 排行榜仅作为高频查询的加速层,通过异步任务或 Binlog 监听保障双端最终一致。该设计在保障 TopN 毫秒级响应的同时,严格遵循了存储边界与内存治理规范。