并发场景下的点赞业务实现
本文基于redis、lua脚本、策略模式、工厂模式实现了并发场景下的用户点赞、收藏、转发、浏览数据的获取与更新。
考虑因素
数据存储:为了避免频繁地访问数据库,可以使用缓存技术,将点赞量存储在缓存中。每次用户点赞时,首先将点赞量从缓存中读取,然后对其进行修改,最后再将修改后的点赞量写回缓存。
分布式锁:在高并发场景下,很容易出现多个用户同时对同一篇文章进行点赞的情况。为了避免出现数据不一致的情况,需要利用redis来保证同一时间只有一个用户能够对同一篇资讯进行点赞操作。
异步处理:如果在每次点赞时都需要对数据库进行更新操作,那么对数据库的访问压力将非常大。为了避免这种情况,可以使用异步处理的方式,将点赞信息先存储到消息队列中,然后再由后台异步任务对数据库进行更新操作。
数据持久化:以文章点赞业务为例,需要进行持久化的数据包含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]) endgetUserAction.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)
基于策略工厂模式生成点赞策略
- 定义行为策略接口
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);
}
- 创建行为策略生成工厂
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());
}
}
- 实现一个点赞策略
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;
}
}
- 创建策略执行上下文
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);
}
}
用户行为数据处理业务层接口设计与实现
- 设计用户行为数据处理接口
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();
}
- 实现用户行为数据处理接口
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();
}
}