并发场景下的点赞功能设计与实现

2,221 阅读6分钟

并发场景下的点赞业务实现

本文基于redis、lua脚本、策略模式、工厂模式实现了并发场景下的用户点赞、收藏、转发、浏览数据的获取与更新。

考虑因素

数据存储:为了避免频繁地访问数据库,可以使用缓存技术,将点赞量存储在缓存中。每次用户点赞时,首先将点赞量从缓存中读取,然后对其进行修改,最后再将修改后的点赞量写回缓存。

分布式锁:在高并发场景下,很容易出现多个用户同时对同一篇文章进行点赞的情况。为了避免出现数据不一致的情况,需要利用redis来保证同一时间只有一个用户能够对同一篇资讯进行点赞操作。

异步处理:如果在每次点赞时都需要对数据库进行更新操作,那么对数据库的访问压力将非常大。为了避免这种情况,可以使用异步处理的方式,将点赞信息先存储到消息队列中,然后再由后台异步任务对数据库进行更新操作。

数据持久化:以文章点赞业务为例,需要进行持久化的数据包含3个模块:

  1. 用户与文章之间的点赞状态记录
  2. 文章的点赞量
  3. 用户点赞的文章列表

实现

依赖导入

基于Spring Boot 2.7.15,我们导入以下依赖包。

     <dependencies>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-actuator</artifactId>
         </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-data-redis</artifactId>
         </dependency>
         <dependency>
            <groupId>org.springframework.kafka</groupId>
            <artifactId>spring-kafka</artifactId>
         </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-web</artifactId>
         </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-configuration-processor</artifactId>
             <optional>true</optional>
         </dependency>
         <dependency>
             <groupId>org.projectlombok</groupId>
             <artifactId>lombok</artifactId>
             <optional>true</optional>
         </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-test</artifactId>
             <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>com.alibaba</groupId>
             <artifactId>fastjson</artifactId>
             <version>1.2.83</version>
         </dependency>
     </dependencies>

参数配置

 spring:
   application:
     name: 'shop'
   redis:
     host: 127.0.0.1
     port: 6712
     lettuce:
       pool:
         max-active: 8
         max-idle: 8
 server:
   port: 8081
   tomcat:
     threads:
       max: 200
     accept-count: 100
     max-connections: 8192  

Lua脚本注入

 package com.example.shop.config;
 ​
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.io.ClassPathResource;
 import org.springframework.core.io.Resource;
 import org.springframework.data.redis.core.script.RedisScript;
 ​
 @Configuration
 public class ScriptConfig {
     @Bean
     public RedisScript<Void> setUserLike() {
         Resource script = new ClassPathResource("script/setUserAction.lua");
         return RedisScript.of(script, Void.class);
     }
 ​
     @Bean
     public RedisScript<String> getUserLike() {
         Resource script = new ClassPathResource("script/getUserAction.lua");
         return RedisScript.of(script, String.class);
     }
 }

setUserAction.lua

 redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
 local status = tonumber(ARGV[2])
 if status == 1 then
     redis.call('INCR', KEYS[2])
 else
     redis.call("DECR", KEYS[2])
 end

getUserAction.lua

 local statusAndCount = {}
 local likeStatus = 'info:like:status:'
 local likeCount = 'info:like:count:'
 statusAndCount['likeStatus'] = tonumber(redis.call('HGET', likeStatus .. KEYS[1], KEYS[2]))
 statusAndCount['likeCount'] = tonumber(redis.call('GET', likeCount .. KEYS[2]))
 return cjson.encode(statusAndCount)

基于策略工厂模式生成点赞策略

  1. 定义行为策略接口
package com.cit.lab.action.server.strategy.useraction;

import com.cit.lab.action.server.dto.ActionDTO;
import com.cit.lab.action.server.enums.UserActionEnum;
import com.cit.lab.action.server.exception.DuplicateException;
import com.cit.lab.action.server.exception.ParamException;

/**
 * @Author: Richard
 * @Description: 行为处理策略
 * @CreateDate: 2023/4/5 00:44
 * @UpdateUser: Richard
 * @UpdateDate: 2023/4/5 00:44
 * @UpdateRemark: 更新说明
 * @Version: 1.0
 */
public interface ActionStrategy {
    default void checkStatus(String status, Object cache) {
        if (cache == null && "0".equals(status)) {
            throw new ParamException("参数异常");
        }
        if (cache != null && status.equals(String.valueOf(cache))) {
            throw new DuplicateException("重复操作!");
        }
    }

    default void checkScanStatus(String status, Object cache) {
        if ("0".equals(status)) {
            if (cache == null) {
                throw new ParamException("参数异常");
            }
            if (status.equals(String.valueOf(cache))) {
                throw new DuplicateException("重复操作!");
            }
        }
    }

    default ActionDTO buildActionDTO(String userId, String infoId, String status, UserActionEnum actionEnum) {
        return ActionDTO.builder()
                .id(Math.abs(userId.hashCode()))
                .userId(userId)
                .infoId(infoId)
                .status(status)
                .actionEnum(actionEnum)
                .build();
    }

    boolean doAction(String userId, String infoId, String status);
}
  1. 创建行为策略生成工厂
package com.cit.lab.action.server.strategy.useraction;

import com.cit.lab.action.server.enums.ActionStrategyEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Map;

/**
 * @Author: Richard
 * @Description: 行为策略工厂
 * @CreateDate: 2023/4/5 00:58
 * @UpdateUser: Richard
 * @UpdateDate: 2023/4/5 00:58
 * @UpdateRemark: 更新说明
 * @Version: 1.0
 */
@Slf4j
@Component
public class ActionStrategyFactory {

    /**
     * 通过Spring容器的方式注入
     */
    @Resource
    private Map<String, ActionStrategy> actionStrategyMap;

    /**
     * 获取对应行为策略类
     *
     * @param actionStrategyEnum 行为策略枚举
     */
    public ActionStrategy getActionStrategy(ActionStrategyEnum actionStrategyEnum) {

        if (!actionStrategyMap.containsKey(actionStrategyEnum.getBeanName())) {
            log.info("没有对应的行为策略,无法进行操作");
            return null;
        }

        return actionStrategyMap.get(actionStrategyEnum.getBeanName());
    }
}
  1. 实现一个点赞策略
package com.cit.lab.action.server.strategy.useraction.action;


import com.cit.lab.action.server.dto.ActionDTO;
import com.cit.lab.action.server.enums.UserActionEnum;
import com.cit.lab.action.server.strategy.useraction.ActionStrategy;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Arrays;

import static com.cit.lab.action.server.constant.RedisKeyConstant.REDIS_LIKE_COUNT;
import static com.cit.lab.action.server.constant.RedisKeyConstant.REDIS_LIKE_STATUS;

/**
 * @Author: Richard
 * @Description: 点赞数据处理策略
 * @CreateDate: 2023/4/5 01:10
 * @UpdateUser: Richard
 * @UpdateDate: 2023/4/5 01:10
 * @UpdateRemark: 更新说明
 * @Version: 1.0
 */
@Slf4j
@Component("likeStrategy")
public class LikeStrategy implements ActionStrategy {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private KafkaTemplate kafkaTemplate;
    @Resource
    private RedisScript<Void> setAction;
    @Value(value = "${kafka.action.topic}")
    private String actionTopic;
    @Resource
    private ObjectMapper objectMapper;

    @Override
    public boolean doAction(String userId, String infoId, String status) {
        Object cache = stringRedisTemplate.opsForHash().get(REDIS_LIKE_STATUS + userId, infoId);
        checkStatus(status, cache);
        stringRedisTemplate.execute(setAction, Arrays.asList(REDIS_LIKE_STATUS + userId, REDIS_LIKE_COUNT + infoId),
                infoId, status);
        log.info("Update like userId {} infoId {}, status {}, count {}!", userId, infoId, status,
                stringRedisTemplate.opsForValue().get(REDIS_LIKE_COUNT + infoId));
        ActionDTO actionDTO = buildActionDTO(userId, infoId, status, UserActionEnum.LIKE);
        try {
            kafkaTemplate.send(actionTopic, objectMapper.writeValueAsString(actionDTO));
        } catch (Exception e) {
            log.error("fail to send like action with action dto: {}", actionDTO, e);
            return false;
        }
        return true;
    }
}
  1. 创建策略执行上下文
package com.cit.lab.action.server.strategy.useraction;

import com.cit.lab.action.server.enums.ActionStrategyEnum;
import lombok.Builder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Objects;

/**
 * @Author: Richard
 * @Description: 策略执行上下文
 * @CreateDate: 2023/4/5 01:10
 * @UpdateUser: Richard
 * @UpdateDate: 2023/4/5 01:10
 * @UpdateRemark: 更新说明
 * @Version: 1.0
 */
@Builder
@Component
@Slf4j
public class ActionContext {

    @Resource
    private ActionStrategyFactory actionStrategyFactory;


    public boolean doStrategy(String userId, String infoId, String status, Integer type) {
        ActionStrategyEnum actionStrategyEnum = ActionStrategyEnum.getByType(type);
        if (Objects.isNull(actionStrategyEnum)) {
            log.info("cannot find matched action strategy with type {}", type);
            return false;
        }
        ActionStrategy actionStrategy = actionStrategyFactory.getActionStrategy(actionStrategyEnum);
        return actionStrategy.doAction(userId, infoId, status);
    }
}

用户行为数据处理业务层接口设计与实现

  1. 设计用户行为数据处理接口
package com.cit.lab.action.server.service;

import com.cit.lab.action.server.dto.ActionDTO;
import com.cit.lab.api.action.clientobject.ActionDetailCO;

/**
 * @Author: Richard
 * @Description: 用户行为数据处理接口
 * @CreateDate: 2023/4/5 19:59
 * @UpdateUser: Richard
 * @UpdateDate: 2023/4/5 19:59
 * @UpdateRemark: 更新说明
 * @Version: 1.0
 */
public interface UserActionService {
    ActionDetailCO doAction(String userId, String infoId, String status, Integer type);

    /**
     * 处理行为数据
     *
     * @param actionDTO 行为数据
     */
    void solveRetry(ActionDTO actionDTO);

    /**
     * 核对请求数与处理数
     */
    String checkRes();
}
  1. 实现用户行为数据处理接口
package com.cit.lab.action.server.service.impl;

import com.alibaba.fastjson.JSON;
import com.cit.lab.action.server.dto.ActionDTO;
import com.cit.lab.action.server.exception.ConcurrentException;
import com.cit.lab.action.server.service.UserActionService;
import com.cit.lab.action.server.strategy.useraction.ActionContext;
import com.cit.lab.api.action.clientobject.ActionDetailCO;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Arrays;
import java.util.concurrent.atomic.AtomicLong;

/**
 * @Author: Richard
 * @Description: 用户行为数据处理业务处理实现
 * @CreateDate: 2023/4/5 20:00
 * @UpdateUser: Richard
 * @UpdateDate: 2023/4/5 20:00
 * @UpdateRemark: 更新说明
 * @Version: 1.0
 */
@Slf4j
@Service
@Getter
public class UserActionServiceImpl implements UserActionService {
    private final StringRedisTemplate stringRedisTemplate;
    private final RedisScript<String> getAction;
    private final ActionContext context;
    private AtomicLong send = new AtomicLong();
    private AtomicLong received = new AtomicLong();

    @Autowired
    public UserActionServiceImpl(StringRedisTemplate stringRedisTemplate, RedisScript<String> getAction, ActionContext context) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.getAction = getAction;
        this.context = context;
    }

    @Transactional(rollbackFor = RuntimeException.class)
    @Override
    public ActionDetailCO doAction(String userId, String infoId, String status, Integer type) {
        boolean success = context.doStrategy(userId, infoId, status, type);
        if (!success) {
            throw new ConcurrentException("当前服务正忙,请稍后再试");
        }
        send.incrementAndGet();
        String result = stringRedisTemplate.execute(getAction, Arrays.asList(userId, infoId));
        return JSON.parseObject(result, ActionDetailCO.class);
    }

    @Override
    public void solveRetry(ActionDTO actionDTO) {
        log.info("sava action {}", JSON.toJSON(actionDTO));
        received.incrementAndGet();
    }

    public String checkRes() {
        return "received/send:".concat(received.toString().concat("/").concat(send.toString()));
    }
}

开放Web 服务 API

package com.cit.lab.action.server.controller;

import com.alibaba.fastjson.JSON;
import com.cit.basic.dto.Result;
import com.cit.lab.action.server.exception.ConcurrentException;
import com.cit.lab.action.server.exception.DuplicateException;
import com.cit.lab.action.server.exception.ParamException;
import com.cit.lab.action.server.service.UserActionService;
import com.cit.lab.api.action.client.UserActionAPI;
import com.cit.lab.api.action.clientobject.ActionDetailCO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicInteger;


/**
 * @Author: Richard
 * @Description: UserActionController
 * @CreateDate: 2023/4/3 22:54
 * @UpdateUser: Richard
 * @UpdateDate: 2023/4/3 22:54
 * @UpdateRemark: 更新说明
 * @Version: 1.0
 */
@Slf4j
@RestController
@RequestMapping("/lab/action")
public class UserActionController implements UserActionAPI {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private RedisScript<String> getUserAction;
    @Resource
    private UserActionService userActionService;
    private AtomicInteger integer = new AtomicInteger();

    @GetMapping("/doAction")
    public Object doAction(@RequestParam(name = "infoId") String infoId,
                           @RequestParam(name = "userId") String userId,
                           @RequestParam(name = "status") String status,
                           @RequestParam(name = "type", defaultValue = "1") Integer type) {
        try {
            if (!"1".equals(status) && !"0".equals(status)) {
                return Result.ofFail("参数异常");
            }
            if (type != 0 && type != 1 && type != 2 && type != 3) {
                return Result.ofFail("参数异常");
            }
            infoId = "1";
            status = "1";
            userId = Integer.toString(integer.incrementAndGet());
            ActionDetailCO actionDetailCO = userActionService.doAction(userId, infoId, status, type);
            return Result.ofSuccess(actionDetailCO);
        } catch (DuplicateException e) {
            log.error("Duplicate action with userId {}, infoId {}, status {}", userId, infoId, status, e);
            return Result.ofFail(e.getMsg());
        } catch (ConcurrentException e) {
            log.error("Fail to get lock with userId {}, infoId {}, status {}", userId, infoId, status, e);
            return Result.ofFail(e.getMsg());
        } catch (ParamException e) {
            log.error("Fail to do action with wrong params userId {}, infoId {}, status {}", userId, infoId, status, e);
            return Result.ofFail(e.getMsg());
        } catch (Exception e) {
            log.error("Fail to do action userId {}, infoId {}, status {}", userId, infoId, status, e);
            return Result.ofFail();
        }
    }

    @GetMapping("/getActionDetail")
    public Object getStatus(@RequestParam(name = "userId") String userId,
                            @RequestParam(name = "infoId") String infoId) {
        try {
            String result = stringRedisTemplate.execute(getUserAction, Arrays.asList(userId, infoId));
            return Result.ofSuccess(JSON.parseObject(result, ActionDetailCO.class));
        } catch (Exception e) {
            log.error("Fail to get action detail with userId {}, infoId {}!", userId, infoId);
            return Result.ofFail();
        }
    }

    @GetMapping("/checkRes")
    public Object checkRes() {
        return userActionService.checkRes();
    }
}