Java万级并发场景-实战解决

今天我们来做一个典型的消费力度能达到万级别的并发场景,老师点名-学生签到

正常情况

正常情况来说是不同班级下的老师发布不同的点名--然后不同班级下的很多学生同一时间进行签到,签到成功就去修改数据库,签到失败就返回,但是这样的话 签到的学生一多,数据库修改每一行的内容,都会加上行锁,那么改的多了,数据库很可能出现卡顿的情况,导致学生明明在规定时间内签到了,但是却出现签到结束的情况,或者说出现其他的冗余签到的情况,这样显然是不希望我们看到的,也不希望学生看到

并发级处理

怎么解决前面的那种签到错误的场景呢?

那么当然就是传统级别的 面对并发情况下的重拳三连了哈哈哈

mysql-redis-rabbitMq

首先 我们这个业务需要怎么写?

redis的key怎么选择,学生的key怎么选都是一个问题,下面我们来一一的进行分析

MySQL表的业务数据关联

因为我们是测试demo,所以我们只做出了关键的表结构关联,像老师表我们是没有做的

看上图,首先我们最顶部有一个课程表,写的有一个课程id和名称,还有还有学生表,学生表和课程表之间有一个中间的表关联,叫学生课程表(student-courses),然后我们老师点名的时候是属于课堂活动表,里面记录的课堂的活动,比如点名和提问,这个表(class_activities)与课程表关联,最后的是每一个学生在该课程下的做出的课堂活动,也就是学生活动表(student-activities),她关联了学生表,课堂活动表和课程表。

主要流程

老师发布点名,然后课堂互动表记录一条会过期的课堂活动,状态是进行中,然后学生签到,签到之后,找到该课程下的该签到过的学生,像学生活动表中添加一条签到过的数据

Redis业务

在redis方面,我们主要做的就是对学生签到数据的存储,对老师发布的签到数据的存储

我们知道 redis的string的数据类型是比较占用空间的,所以对于我们单个的老师发布的签到数据,我们可以用string类型,对于不同班级下的多个学生的签到情况,我们可以用hash结构 ,因为对于ihash结构,我们的数据一般是使用ziplist压缩,更省空间

RabbitMQ业务

我们mq主要做的就是读取redis中的签到过的学生数据,然后把学生数据做一个异步写入mysql,这样减缓签到高峰时段mysql的压力

我们mq首先从redis中查到签到过的学生数据,然后跟该课程下的学生数据做对比,如果该课程下学生有数据,redis中学生签到无数据,那么该学生就是未签到

如果签到,就把签到数据存入数据库

总体代码

老师点名-学生签到

package com.example.tabledemo.controller;import cn.hutool.core.date.DateTime;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.example.tabledemo.config.RabbitConfig;
import com.example.tabledemo.generator.service.ClassActivitiesService;
import com.example.tabledemo.generator.service.CourseService;
import com.example.tabledemo.pojo.Result;
import com.example.tabledemo.pojo.entity.ClassActivitiesEntity;
import com.example.tabledemo.pojo.entity.CourseEntity;
import com.example.tabledemo.pojo.request.ClassActivitiesRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.AmqpException;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessagePostProcessor;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Objects;import static cn.hutool.core.date.DateTime.now;/*** @Author: wyz* @Date: 2025-04-08-16:17* @Description:课堂活动*/
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/class/activities")
public class ClassActivitiesController {private final ClassActivitiesService classActivitiesService;private final CourseService courseService;private final StringRedisTemplate redisTemplate;private final RabbitTemplate rabbitTemplate;/*** 老师点名*/@PostMapping("/teacher/rollCall")public Result teacherRollCall(@RequestBody ClassActivitiesRequest.TeacherRollCall teacherRollCall) {//判断是否有课程CourseEntity course = courseService.getById(teacherRollCall.getCourseId());if (Objects.isNull(course)) {return Result.fail("没有该课程");}//查看该课程下是否有点名活动LambdaQueryWrapper<ClassActivitiesEntity> eq = Wrappers.lambdaQuery(ClassActivitiesEntity.class).eq(ClassActivitiesEntity::getCourseId, teacherRollCall.getCourseId()).eq(ClassActivitiesEntity::getActiveType, 1).eq(ClassActivitiesEntity::getActiveStatus, 0);ClassActivitiesEntity one = classActivitiesService.getOne(eq);if(!Objects.isNull(one)){return Result.fail("该课程已存在点名,请勿重复点名");}//生成签到码//// String signCode = RandomUtil.randomNumbers(4);String signCode = "1234";ClassActivitiesEntity classActivitiesEntity = new ClassActivitiesEntity();classActivitiesEntity.setCourseId(teacherRollCall.getCourseId());// 获取当前时间DateTime now = now();classActivitiesEntity.setStartTime(now);// 使用Calendar计算未来时间Calendar calendar = Calendar.getInstance();calendar.setTime(now);calendar.add(Calendar.SECOND, teacherRollCall.getSignSeconds());Date endTime = calendar.getTime();classActivitiesEntity.setEndTime(endTime);classActivitiesEntity.setActiveType(1);classActivitiesEntity.setActiveStatus(0);//课堂活动存入数据库boolean save = classActivitiesService.save(classActivitiesEntity);//redis中生成签到码的keyString signCodeKey = "sign_" + teacherRollCall.getCourseId() + "_" + signCode;redisTemplate.opsForValue().set(signCodeKey, signCode);//发给rabbitmq 延迟队列 让延迟队列处理 最终的签到情况//1. 学生查看课堂的活动的信息 应该在 课堂活动表中查看//2. 延迟队列处理 签到结束后的情况HashMap<Object, Object> map = new HashMap<>();map.put("course_id", teacherRollCall.getCourseId());map.put("class_activities_id", classActivitiesEntity.getId());map.put("sign_code", signCode);rabbitTemplate.convertAndSend(RabbitConfig.ROLL_CALL_DEAD_EXCHANGE, RabbitConfig.ROLL_CALL_DEAD_ROUTING_KEY, map, new MessagePostProcessor() {@Overridepublic Message postProcessMessage(Message message) throws AmqpException {message.getMessageProperties().setDelay(teacherRollCall.getSignSeconds()*1000);return message;}});return Result.success("发布签到成功",signCode);}/*** 学生签到*/@PostMapping("/student/sign")public Result studentSign(@RequestBody ClassActivitiesRequest.StudentSign studentSign) {//判断该学生是否在班级当中//这里我们不判断 知道就行String signCodeKey = "sign_" + studentSign.getCourseId() + "_" + studentSign.getSignCode();//不为空 证明有该签到String signCode = redisTemplate.opsForValue().get(signCodeKey);if (!Objects.isNull(signCode)) {if (!signCode.equals(studentSign.getSignCode())) {return Result.fail("签到码错误,签到失败");}//学生签到keyString studentSignKey="student_sign_"+studentSign.getStudentId();if(redisTemplate.opsForHash().hasKey("h"+signCodeKey,studentSignKey)){return  Result.fail("您已经签到成功,请勿重复签到");}//value正常应该是 签到时间  我们换成签到码redisTemplate.opsForHash().put("h"+signCodeKey,studentSignKey,signCode);return Result.success("签到成功");} else {return Result.fail("签到已过期或已被删除");}}
}

mq配置

package com.example.tabledemo.config;import org.springframework.amqp.core.*;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;/*** @Author: wyz* @Date: 2025-04-08-17:19* @Description:*/
@Configuration
public class RabbitConfig {@Beanpublic MessageConverter messageConverter() {// 定义消息转换器Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter();return jackson2JsonMessageConverter;}//    //点名延迟交换机
//    public static final String ROLL_CALL_EXCHANGE = "roll_call_exchange";
//    //点名延迟队列
//    public static final String ROLL_CALL_QUEUE = "roll_call_queue";//点名死信交换机public static final String ROLL_CALL_DEAD_EXCHANGE = "roll_call_dead_exchange";//点名死信队列public static final String ROLL_CALL_DEAD_QUEUE = "roll_call_dead_queue";public static final String ROLL_CALL_DEAD_ROUTING_KEY = "roll_call";/*** 绑定 点名消息队列 -> 点名私信交换机->点名私信队列** @return*/
//    @Bean
//    public Queue bindMsgDeadQueue() {
//        return QueueBuilder.durable(ROLL_CALL_QUEUE)
//                .deadLetterExchange(ROLL_CALL_DEAD_EXCHANGE)
//                .deadLetterRoutingKey(ROLL_CALL_DEAD_ROUTING_KEY)
//
//                .build();
//    }
//
//
//
//
//    /**
//     * 声明点名交换机
//     */
//    @Bean
//    Exchange rollCallExchange() {
//        return ExchangeBuilder.directExchange(ROLL_CALL_EXCHANGE)
//                .durable(true)
//                .build();
//    }
//
//    /**
//     * 绑定 点名 交换机队列
//     */
//    @Bean
//    Binding bingingRollCallExchangeQueue() {
//        return BindingBuilder.bind(bindMsgDeadQueue())
//                .to(rollCallExchange())
//                .with(ROLL_CALL_DEAD_ROUTING_KEY).noargs();
//    }/*** 声明点名死信队列*/@BeanQueue rollCallDeadQueue() {return QueueBuilder.durable(ROLL_CALL_DEAD_QUEUE).build();}/*** 声明点名 死信交换机*/@BeanExchange rollCallDeadExchange() {return ExchangeBuilder.directExchange(ROLL_CALL_DEAD_EXCHANGE).delayed().durable(true).build();}/*** 绑定点名 私信交换机队列*/@BeanBinding bindingRollCallExchangeQueue() {return BindingBuilder.bind(rollCallDeadQueue()).to(rollCallDeadExchange()).with(ROLL_CALL_DEAD_ROUTING_KEY).noargs();}}

消费者配置

package com.example.tabledemo.consumer;import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.example.tabledemo.config.RabbitConfig;
import com.example.tabledemo.generator.service.ClassActivitiesService;
import com.example.tabledemo.generator.service.StudentActivitiesService;
import com.example.tabledemo.pojo.entity.ClassActivitiesEntity;
import com.example.tabledemo.pojo.entity.StudentActivitiesEntity;
import com.example.tabledemo.student.StudentCoursesEntity;
import com.example.tabledemo.student.service.StudentCoursesService;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.GetResponse;
import lombok.extern.slf4j.Slf4j;import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.*;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;import java.io.IOException;import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;import static java.time.LocalTime.now;/*** @Author: wyz* @Date: 2025-04-08-20:40* @Description:处理学生签到的消费者*/
@Component
@Slf4j
public class SignConsumer {@Autowiredprivate StringRedisTemplate redisTemplate;@Autowiredprivate StudentCoursesService studentCoursesService;@Autowiredprivate ClassActivitiesService classActivitiesService;@Autowiredprivate StudentActivitiesService studentActivitiesService;@RabbitListener(queues = RabbitConfig.ROLL_CALL_DEAD_QUEUE)@RabbitHandler// 直接引用队列名public void studentSignConsumer(HashMap<Object, Object> map, Channel channel, Message message) throws IOException {try {log.info(now() + "----------老师点名延迟消息处理开始----------");//解析消息Integer courseId = (Integer) map.get("course_id");Integer classActivitiesId = (Integer) map.get("class_activities_id");String signCode = (String) map.get("sign_code");//业务幂等性判断ClassActivitiesEntity byId = classActivitiesService.getById(classActivitiesId);//证明已经消费过了 本来是额外存的这里 只用状态判断if(byId.getActiveStatus()==1){return;}//拿到redis中的学生签到数据String signCodeKey = "sign_" + courseId + "_" + signCode;Map<Object, Object> studentSignMap = redisTemplate.opsForHash().entries("h" + signCodeKey);//课堂活动状态改为已经结束LambdaUpdateWrapper<ClassActivitiesEntity> eq1 = Wrappers.lambdaUpdate(ClassActivitiesEntity.class).set(ClassActivitiesEntity::getActiveStatus, 1).eq(ClassActivitiesEntity::getId, classActivitiesId);classActivitiesService.update(eq1);//学生签到key//String studentSignKey="student_sign_"+studentSign.getStudentId();List<Integer> studentSignIdList = studentSignMap.entrySet().stream().map(i -> {String studentSignKey = (String) i.getKey();log.info("学生信息为{}", studentSignKey);Integer studentId = Integer.valueOf(studentSignKey.split("_")[2]);log.info("学生id为{}", studentId);return studentId;}).collect(Collectors.toList());//查出该课程下 的所有学生idLambdaQueryWrapper<StudentCoursesEntity> eq = Wrappers.lambdaQuery(StudentCoursesEntity.class).eq(StudentCoursesEntity::getCourseId, courseId);List<StudentCoursesEntity> list = studentCoursesService.list(eq);List<Integer> studentIds = list.stream().map(i -> i.getStudentId()).collect(Collectors.toList());//正常是 会有课程状态 课程结课什么的 ,这里我们模拟 不做处理ArrayList<StudentActivitiesEntity> studentActivitiesEntities = new ArrayList<>();studentIds.stream().forEach(studentId -> {StudentActivitiesEntity studentActivitiesEntity = new StudentActivitiesEntity();studentActivitiesEntity.setStudentId(studentId);studentActivitiesEntity.setClassActivitiesId(classActivitiesId);studentActivitiesEntity.setCourseId(courseId);studentActivitiesEntity.setStudentActivitiesStatus(0);if (studentSignIdList.contains(studentId)) {log.info("有学生签到了");studentActivitiesEntity.setStudentActivitiesStatus(1);}studentActivitiesEntities.add(studentActivitiesEntity);});//构建学生活动表的数据studentActivitiesService.saveBatch(studentActivitiesEntities);//删除redis数据redisTemplate.delete(signCodeKey);redisTemplate.delete("h" + signCodeKey);//true 和false 代表着 是否 确认该条消息之前的  true 是确认  false 不确认// 假设队列中有消息 deliveryTag=5,6,7  现在是6// 结果:仅消息6被确认删除,消息5和7仍在队列中channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);log.info(now() + "----------老师点名延迟消息处理结束----------");} catch (Exception e) {Boolean redelivered = message.getMessageProperties().getRedelivered();if (redelivered) {log.info(now() + "----------老师点名延迟消息处理异常,已被重新投递,丢弃消息----------");channel.basicReject(message.getMessageProperties().getDeliveryTag(), false);} else {log.info(now() + "----------老师点名延迟消息处理异常,消息重新投递----------");channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);}throw e;}}
}

测试流程

接口测试

jmeter 压测

数据库数据查看 

可见 已经测试成功了 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/bicheng/76851.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

openGauss新特性 | 自动参数化执行计划缓存

目录 自动化参数执行计划缓存简介 SQL参数化及约束条件 一般常量参数化示例 总结 自动化参数执行计划缓存简介 执行计划缓存用于减少执行计划的生成次数。openGauss数据库会缓存之前生成的执行计划&#xff0c;以便在下次执行该SQL时直接使用&#xff0c;可…

计算机操作系统——存储器管理

系列文章目录 1.存储器的层次结构 2.程序的装入和链接 3.连续分配存储管理方式&#xff08;内存够用&#xff09; 4.对换&#xff08;Swapping&#xff09;(内存不够用) 5.分页存储管理方式 6.分段存储管理方式 文章目录 系列文章目录前言一、存储器的存储结构寄存器&…

KF V.S. GM-PHD

在计算机视觉的多目标跟踪&#xff08;MOT&#xff09;任务中&#xff0c;卡尔曼滤波&#xff08;KF&#xff09;和高斯混合概率假设密度&#xff08;GM-PHD&#xff09;滤波器是两种经典的状态估计方法&#xff0c;但它们的原理和应用场景存在显著差异。以下是两者的核心机制和…

车载通信架构 --- DOIP系统机制初入门

我是穿拖鞋的汉子,魔都中坚持长期主义的汽车电子工程师。 老规矩,分享一段喜欢的文字,避免自己成为高知识低文化的工程师: 周末洗了一个澡,换了一身衣服,出了门却不知道去哪儿,不知道去找谁,漫无目的走着,大概这就是成年人最深的孤独吧! 旧人不知我近况,新人不知我过…

C++对象池设计:从高频`new/delete`到性能飞跃的工业级解决方案

一、new/delete的性能之殇&#xff1a;一个真实的生产事故 2023年某证券交易系统在峰值时段出现请求堆积&#xff0c;事后定位发现&#xff1a;每秒40万次的订单对象创建/销毁&#xff0c;导致&#xff1a; 内存碎片率高达37%&#xff08;jemalloc统计&#xff09;malloc调用…

【C/C++】深入理解整型截断与提升:原理、应用与区别

文章目录 1. 整形截断&#xff08;Integer Truncation&#xff09;1.1 整形截断的例子1.2 整形截断的细节 2. 整形提升&#xff08;Integer Promotion&#xff09;2.1 整形提升的规则2.2 整形提升的示例2.3 整形提升的实际应用2.4 整型提升与标准操作符 3. 整型截断与提升的区别…

python蓝桥杯备赛常用算法模板

一、python基础 &#xff08;一&#xff09;集合操作 s1 {1,2,3} s2{3,4,5} print(s1|s2)#求并集 print(s1&s2)#求交集 #结果 #{1, 2, 3, 4, 5} #{3}&#xff08;二&#xff09;对多维列表排序 1.新建列表 list1[[1,2,3],[2,3,4],[0,3,2]] #提取每个小列表的下标为2的…

【模块化拆解与多视角信息3】教育背景:学历通胀时代的生存法则

教育背景:学历通胀时代的生存法则 写在最前 作为一个中古程序猿,我有很多自己想做的事情,比如埋头苦干手搓一个低代码数据库设计平台(目前只针对写java的朋友),比如很喜欢帮身边的朋友看看简历,讲讲面试技巧,毕竟工作这么多年,也做到过高管,有很多面人经历,意见还算…

uniapp实现H5页面麦克风权限获取与录音功能

1.权限配置 在uni-app开发H5页面时&#xff0c;需要在manifest.json文件中添加录音权限的配置。具体如下&#xff1a; {"h5": {"permissions": {"scope.record": {"desc": "请授权使用录音功能"}}} }这段配置代码是用于向…

功能丰富的PDF处理免费软件推荐

软件介绍 今天给大家介绍一款超棒的PDF工具箱&#xff0c;它处理PDF文档的能力超强&#xff0c;而且是完全免费使用的&#xff0c;没有任何限制。 TinyTools&#xff08;PC&#xff09;这款软件&#xff0c;下载完成后即可直接打开使用。在使用过程中&#xff0c;操作完毕后&a…

鸿蒙开发-ArkUi控件使用

2.0控件-按钮 2.1.控件-文本框 Text(this.message).fontSize(40) // 设置文本的文字大小.fontWeight(FontWeight.Bolder) // 设置文本的粗细.fontColor(Color.Red) // 设置文本的颜色------------------------------------------------------------------------- //设置边框Tex…

深入理解 ResponseBodyAdvice 及其应用

ResponseBodyAdvice 是 Spring MVC 提供的一个强大接口&#xff0c;允许你在响应体被写入 HTTP 响应之前对其进行全局处理。 下面我将全面介绍它的工作原理、使用场景和最佳实践。 基本概念 接口定义 public interface ResponseBodyAdvice<T> {boolean supports(Metho…

深度解析Redis过期字段清理机制:从源码到集群化实践 (一)

深度解析Redis过期字段清理机制&#xff1a;从源码到集群化实践 一、问题本质与架构设计 1.1 过期数据管理的核心挑战 Redis连接池时序图技术方案 ​​设计规范&#xff1a;​ #mermaid-svg-Yr9fBwszePgHNnEQ {font-family:"trebuchet ms",verdana,arial,sans-se…

数据库ocm有什么用

专业能力的权威象征 。技术水平的高度认可&#xff1a;OCM 是 Oracle 认证体系中的最高级别&#xff0c;代表着持证人在 Oracle 数据库领域具备深厚的专业知识和卓越的实践技能。它证明持证人能够熟练掌握数据库的安装、配置、管理、优化、备份恢复等核心技术&#xff0c;并且能…

无人船 | 图解基于视线引导(LOS)的无人艇制导算法

目录 1 视线引导法介绍2 LOS制导原理推导3 Lyapunov稳定性分析4 LOS制导效果 1 视线引导法介绍 视线引导法&#xff08;Line of Sight, LOS&#xff09;作为无人水面艇&#xff08;USV&#xff09;自主导航领域的核心技术&#xff0c;通过几何制导与动态控制深度融合的机制&am…

Swift观察机制新突破:如何用AsyncSequence实现原子化数据监听?

网罗开发 &#xff08;小红书、快手、视频号同名&#xff09; 大家好&#xff0c;我是 展菲&#xff0c;目前在上市企业从事人工智能项目研发管理工作&#xff0c;平时热衷于分享各种编程领域的软硬技能知识以及前沿技术&#xff0c;包括iOS、前端、Harmony OS、Java、Python等…

【KWDB创作者计划】_KWDB部署与使用详细版本

KWDB发展历程 介绍KWDB前&#xff0c;先介绍下KaiwuDB&#xff0c; KaiwuDB 是浪潮控股的数据库企业&#xff0c;该企业提供的KaiwuDB数据库是一款分布式多模数据库产品&#xff0c;主要面向工业物联网、数字能源、车联网、智慧产业等行业领域。 在2024年7月&#xff0c; Kai…

Go:接口

接口既约定 Go 语言中接口是抽象类型 &#xff0c;与具体类型不同 &#xff0c;不暴露数据布局、内部结构及基本操作 &#xff0c;仅提供一些方法 &#xff0c;拿到接口类型的值 &#xff0c;只能知道它能做什么 &#xff0c;即提供了哪些方法 。 func Fprintf(w io.Writer, …

一、Appium环境安装

找了一圈操作手机的工具或软件&#xff0c;踩了好多坑&#xff0c;最后决定用这个工具(影刀RPA手机用的也是这个)&#xff0c;目前最新的版本是v2.17.1&#xff0c;是基于nodejs环境的&#xff0c;有两种方式&#xff0c;我只试了第一种方式&#xff0c;第二种方式应该是比较简…

【玩转全栈】—— Django 连接 vue3 保姆级教程,前后端分离式项目2025年4月最新!!!

本文基于之前的一个旅游网站&#xff0c;实现 Django 连接 vue3&#xff0c;使 vue3 能携带 CSRF Token 发送 axios 请求给后端&#xff0c;后端再响应数据给前端。想要源码直接滑倒底部。 目录 实现效果 解决跨域 获取 csrf-token 什么是 csrf-token &#xff1f; CSRF攻击的…