阅读、点赞、收藏功能

阅读、点赞、收藏

需求分析

  • 阅读数量在点击文章之后就会+1,在退出文章后也不会-1
  • 点赞和收藏数既可以+1,也可以-1
  • 有时收藏的内容会放在收藏夹,取消收藏则要移除收藏夹

另一方面,对于一个平台,不论是文章、视频还是评论,这些对象都会有阅读、点赞、收藏等属性字段。这也就意味着,我们提供的接口不能只限制应用于文章,而是一个面向任何资源都能使用的阅读、点赞、收藏功能接口

更进一步地,需要分析阅读、点赞、收藏这三者之间的界限。实际可以考虑一下三个方面:

  • 场景
  • 性能
  • 研发

阅读数功能设计与实现

设计一个InteractService接口,该接口提供一个IncReadCnt方法,每当调用阅读接口时,就是该方法将对象的阅读数字段+1。

考虑极端情况,如果是第一次对某个对象调用IncReadCnt方法,那么此时数据库中是没有该记录的,需要进行insert操作,而第二次、第三次调用时则是进行update操作。由此可见,IncReadCnt方法应该实现upsert操作:有记录则更新read_cnt字段,没有记录则插入一条记录

复习一下GORM实现upsert操作

dao.db.WithContext(ctx).Clauses(clause.OnConflict{
    DoUpdates: clause.Assignments(map[string]any{
        // 直接使用 `read_cnt` = `read_cnt` + 1
        // 保持并发安全
        "read_cnt": gorm.Expr("read_cnt + 1"),
        "utime": time.Now().UnixMilli(),
    }).Create(&Interactive{
        // 略
        ...
    })
})

在需求分析阶段,我们得出结论:应该提供一个面向任何资源的阅读、点赞、收藏功能接口。既然如此,就得有区分不同资源的字段。一种很常见的方法就是采用biz + bizId来唯一标识某个特定业务下的一个对象

类似的设计有:

  • biz + bizId
  • resource type + resourceId
  • request type + requestId

缓存设计

在任何一个平台,阅读量、点赞、收藏都是高频访问数据。因此需要做好缓存,防止超高的QPS压垮数据库

这里选择使用Redis的Hashes结构,一个key中同时保存read_cnt,like_cnt,collect_cnt字段

为什么不是三个(广义上)Redis分别保存三个字段的属性值?🤔

一个原因是:阅读、点赞、收藏这三个字段经常一同出现,如果用一个key保存,那么只需要进行一次Redis查询就能拿到三个字段值

另一个原因是:如果使用三个Redis保存,那么为了确保读取三个Redis操作的原子性,就必须使用lua编写复杂脚本(而且性能可能会有一定损失🧐)

枚举使用缓存可能发生的场景:

  • 没有Key
  • 有Key且有相应的字段
  • 有Key但没有相应的字段

幸运地是,Redis提供了一个HCINRBY api(详见下图),可以有效解决后两种情况

image-20241009142111996

对于情况一:我们就需要事先判断一下Redi中的key是否存在,如果存在就把对应的字段+1。这是一个典型的check-do something场景,可以考虑使用lua脚本确保没有并发问题

注意事项

⚠️:存储对象中的string类型默认对应着MySQL中的BLOB/TEXT类型,需要用tag明确指定varchar类型

image-20241011192816540

先更新数据库,再更新缓存

image-20241012151321298

点赞功能设计与实现

需求分析

点赞事实上分成两个部分:

  • 保留某个用户是否点赞过,并且用户可以取消点赞
  • 统计点赞数量

统计点赞数量和统计阅读数量的思路是一样的,不过需要额外记录用户是否对某个资源已经点过赞

image-20241012142803292

功能实现

为了简化前端代码,可以将点赞和取消点赞转发至同一个接口上,通过一个like字段进行区分

  • true,那么是点赞
  • false,那么是取消点赞

并在Handler中根据like值,调用不同的方法

软删除

涉及到用户可以撤销某个行为的场景(例如本处就是取消点赞),通常有两种操作数据的方式

  • 硬删除:直接将数据库中的记录删除
  • 软删除:将记录中的status字段设置成2
    • 通常为了避免零值问题,人为规定status = 1标识记录存在,status = 2标识记录被删除
image-20241012143649799

相较于软删除,硬删除会产生很多空洞,影响MySQL性能。换句话说,软删除的性能更好

同样地,在DAO层面,对于点赞操作,不能确定是第一次点赞还会再次点赞。因此需要采用upsert操作

image-20241011201326778

收藏功能设计与实现

这里需要引入收藏夹的概念

总体上就是两张表:

  • 收藏夹本体:也就是收藏夹本身,以及其归属的用户
  • 收藏夹和资源的关联关系

image-20241013101806634

收藏夹中会有多个项目,因此收藏夹和收藏夹中的内容是1:N的关系

具体实现和阅读数、点赞数类似,都是要upsert语义,以及collect_cnt = collect_cnt + 1

查询接口

参考竞品:

image-20241013111021697

在前端做展示一片文章、视频等资源时,需要将资源本体、阅读数、点赞数、收藏数等一同显示

因为在实现阅读、点赞、收藏时,将这些字段视为了一个独立的领域,所以需要提供两个接口分别对数据和资源进行查询,并在Web层面进行聚合。另一种可行的方案就是将数据作为文章的子对象,那么就可以在Service层进行聚合

考虑到需要进行查询多个表的操作,为了提高性能,可以使用errgroup来并发查询数据和资源本体

具体Web层面实现如下:

```



```go
func (h *ArticleHandler) PubDetail(ctx *gin.Context, c jwt.UserClaims, req ArticleReq) (ginx.Result, error) {
	uid := c.UserId
	var eg errgroup.Group
	var art domain.Article
	eg.Go(func() error {
		var err error
		art, err = h.svc.PubDetail(ctx, req.BizId)
		if err != nil {
			h.l.Error("获取文章失败", zap.Error(err))
		}
		return err
	})
	var intr domain.Interact
	eg.Go(func() error {
		var err error
		intr, err = h.interactSvc.Get(ctx, req.Biz, req.BizId, uid)
		if err != nil {
			h.l.Error("获取文章数据失败", zap.Error(err))
		}
		return err
	})
	err := eg.Wait()
	if err != nil {
		return ginx.Result{}, err
	}
	go func() {
		var err error
		err = h.interactSvc.IncReadCnt(ctx, req.Biz, req.BizId)
		if err != nil {
			h.l.Error("增加阅读数失败", zap.Error(err))
		}
	}()
	return ginx.Result{
		Data: ArticleVO{
			Id:         art.Id,
			Title:      art.Title,
			Content:    art.Content,
			AuthorName: art.Author.Name,
			ReadCnt:    intr.ReadCnt,
			LikeCnt:    intr.LikeCnt,
			CollectCnt: intr.CollectCnt,
			Liked:      intr.Liked,
			Collected:  intr.Collected,
			Ctime:      time.UnixMilli(art.Ctime).Format(time.DateTime),
			Utime:      time.UnixMilli(art.Utime).Format(time.DateTime),
		},
	}, nil
}

⚠️:

  • 方法不直接修改参数,而是通过返回值间接修改参数

  • 返回值的类型选择:

    • 如果指针实现了接口,那就返回指针

    • 如果返回值很大,避免传递引发复制,那就返回指针

  • 简易原则:

    • 接收器永远用指针
    • 输入输出都用结构体
  • 不是所有结构体都是可比较的

功能实现小结

  • 大量使用事物,保证在操作两张表时保持ACID特性
  • 大量使用Upsert语义,因为很多情况下我们不知道表中是否有对应的数据
  • 大量使用lua脚本来保证缓存中的数据是正确的,但是无法彻底解决缓存一致性问题
  • 在查询接口这个呢,只缓存总数数据,并不会缓存个人是否点赞、是否收藏的数据
  • 缓存一致性虽然很重要,但不是所有的场景都必须维护。另一方面,需要彻底解决缓存一致性问题的场景,就不应该使用缓存