领导问我:为什么一个点赞功能你做了五天?

53,528 阅读14分钟

公众号:【可乐前端】,每天3分钟学习一个优秀的开源项目,分享web面试与实战知识。

前言

可乐是一名前端切图仔,最近他们团队需要做一个文章社区平台。由于人手不够,前后端部分都是由前端同学来实现,后端部分用的技术栈是 nest.js

某一个周一,领导希望做一个给文章点赞的功能,在文章列表页与文章详情页需要看到该文章的点赞总数,以及当前登录的用户有没有对该文章点赞,即用户与文章的点赞关系。

交代完之后,领导就去出差了。等领导回来时已是周五,他问可乐:这期的需求进展如何?

可乐回答:点赞的需求我做完了,其他的还没开始。

领导生气的说:为什么点赞这样的一个小功能你做了五天才做完???

可乐回答:领导息怒。。请听我细细道来

往期文章

仓库地址

初步设计

对于上面的这个需求,我们提炼出来有三点最为重要的功能:

  1. 获取点赞总数
  2. 获取用户的点赞关系
  3. 点赞/取消点赞

所以这里容易想到的是在文章表中冗余一个点赞数量字段 likes ,查询文章的时候一起把点赞总数带出来。

idcontentlikes
1文章A10
2文章B20

然后建一张 article_lile_relation 表,建立文章点赞与用户之间的关联关系。

idarticle_iduser_idvalue
1100120011
2100120020

上面的数据就表明了 id2001 的用户点赞了 id1001 的文章; id2002 的用户对 id1001 的文章取消了点赞。

这是对于这种关联关系需求最容易想到的、也是成本不高的解决方案,但在仔细思考了一番之后,我放弃了这种方案。原因如下:

  1. 由于首页文章流中也需要展示用户的点赞关系,这里获取点赞关系需要根据当前文章 id 、用户 id 去联表查询,会增加数据库的查询压力。
  2. 有关于点赞的信息存放在两张表中,需要维护两张表的数据一致性。
  3. 后续可能会出现对摸鱼帖子点赞、对用户点赞、对评论点赞等需求,这样的设计方案显然拓展性不强,后续再做别的点赞需求时可能会出现大量的重复代码。

基于上面的考虑,准备设计一个通用的点赞模块,以拓展后续各种业务的点赞需求。

表设计

首先来一张通用的点赞表, DDL 语句如下:

CREATE TABLE `like_records` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) DEFAULT NULL,
  `target_id` int(11) DEFAULT NULL,
  `type` int(4) DEFAULT NULL,
  `created_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
  `value` int(4) DEFAULT '0',
  PRIMARY KEY (`id`),
  KEY `like_records_target_id_IDX` (`target_id`,`user_id`,`type`) USING BTREE,
  KEY `like_records_user_id_IDX` (`user_id`,`target_id`,`type`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;

解释一下上面各个字段的含义:

  • id :点赞记录的主键 id
  • user_id :点赞用户的 id
  • target_id :被点赞的文章 id
  • type :点赞类型:可能有文章、帖子、评论等
  • value :是否点赞, 1 点赞, 0 取消点赞
  • created_time :创建时间
  • updated_time :更新时间

前置知识

在设计好数据表之后,再来捋清楚这个业务的一些特定属性与具体实现方式:

  1. 我们可以理解这是一个相对来说读比写多的需求,比如你看了 10 篇掘金的文章,可能只会对 1 篇文章点赞
  2. 应该设计一个通用的点赞模块,以供后续各种点赞需求的接入
  3. 点赞数量与点赞关系需要频繁地获取,所以需要读缓存而不是读数据库
  4. 写入数据库与同步缓存需考虑数据一致性

所以可乐针对这样的业务特性上网查找了一些资料,发现有一些前置知识是他所欠缺的,我们一起来看看。

mysql事务

mysql 的事务是指一系列的数据库操作,这些操作要么全部成功执行,要么全部失败回滚。事务是用来确保数据库的完整性、一致性和持久性的机制之一。

mysql 中,事务具有以下四个特性,通常缩写为 ACID

  1. 原子性: 事务是原子性的,这意味着事务中的所有操作要么全部成功执行,要么全部失败回滚。

  2. 一致性: 事务执行后,数据库从一个一致的状态转换到另一个一致的状态。这意味着事务执行后,数据库中的数据必须满足所有的约束、触发器和规则,保持数据的完整性。

  3. 隔离性: 隔离性指的是多个事务之间的相互独立性。即使有多个事务同时对数据库进行操作,它们之间也不会相互影响,每个事务都感觉到自己在独立地操作数据库。 mysql 通过不同的隔离级别(如读未提交、读已提交、可重复读和串行化)来控制事务之间的隔离程度。

  4. 持久性: 持久性指的是一旦事务被提交,对数据库的改变将永久保存,即使系统崩溃也不会丢失。 mysql 通过将事务的提交写入日志文件来保证持久性,以便在系统崩溃后能够恢复数据。

这里以商品下单创建订单并扣除库存为例,演示一下 nest+typeorm 中的事务如何使用:

import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { Order } from './order.entity';
import { Product } from './product.entity';

@Injectable()
export class OrderService {
  constructor(
    @InjectEntityManager()
    private readonly entityManager: EntityManager,
  ) {}

  async createOrderAndDeductStock(productId: number, quantity: number): Promise<Order> {
    return await this.entityManager.transaction(async transactionalEntityManager => {
      // 查找产品并检查库存是否充足
      const product = await transactionalEntityManager.findOne(Product, productId);
      if (!product || product.stock < quantity) {
        throw new Error('Product not found or insufficient stock');
      }

      // 创建订单
      const order = new Order();
      order.productId = productId;
      order.quantity = quantity;
      await transactionalEntityManager.save(order);

      // 扣除库存
      product.stock -= quantity;
      await transactionalEntityManager.save(product);

      return order;
    });
  }
}

this.entityManager.transaction 创建了一个事务,在异步函数中,如果发生错误, typeorm 会自动回滚事务;如果没有发生错误,typeorm 会自动提交事务。

在这个实例中,尝试获取库存并创建订单和减库存,如果任何一个地方出错异常抛出,则事务就会回滚,这样就保证了多表间数据的一致性。

分布式锁

分布式锁是一种用于在分布式系统中协调多个节点并保护共享资源的机制。在分布式系统中,由于涉及多个节点并发访问共享资源,因此需要一种机制来确保在任何给定时间只有一个节点能够访问或修改共享资源,以防止数据不一致或竞争条件的发生。

对于同一个用户对同一篇文章频繁的点赞/取消点赞请求,可以加分布式锁的机制,来规避一些问题:

  1. 防止竞态条件: 点赞/取消点赞操作涉及到查询数据库、更新数据库和更新缓存等多个步骤,如果不加锁,可能会导致竞态条件,造成数据不一致或错误的结果。
  2. 保证操作的原子性: 使用分布式锁可以确保点赞/取消点赞操作的原子性,即在同一时间同一用户只有一个请求能够执行操作,从而避免操作被中断或不完整的情况发生。
  3. 控制并发访问: 加锁可以有效地控制并发访问,限制了频繁点击发送请求的数量,从而减少系统负载和提高系统稳定性。

redis 中实现分布式锁通常使用的是基于 SETNX 命令和 EXPIRE 命令的方式:

  1. 使用 SETNX 命令尝试将 lockKey 设置为 lockValue ,如果 lockKey 不存在,则设置成功并返回 1;如果 lockKey 已经存在,则设置失败并返回 0
  2. 如果 SETNX 成功,说明当前客户端获得了锁,可以执行相应的操作;如果 SETNX 失败,则说明锁已经被其他客户端占用,当前客户端需要等待一段时间后重新尝试获取锁。
  3. 为了避免锁被永久占用,可以使用 EXPIRE 命令为锁设置一个过期时间,确保即使获取锁的客户端在执行操作时发生故障,锁也会在一定时间后自动释放。
  async getLock(key: string) {
    const res = await this.redis.setnx(key, 'lock');
    if (res) {
        // 10秒锁过期
        await this.redis.expire(key, 10);
    }
    return res;
  }

  async unLock(key: string) {
    return this.del(key);
  }

redis中的set结构

redis 中的 set 是一种无序集合,用于存储多个不重复的字符串值,set 中的每个成员都是唯一的。

我们存储点赞关系的时候,需要用到 redis 中的 set 结构,存储的 keyvalue 如下:

article_1001:[uid1,uid2,uid3]

这就表示文章 id1001 的文章,有用户 iduid1uid2uid3 这三个用户点赞了。

常用的 set 结构操作命令包括:

  • SADD key member [member ...]: 将一个或多个成员加入到集合中。
  • SMEMBERS key: 返回集合中的所有成员。
  • SISMEMBER key member: 检查成员是否是集合的成员。
  • SCARD key: 返回集合元素的数量。
  • SREM key member [member ...]: 移除集合中一个或多个成员。
  • SPOP key [count]: 随机移除并返回集合中的一个或多个元素。
  • SRANDMEMBER key [count]: 随机返回集合中的一个或多个元素,不会从集合中移除元素。
  • SUNION key [key ...]: 返回给定所有集合的并集。
  • SINTER key [key ...]: 返回给定所有集合的交集。
  • SDIFF key [key ...]: 返回给定所有集合的差集。

下面举几个点赞场景的例子

  1. 当用户 iduid1 给文章 id1001 的文章点赞时:sadd 1001 uid1
  2. 当用户 iduid1 给文章 id1001 的文章取消点赞时:srem 1001 uid1
  3. 当需要获取文章 id1001 的点赞数量时:scard 1001

redis事务

redis 中,事务是一组命令的有序序列,这些命令在执行时会被当做一个单独的操作来执行。即事务中的所有命令要么全部执行成功,要么全部执行失败,不存在部分执行的情况。

以下是 redis 事务的主要命令:

  1. MULTI: 开启事务,在执行 MULTI 命令后,后续输入多个命令来组成一个事务。
  2. EXEC: 执行事务,在执行 EXEC 命令时,redis 会执行客户端输入的所有事务命令,如果事务中的所有命令都执行成功,则事务执行成功,返回事务中所有命令的执行结果;如果事务中的某个命令执行失败,则事务执行失败,返回空。
  3. DISCARD: 取消事务,在执行 DISCARD 命令时,redis 会取消当前事务中的所有命令,事务中的命令不会被执行。
  4. WATCH: 监视键,在执行 WATCH 命令时,redis 会监听一个或多个键,如果在执行事务期间任何被监视的键被修改,事务将会被打断。

比如说下面的代码给集合增加元素,并更新集合的过期时间,可以如下使用 redis 的事务去执行它:

  const pipeline = this.redisService.multi();
  const setKey = this.getSetKey(targetId, type);
  if (value === ELike.LIKE) {
    pipeline.sadd(setKey, userId);
  } else {
    pipeline.srem(setKey, userId);
  }
  pipeline.expire(setKey, this.ttl);
  await pipeline.exec();

流程图设计

在了解完这些前置知识之后,可乐开始画一些实现的流程图。

首先是点赞/取消点赞接口的流程图:

image.png

简单解释下上面的流程图:

  1. 先尝试获取锁,获取不到的时候等待重试,保证接口与数据的时序一致。
  2. 判断这个点赞关系是否已存在,比如说用户对这篇文章已经点过赞,其实又来了一个对此篇文章点赞的请求,直接返回失败
  3. 开启 mysql 的事务,去更新点赞信息表,同时尝试去更新缓存,在缓存更新的过程中,会有3次的失败重试机会,如果缓存更新都失败,则回滚mysql事务;整体更新失败
  4. mysql 更新成功,缓存也更新成功,则整个操作都成功

然后是获取点赞数量和点赞关系的接口

image.png

简单解释下上面的流程图:

  1. 首先判断当前文章 id 对应的点赞关系是否在 redis 中存在,如果存在,则直接从缓存中读取并返回
  2. 如果不存在,此时加锁,准备读取数据库并更新 redis ,这里加锁的主要目的是防止大量的请求一下子打到数据库中。
  3. 由于加锁的时候,可能很多接口已经在等待,所以在锁释放的时候,再加多一次从 redis 中获取的操作,此时 redis 中已经有值,可以直接从缓存中读取。

代码实现

在所有的设计完毕之后,可以做最后的代码实现了。分别来实现点赞操作与点赞数量接口。这里主要关注 service 层的实现即可。

点赞/取消点赞接口

  async toggleLike(params: {
    userId: number;
    targetId: number;
    type: ELikeType;
    value: ELike;
  }) {
    const { userId, targetId, type, value } = params;
    const LOCK_KEY = `${userId}::${targetId}::${type}::toggleLikeLock`;
    const canGetLock = await this.redisService.getLock(LOCK_KEY);
    if (!canGetLock) {
      console.log('获取锁失败');
      await wait();
      return this.toggleLike(params);
    }
    const record = await this.likeRepository.findOne({
      where: { userId, targetId, type },
    });
    if (record && record.value === value) {
      await this.redisService.unLock(LOCK_KEY);
      throw Error('不可重复操作');
    }

    await this.entityManager.transaction(async (transactionalEntityManager) => {
      if (!record) {
        const likeEntity = new LikeEntity();
        likeEntity.targetId = targetId;
        likeEntity.type = type;
        likeEntity.userId = userId;
        likeEntity.value = value;
        await transactionalEntityManager.save(likeEntity);
      } else {
        const id = record.id;
        await transactionalEntityManager.update(LikeEntity, { id }, { value });
      }
      const isSuccess = await this.tryToFreshCache(params);

      if (!isSuccess) {
        await this.redisService.unLock(LOCK_KEY);
        throw Error('操作失败');
      }
    });
    await this.redisService.unLock(LOCK_KEY);
    return true;
  }
  
private async tryToFreshCache(
    params: {
      userId: number;
      targetId: number;
      type: ELikeType;
      value: ELike;
    },
    retry = 3,
  ) {
    if (retry === 0) {
      return false;
    }
    const { targetId, type, value, userId } = params;
    try {
      const pipeline = this.redisService.multi();
      const setKey = this.getSetKey(targetId, type);
      if (value === ELike.LIKE) {
        pipeline.sadd(setKey, userId);
      } else {
        pipeline.srem(setKey, userId);
      }
      pipeline.expire(setKey, this.ttl);
      await pipeline.exec();
      return true;
    } catch (error) {
      console.log('tryToFreshCache error', error);
      await wait();
      return this.tryToFreshCache(params, retry - 1);
    }
  }

可以参照流程图来看这部分实现代码,基本实现就是使用 mysql 事务去更新点赞信息表,然后去更新 redis 中的点赞信息,如果更新失败则回滚事务,保证数据的一致性。

获取点赞数量、点赞关系接口

  async getLikes(params: {
    targetId: number;
    type: ELikeType;
    userId: number;
  }) {
    const { targetId, type, userId } = params;
    const setKey = this.getSetKey(targetId, type);
    const cacheExsit = await this.redisService.exist(setKey);
    if (!cacheExsit) {
      await this.getLikeFromDbAndSetCache(params);
    }
    const count = await this.redisService.getSetLength(setKey);
    const isLike = await this.redisService.isMemberOfSet(setKey, userId);
    return { count, isLike };
  }

  private async getLikeFromDbAndSetCache(params: {
    targetId: number;
    type: ELikeType;
    userId: number;
  }) {
    const { targetId, type, userId } = params;
    const LOCK_KEY = `${targetId}::${type}::getLikesLock`;
    const canGetLock = await this.redisService.getLock(LOCK_KEY);
    if (!canGetLock) {
      console.log('获取锁失败');
      await wait();
      return this.getLikeFromDbAndSetCache(params);
    }
    const setKey = this.getSetKey(targetId, type);
    const cacheExsit = await this.redisService.exist(setKey);
    if (cacheExsit) {
      await this.redisService.unLock(LOCK_KEY);
      return true;
    }
    const data = await this.likeRepository.find({
      where: {
        targetId,
        userId,
        type,
        value: ELike.LIKE,
      },
      select: ['userId'],
    });
    if (data.length !== 0) {
      await this.redisService.setAdd(
        setKey,
        data.map((item) => item.userId),
        this.ttl,
      );
    }
    await this.redisService.unLock(LOCK_KEY);
    return true;
  }

由于读操作相当频繁,所以这里应当多使用缓存,少查询数据库。读点赞信息时,先查 redis 中有没有,如果没有,则从 mysql 同步到 redis 中,同步的过程中也使用到了分布式锁,防止一开始没缓存时请求大量打到 mysql

同时,如果所有文章的点赞信息都同时存在 redis 中,那 redis 的存储压力会比较大,所以这里会给相关的 key 设置一个过期时间。当用户重新操作点赞时,会更新这个过期时间。保障缓存的数据都是相对热点的数据。

通过组装数据,获取点赞信息的返回数据结构如下:

image.png

返回一个 map ,其中 key 文章 idvalue 里面是该文章的点赞数量以及当前用户是否点赞了这篇文章。

前端实现

文章流列表发生变化的时候,可以监听列表的变化,然后去获取点赞的信息:

useEffect(() => {
    if (!article.list) {
      return;
    }
    const shouldGetLikeIds = article.list
      .filter((item: any) => !item.likeInfo)
      .map((item: any) => item.id);
    if (shouldGetLikeIds.length === 0) {
      return;
    }
    console.log("shouldGetLikeIds", shouldGetLikeIds);
    getLikes({
      targetIds: shouldGetLikeIds,
      type: 1,
    }).then((res) => {
      const map = res.data;
      const newList = [...article.list];
      for (let i = 0; i < newList.length; i++) {
        if (!newList[i].likeInfo && map[newList[i].id]) {
          newList[i].likeInfo = map[newList[i].id];
        }
      }
      const newArticle = { ...article };
      newArticle.list = newList;
      setArticle(newArticle);
    });
  }, [article]);

image.png

点赞操作的时候前端也需要加锁,接口执行完毕了再把锁释放。

   <Space
    onClick={(e) => {
      e.stopPropagation();
      if (lockMap.current?.[item.id]) {
        return;
      }
      lockMap.current[item.id] = true;
      const oldValue = item.likeInfo.isLike;
      const newValue = !oldValue;
      const updateValue = (value: any) => {
        const newArticle = { ...article };
        const newList = [...newArticle.list];
        const current = newList.find(
          (_) => _.id === item.id
        );
        current.likeInfo.isLike = value;
        if (value) {
          current.likeInfo.count++;
        } else {
          current.likeInfo.count--;
        }
        setArticle(newArticle);
      };
      updateValue(newValue);
      toggleLike({
        targetId: item.id,
        value: Number(newValue),
        type: 1,
      })
        .catch(() => {
          updateValue(oldValue);
        })
        .finally(() => {
          lockMap.current[item.id] = false;
        });
    }}
  >
    <LikeOutlined
      style={
        item.likeInfo.isLike ? { color: "#1677ff" } : {}
      }
    />
    {item.likeInfo.count}
  </Space>

Kapture 2024-03-23 at 22.49.08.gif

解释

可乐:从需求分析考虑、然后研究网上的方案并学习前置知识,再是一些环境的安装,最后才是前后端代码的实现,领导,我这花了五天不过份吧。

领导(十分无语):我们平台本来就没几个用户、没几篇文章,本来就是一张关联表就能解决的问题,你又搞什么分布式锁又搞什么缓存,还花了那么多天时间。我不管啊,剩下没做的需求你得正常把它正常做完上线,今天周五,周末你也别休息了,过来加班吧。

最后

以上就是本文的全部内容,如果你觉得有意思的话,点点关注点点赞吧~