​ 作为一名开发人员,要知道我们每天都会面对很多场景,我们要想着如何去解决它们,因此,开展此专栏主要是想记录一下我日常开发遇到的一些场景以及和各种大佬交流时谈及的场景,以期日后如果能遇到类似场景,能够快速定位问题。

如果某天我不更新的,两种可能:我无所不能了/我无了

这几篇老文章都是我之前用飞书云文档写的,因转换格式问题,本文之前已写完的文档图片太多了,不想一一转化了,大家可以访问我的飞书云文档来查看这些图片–https://miu7shl031o.feishu.cn/drive/folder/WJiBfqX9klvKNEdpTHccfrEQn3e

基于Redis实现短信验证码的登录并解决登陆状态刷新的问题。

1.1 设计key的结构

​ 我们需要保存在redis中的数据一共有两种,第一种是验证码,第二种是用户信息。那么针对这两种不同的信息,我们应该分别设计怎样的key呢?Redis中的key应该满足两点:

  1. 唯一性
  2. 方便携带

​ 针对验证码,我们可以用手机号来做key,这样的话就可以很好的保证key的唯一性

​ 针对用户信息,我们同样可以使用手机号作为key,但是有一个问题需要考虑,就是此时我们已经不用session进行用户校验了,那么服务器在做登录拦截时使用什么作为校验凭证呢?最好的方案就是使用redis中用户信息的key,前端在访问时通过访问头携带key来访问,如果通过key能在redis中找到数据,说明用户已登录。那么这种情况下我们最好不要使用手机号作为key,这毕竟属于用户比较隐私的信息,我们在后台生成一个随机串token,用这个token来作为key就比较合适了。

1.2 设计整体访问流程

  1. 发送短信验证码:将手机号作为key,生成的验证码作为value存到redis中,等登录的时候,再去校验验证码是否一致。
  2. 验证码登录、注册:将用户发过来的手机号和验证码进行校验,查询redis对应的验证码,检验是否和发送过来的验证码是否一致,如果一致查询是否存在用户信息,如果不存在则去创建用户信息,最后将该用户信息存入redis中,以token作为key,用户信息作为value。为了后续的校验登录状态。
  3. 校验登录状态:根据请求携带的token去redis查询对应的用户信息,如果没有则拦截,如果有则保存到Threadlocal中,并且放行。

代码实现:

  • UserController层:
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone);
}

/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm){
// 实现登录功能
return userService.login(loginForm);
}

/**
* 获取当前登录的用户信息
* @return
*/
@GetMapping("/me")
public Result me(){
// 通过ThreadLocal获取当前登录的用户信息并返回
return Result.ok(UserHolder.getUser());
}
  • 新建 LoginInterceptor拦截器
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {

private StringRedisTemplate redisTemplate;

//这里stringRedisTemplate并不能直接在ioc容器中获取,因为本类并没有交给spring容器管理。但是MvcConfig会创造本类的对象,我们只需要通过构造器让MvcConfig传入即可
public RefreshTokenInterceptor(StringRedisTemplate redisTemplate){
this.redisTemplate = redisTemplate;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

String uri = request.getRequestURI();
log.info("拦截到请求:{}",uri);

//前端是通过请求头"authorization"携带token令牌的,我们需要先判断token令牌是否携带
String token = request.getHeader("authorization");

//判断token是否为空
if(StrUtil.isBlank(token)){
//token为空直接返回
log.info("用户未登录,请求已被拦截:{}",uri);
response.setStatus(401);
return false;
}

//组装key
String key = RedisConstants.LOGIN_USER_KEY + token;

//通过key获取redis中的用户信息
Map<Object, Object> map = redisTemplate.opsForHash().entries(key);

if(map == null||map.size()==0){
//map为null说明令牌是瞎编的,直接返回
log.info("key不正确,请求已被拦截:{}",uri);
response.setStatus(401);
return true;
}

//将map集合转化为userDto对象
UserDTO user = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);

//保存用户信息到ThreadLocal
UserHolder.saveUser(user);

//刷新token有效期,用户每访问一次服务器都需要刷新一次token有效期,避免用户在一直活跃的情况下令牌失效
redisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

log.info("用户已登录,id为{}",user.getId());

return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//在请求结束后销毁ThreadLocal中的用户信息
UserHolder.removeUser();
}
}
  • 新建MvcConfig配置类,让拦截器生效
@Configuration
public class MvcConfig implements WebMvcConfigurer {

@Autowired
private StringRedisTemplate stringRedisTemplate;

@Override
public void addInterceptors(InterceptorRegistry registry) {
//在创建对象时将stringRedisTemplate传给拦截器使用
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/user/login",
"/user/code",
"/shop-type/**",
"/upload/**"
);
}
}
  • UserService层
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

@Autowired
private StringRedisTemplate redisTemplate;

/**
* 获取验证码
* @param phone
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
//校验手机号是否正确
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式不正确");
}

//生成验证码
String code = RandomUtil.randomNumbers(6);

//将验证码存放在redis中
//这里使用String类型,key使用固定前缀+手机号码,值为验证码,并设置有效期为两分钟
redisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY+phone,code,RedisConstants.LOGIN_CODE_TTL,TimeUnit.MINUTES);

//发送短信验证码成功
log.info("短信验证码发送成功:{}",code);

return Result.ok();
}


/**
* 登录验证
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();

//从redis中获取验证码
String code = redisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY+phone);

//判断手机号是否为空
if(phone == null){
return Result.fail("手机号不能为空");
}

//判断验证码是否为空
if(code == null){
return Result.fail("验证码不能为空");
}

//校验手机号是否合法
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式不正确");
}

//判断验证码是否正确
if(!code.equals(loginForm.getCode())){
return Result.fail("验证码不正确");
}

//判断用户是否存在
User user = query().eq("phone", phone).one();
if(user == null){
log.info("用户不存在,创建新用户:{}",phone);
//调用创建用户方法
user = createNewUser(phone);
}

//使用redis保存用户信息

//这里使用uuid随机生成redis的key
String userToken = UUID.randomUUID().toString(true);

//这里为了避免不同业务的key冲突,给key加上前缀
String userTokenKey = RedisConstants.LOGIN_USER_KEY+userToken;

//使用Hash类型存储用户信息,存储数据前,需要先将对象转换成map集合
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);

//转换成map集合的过程中还需要做处理,因为StringRedisTemplate只能只针对字符串进行序列化,因此我们要将userDTO中每个 属性都转换成字符串
Map<String, Object> map = BeanUtil.beanToMap(
userDTO,
new HashMap<>(),
CopyOptions.create() //自定义拷贝选项
.ignoreNullValue() //允许属性为null
.setFieldValueEditor((fileName,fileValue)->fileValue.toString()) //对属性值进行编辑,把所有属性值转换成字符串
);

//保存数据到redis
redisTemplate.opsForHash().putAll(userTokenKey,map);

//设置redis有效期
redisTemplate.expire(userTokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

//这里需要将token信息返回给前端,前端需要token令牌来访问
return Result.ok(userToken);
}

/**
*
* @param phone
* @return
*/
public User createNewUser(String phone){
User user = new User();
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
user.setPhone(phone);
save(user);
return user;
}

}

1.3 解决状态登录刷新问题

初始方案思路总结:

在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的

优化方案

既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

代码实现:

  • 新建一个RefreshTokenInterceptor拦截器,负责刷新令牌和保存用户信息等工作
@Slf4j
public class RefreshTokenInterceptor implements HandlerInterceptor {

private StringRedisTemplate redisTemplate;

//这里stringRedisTemplate并不能直接在ioc容器中获取,因为本类并没有交给spring容器管理。但是MvcConfig会创造本类的对象,我们只需要通过构造器让MvcConfig传入即可
public RefreshTokenInterceptor(StringRedisTemplate redisTemplate){
this.redisTemplate = redisTemplate;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
log.info("RefreshTokenInterceptor拦截到请求:{}",uri);

//前端是通过请求头"authorization"携带token令牌的,我们需要先判断token令牌是否携带
String token = request.getHeader("authorization");

//判断token是否为空
if(StrUtil.isBlank(token)){
//令牌不存在是不需要刷新的,直接放给下一个拦截器处理
log.info("令牌不存在,RefreshTokenInterceptor已放行请求:{}",uri);
return true;
}

//组装key
String key = RedisConstants.LOGIN_USER_KEY + token;

//通过key获取redis中的用户信息
Map<Object, Object> map = redisTemplate.opsForHash().entries(key);

if(map == null||map.size()==0){
//用户不存在的话也不需要刷新令牌,直接放行给下一个拦截器处理
log.info("令牌不存在,RefreshTokenInterceptor已放行请求:{}",uri);
return true;
}

//将map集合转化为userDto对象
UserDTO user = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);

//保存用户信息到ThreadLocal
UserHolder.saveUser(user);

//刷新token有效期
redisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

log.info("用户已登录,RefreshTokenInterceptor已放行请求,用户为{}",user.getId());

return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//在请求结束后销毁ThreadLocal中的用户信息
UserHolder.removeUser();
}
}
  • 修改LoginInterceptor的代码,因为很多工作我们已经在RefreshTokenInterceptor中做了,因此在LoginInterceptor我们只需要判断ThreadLocal中有没有用户信息即可
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

String uri = request.getRequestURI();
log.info("LoginInterceptor拦截到请求:{}",uri);

if(UserHolder.getUser() == null){
//说明用户未登录,直接拦截
log.info("用户未登录,LoginInterceptor未放行请求{}",uri);
response.setStatus(401);
return false;
}

log.info("LoginInterceptor已放行请求:{}",uri);
//说明用户已登录,直接放行
return true;
}
}
  • 还需要在MvcConfig中修改拦截器配置
@Configuration
public class MvcConfig implements WebMvcConfigurer {

@Autowired
private StringRedisTemplate stringRedisTemplate;

// 配置拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 添加拦截器并排除不需要拦截的路径,即不用登录也可以访问的页面
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/user/login",
"/user/code",
"/shop-type/**",
"/upload/**"
).order(1);//
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
}
}

Mongodb实现评论的功能

为什么用mongdb存储内推信息的评论

1.文档存储模型:MongoDB是一个面向文档的数据库,这意味着它存储数据的方式更接近于JSON格

式。对于文章评论,这意味着可以将评论作为一个整体文档进行存储,而不需要像关系型数据库那样将其分解为多个表和字段。这种灵活性使得MongoDB在处理复杂的数据结构时更加方便。

2.动态模式:MongoDB不需要预先定义数据结构,可以存储不同格式的文档。这对于文章评论来说非常有用,因为不同的评论可能有不同的字段和格式。相比之下,MySQL等传统关系型数据库需要预先定义表结构,这可能会限制灵活性。

3.水平扩展:MongoDB的分布式架构使其能够轻松地在多个服务器之间进行数据分区和复制,从而实现水平扩展。这对于处理大量文章评论非常有用,因为可以很容易地增加更多的服务器来处理更高的负载。而MySQL等传统关系型数据库在扩展方面可能面临更多的挑战。

4.查询性能:MongoDB的查询性能通常优于传统关系型数据库,尤其是在处理大量数据时。MongoDB

使用BSON(Binary JSON)格式存储数据,这使得它能够更高效地执行复杂查询和索引操作。这对于

文章评论系统来说非常重要,因为它们通常需要支持高效的查询和搜索功能。

5.实时更新:MongoDB支持实时更新和插入操作,这使得它非常适合处理实时评论。相比之下,传

统关系型数据库可能需要在更新数据时执行更多的锁定和事务操作,这可能会影响性能:

​ 当然,MongoDB也有其局限性,例如在处理复杂的事务和连接操作方面可能不如MySQL等传统关系型数据库。因此,在选择数据库时,需要根据具体需求和场景进行权衡。

  1. 表结构

img

2.使用技术SpringDataMongoDB

SpringData家族成员之一,用于操作MongoDB的持久层框架,封装了底层的mongodb-driver。

3.搭建工程,pom.xml引入依赖:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.6.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>cn.itcast</groupId>
<artifactId>article</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
</dependencies>
</project>

4.application.yml中配置mongodb

spring:
#数据源配置
data:
mongodb:
# 主机地址
host: 192.168.40.141
# 数据库
database: articledb
# 默认端口是27017
port: 27017
#也可以使用uri连接
#uri: mongodb://192.168.40.134:27017/

5.文章评论实体类的编写(根据数据库表生成)

package cn.itcast.article.po;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Date;
/**
* 文章评论实体类
*/
//把一个java类声明为mongodb的文档,可以通过collection参数指定这个类对应的文档。
//@Document(collection="mongodb 对应 collection 名")
// 若未加 @Document ,该 bean save 到 mongo 的 comment collection
// 若添加 @Document ,则 save 到 comment collection
@Document(collection="comment")//可以省略,如果省略,则默认使用类名小写映射集合
//复合索引
// @CompoundIndex( def = "{'userid': 1, 'nickname': -1}")
public class Comment implements Serializable {
//主键标识,该属性的值会自动对应mongodb的主键字段"_id",如果该属性名就叫“id”,则该注解可以省略,否则必须写
@Id
private String id;//主键
//该属性对应mongodb的字段的名字,如果一致,则无需该注解
@Field("content")
private String content;//吐槽内容
private Date publishtime;//发布日期
//添加了一个单字段的索引
@Indexed
private String userid;//发布人ID
private String nickname;//昵称
private LocalDateTime createdatetime;//评论的日期时间
private Integer likenum;//点赞数
private Integer replynum;//回复数
private String state;//状态
private String parentid;//上级ID
private String articleid;
//getter and setter.....
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public Date getPublishtime() {
return publishtime;
}
public void setPublishtime(Date publishtime) {
this.publishtime = publishtime;
}
public String getUserid() {
return userid;
}
public void setUserid(String userid) {
this.userid = userid;
}
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public LocalDateTime getCreatedatetime() {
return createdatetime;
}
public void setCreatedatetime(LocalDateTime createdatetime) {
this.createdatetime = createdatetime;
}
public Integer getLikenum() {
return likenum;
}
public void setLikenum(Integer likenum) {
this.likenum = likenum;
}
public Integer getReplynum() {
return replynum;
}
public void setReplynum(Integer replynum) {
this.replynum = replynum;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
public String getParentid() {
return parentid;
}
public void setParentid(String parentid) {
this.parentid = parentid;
}
public String getArticleid() {
return articleid;
}
public void setArticleid(String articleid) {
this.articleid = articleid;
}
@Override
public String toString() {
return "Comment{" +
"id='" + id + '\'' +
", content='" + content + '\'' +
", publishtime=" + publishtime +
", userid='" + userid + '\'' +
", nickname='" + nickname + '\'' +
", createdatetime=" + createdatetime +
", likenum=" + likenum +
", replynum=" + replynum +
", state='" + state + '\'' +
", parentid='" + parentid + '\'' +
", articleid='" + articleid + '\'' +
'}';
}
}

6.为合适字段建立索引

索引可以大大提升查询效率,一般在查询字段上添加索引,索引的添加可以通过Mongo的命令来添加,也可以在Java的实体类中通过注解添加。

1)单字段索引注解@Indexed

org.springframework.data.mongodb.core.index.Indexed.class

声明该字段需要索引,建索引可以大大的提高查询效率。

db.comment.createIndex({"userid":1})

2)复合索引注解@CompoundIndex

org.springframework.data.mongodb.core.index.CompoundIndex.class

复合索引的声明,建复合索引可以有效地提高多字段的查询效率。

Mongo命令参考:

db.comment.createIndex({"userid":1,"nickname":-1})

7.创建controller、sevice、mapper层

mapper层的CommentRepository要继承自MongoRepository并指定实体类和id

package cn.itcast.article.dao;
import cn.itcast.article.po.Comment;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.mongodb.repository.MongoRepository;
//评论的持久层接口
public interface CommentRepository extends MongoRepository<Comment,String> {
}

service里面的评论的增删改查方法

package cn.itcast.article.service;
import cn.itcast.article.dao.CommentRepository;
import cn.itcast.article.po.Comment;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
//评论的业务层
@Service
public class CommentService {
//注入dao
@Autowired
private CommentRepository commentRepository;
/**
* 保存一个评论
* @param comment
*/
public void saveComment(Comment comment){
//如果需要自定义主键,可以在这里指定主键;如果不指定主键,MongoDB会自动生成主键
//设置一些默认初始值。。。
//调用dao
commentRepository.save(comment);
}
/**
* 更新评论
* @param comment
*/
public void updateComment(Comment comment){
//调用dao
commentRepository.save(comment);
}
/**
* 根据id删除评论
* @param id
*/
public void deleteCommentById(String id){
//调用dao
commentRepository.deleteById(id);
}
/**
* 查询所有评论
* @return
*/
public List<Comment> findCommentList(){
//调用dao
return commentRepository.findAll();
}
/**
* 根据id查询评论
* @param id
* @return
*/
public Comment findCommentById(String id){
//调用dao
return commentRepository.findById(id).get();
}
}

8.根据上级ID查询内推评论的分页列表

  1. CommentRepository新增方法定义

//根据父id,查询子评论的分页列表
Page<Comment> findByParentid(String parentid, Pageable pageable);
  1. CommentService新增方法

/**
* 根据父id查询分页列表
* @param parentid
* @param page
* @param size
* @return
*/
public Page<Comment> findCommentListPageByParentid(String parentid,int page ,int size){
return commentRepository.findByParentid(parentid, PageRequest.of(page-1,size));
}

9.MongoTemplate实现评论点赞

我们一开始用的是根据id查出来评论的点赞数,然后+1,但是后面觉得这么做效率太低了

/**
* 点赞-效率低
* @param id
*/
public void updateCommentThumbupToIncrementingOld(String id){
Comment comment = CommentRepository.findById(id).get();
comment.setLikenum(comment.getLikenum()+1);
CommentRepository.save(comment);
}

所以后面用MongoTemplate类来实现对某列的操作从而进行了优化

@Autowired
private MongoTemplate mongoTemplate;
/**
* 点赞数+1
* @param id
*/
public void updateCommentLikenum(String id){
//查询对象
Query query=Query.query(Criteria.where("_id").is(id));
//更新对象
Update update=new Update();
//局部更新,相当于$set
// update.set(key,value)
//递增$inc
// update.inc("likenum",1);
update.inc("likenum");
//参数1:查询对象
//参数2:更新对象
//参数3:集合的名字或实体类的类型Comment.class
mongoTemplate.updateFirst(query,update,"comment");
}

//另一个版本
/**
* 用户点赞与取消点赞评论根据评论id
* @param commentId 被点赞评论id
* @param isThumbup true : 点赞 ; false : 取消点赞
*/
@Override
public void thumbup(String commentId, Boolean isThumbup) {
Query query = new Query(Criteria.where("cid").is(commentId)); //设置修改条件
Update update = new Update();
if(isThumbup){ //点赞操作
update.inc("thumbup",1);
}else{ //取消点赞操作
update.inc("thumbup",-1);
}
mongoTemplate.updateFirst(query,update,"comment");
}

印象最深-解决深分页问题

先说一下背景吧:遇到的问题:因为我们用的是Navicat模拟了十万的假数据,数据量比较大,把数据录入MySQL表之后,在前后端联调时发现了翻页越到后面显示的越慢,起初不知道深度分页这个问题,以为只是网络的原因,就一直搁置着这个问题,但是心里总是感觉不得劲,后来我在刷抖音时刚好刷到有大佬聊这个问题,于是我就尝试在我们的系统上进行了修改,最后解决了这个问题,虽然说这个只是一个小优化,但是解了我心里的一个结,所以我对这个确实印象深刻。

​ 然后我说一下什么是深分页吧,深分页就是 mysql的limit关键字 ,它需要设置偏移量和它查询的条数进行分页,如果limit的偏移量数值设置的比较大,它还要扫描偏移量的全部数据然后舍弃掉。比如 limit 1000 10,它需要扫描1010条数据并且丢弃掉前面的1000条,只返回最后的10条,了解到它的解决方法,

  • 首先是可以设置避免直接查询太深的页码,让偏移量少一点,就是不设置跳转的功能,但是我觉得这种可能会减少用户的体验感。

所以就想着从sql上做优化,上网搜索了一下,主要有两种方法

  • 一种就是要记录上一次查询到哪了,这一次就筛选掉之前的数据,减少limit的丢弃量,那么知道上次分页的最后一个数据的id并且id是自增的,那就可以根据id筛选掉不需要的数据,偏移量直接不写,只需要具体的条数就可以了,比如:
select from table where id>? and 其他条件 limit ?
  • 第二种就是id不是自增的,就利用子查询,只查询id作为外部查询的id in的条件,通过索引覆盖减少回表,从而尽可能提升查询效率 比如:
select from table where id in (select id from (select id from table where 条件 limit ?,?)t)

我们这个项目用的是第二种方式。

采用CompletableFuture异步编程实现数据汇总

传统的Future异步编程实现起来非常复杂,它需要实现FutureTask方法并实现Callable内部类,再结合Thread或者线程池的方式实现,获得返回值要调用FutureTask的get方法,他会阻塞后面的代码,但是如果后面的代码不依赖返回值的话,我们希望它们能以并行的方式去执行,那我们就能结合CompletableFuture去改造

它提供了两种异步任务:

  • runAsync(),方法执行任务是没有返回值的
  • supplyAsync()方法执行任务则支持返回值

两种组合处理:

  • anyOf返回跑的最快的那个future。最快的如果异常都玩完
  • allOf全部并行执行,如果需要获得返回值,需要配合thenApply,异常会抛出不影响其他任何任务

异步回调方法:不会阻碍后面的任务执行

  • whenComplete()没有返回值,且返回的CompletableFuture为任务结果,而非回调结果
  • handle()有返回值,且返回的CompletableFuture为回调结果

上面两个方法出现异常不会中断throwable:参数会接收前面的任务的异常异常会通过get抛出到主线程

  • 链式处理:–出现异常后面的任务会中断处理任务中感知不到异常异常会通过get抛出到主线程
  1. thenRun(Runnable runnable): 对异步任务的结果进行操作,不能传入参,也没有返回值
  2. thenAccept(Consumer consumer):可传入参数
  3. thenApply(Function function):可传入参数,并返回结果

例如:

public static void main(String[]args)throws ExecutionException,InterruptedException{
CompletableFuture<Integer>future1 CompletableFuture.supplyAsync(()->15);
CompletableFuture<Integer>future2 CompletableFuture.supplyAsync(()->10);
CompletableFuture<Integer>allFutures CompletableFuture.allOf(future1,future2)
thenApply(res ->{
return future1.join()+future2.join();
//TODO....
});
System.out.println(allFutures.join());
}
}

然后看一下我采用CompletableFuture异步编程后的思路(简单展示思路,并非项目真实代码)

controller层

@RestController
@RequestMapping("/api/surveys")
public class SurveyController {

private final SurveyService surveyService;

public SurveyController(SurveyService surveyService) {
this.surveyService = surveyService;
}

@PostMapping("/process")
public CompletableFuture<Void> processSurveyConcurrently(@RequestBody SurveyData data,
@RequestParam SurveyStatus newStatus) {
return surveyService.processSurveyConcurrentlyAsync(data, newStatus);
}
}

service层

// SurveyService.java
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SurveyServiceImpl implements SurveyService {
//先用一个mapper实例
private final SurveyMapper surveyMapper;
private final ExecutorService executorService = Executors.newFixedThreadPool(3);

public SurveyServiceImpl(SurveyMapper surveyMapper) {
this.surveyMapper = surveyMapper;
}

@Override
public CompletableFuture<Void> saveSurveyDataAsync(SurveyData data) {
return CompletableFuture.runAsync(() -> surveyMapper.saveSurveyData(data), executorService);
}

@Override
public CompletableFuture<Void> updateSurveyStatusAsync(String surveyId, SurveyStatus status) {
return CompletableFuture.runAsync(() -> surveyMapper.updateSurveyStatus(surveyId, status), executorService);
}

public CompletableFuture<AnalysisResult> analyzeSurveyResultsAsync(SurveyData data) {
// 这里只是一个示例,实际的数据处理更复杂
return CompletableFuture.supplyAsync(() -> {
return new AnalysisResult();
}, executorService);
}

@Override
public CompletableFuture<Void> processSurveyConcurrentlyAsync(SurveyData data, SurveyStatus newStatus) {
CompletableFuture<Void> saveFuture = saveSurveyDataAsync(data);
CompletableFuture<AnalysisResult> analyzeFuture = analyzeSurveyResultsAsync(data);
CompletableFuture<Void> updateFuture = updateSurveyStatusAsync(data.getSurveyId(), newStatus);

return CompletableFuture.allOf(saveFuture, analyzeFuture, updateFuture)
.thenRun(() -> System.out.println("All operations completed successfully!"));
}
}

mapper层

// SurveyMapper.java
public interface SurveyMapper {
void saveSurveyData(SurveyData data);
void updateSurveyStatus(String surveyId, SurveyStatus status);
SurveyData getSurveyDataById(String surveyId);
}

基于Spring Task定时任务调度实现定期清理回访问卷数据

这个场景呢,也是我在开发学院科研项目时遇到的场景,主要呢就是实现两个功能,一是超时的问卷,二是每天凌晨触发一次清理一直处于未审核通过的问卷。

具体做法(涉及到项目内部,所以这里只展示一下大体实现)

首先呢,要在启动类上加上 @EnableScheduling //开启任务调度

然后创建一个定时任务类QuestionnaireTask ,并注入QuestionnaireMapper

5.1. 处理超时问卷

@Scheduled(cron = "0 * * * * ? ")//每分钟触发一次
public void processTimeoutQuestionnaire(){
log.info("定时处理超时问卷,{}", LocalDateTime.now());
LocalDateTime time = LocalDateTime .now().plusMinutes(-15);
//获取已超时的问卷
List<questionnaires> questionnaireList = QuestionnaireMapper.getByStatusAndQuestionnaireTimeLT(Questionnaires.COLLECT,time);
if (questionnaireList!=null&&questionnaireList .size()>0){
for (Questionnaires questionnaires: questionnaireList ) {
//问卷处理(此处为演示,具体逻辑在mapper层实现)
QuestionnaireMapper.update(questionnaires);
}
}

5.2 每天凌晨触发一次清理一直处于未发布状态的问卷

@Scheduled(cron = "0 0 1 * * ?") //每天凌晨一点触发一次
public void processAuditQuestionnaires(){
LocalDateTime time = LocalDateTime.now().plusMinutes(-60);
List<Questionnaires> questionnairesList = questionnaireMapper.getByStatusAndOrderTimeLT(Questionnaires.UNPUBLISH, time);
if (questionnairesList !=null&&questionnairesList .size()>0){
for (Questionnaires questionnaires : questionnairesList ) {
//演示
questionnaireMapper.delete(questionnaires);
}
}
}

基于SpringData Elasticsearch操纵Elasticsearch的实现检索中药材sku信息、热销药材

此项目是微服务项目,不同的功能分成了不同的模块,通过feign相互调用,下面只针对service-search模块做介绍,远程调用其他模块不做详细介绍。(search模块还完成上下架的功能,此处不做具体介绍,在rabbitMQ功能使用时具体介绍)

先在service-search引入SpringData Elasticsearch 的依赖

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

启动类

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)//取消数据源自动配置
@EnableDiscoveryClient
@EnableFeignClients
public class ServiceSearchApplication {

public static void main(String[] args) {
SpringApplication.run(ServiceSearchApplication.class, args);
}
}

application-dev.yml中进行配置

elasticsearch:
rest:
uris: http://localhost:9201

controller层

@RestController
@RequestMapping("api/search/sku")
public class SkuApiController {

@Autowired
private SkuService skuService;

@GetMapping("inner/findHotSkuList")
public List<SkuEs> findHotSkuList() {
return skuService.findHotSkuList();
}

@GetMapping("{page}/{limit}")
public Result listSku(@PathVariable("page") Integer page, @PathVariable("limit") Integer limit, SkuEsQueryVo skuEsQueryVo) {
//0代表第一页
Pageable pageable = PageRequest.of(page - 1, limit);
Page<SkuEs> pageModel = skuService.search(pageable, skuEsQueryVo);
return Result.ok(pageModel);
}

//更新热度
@GetMapping("inner/incrHotScore/{skuId}")
public Boolean incrHotScore(@PathVariable("skuId") Long skuId){
skuService.incrHotScore(skuId);
return true;
}
}

service层

@Service
@Slf4j
public class SkuServiceImpl implements SkuService {


@Autowired
private SkuRepository skuRepository;

@Autowired
private ProductFeignClient productFeignClient;

@Autowired
private ActivityFeignClient activityFeignClient;

@Autowired
private RedisTemplate redisTemplate;

//获取热度高的中药
@Override
public List<SkuEs> findHotSkuList() {
Pageable pageable = PageRequest.of(0, 10);
Page<SkuEs> pageModel = skuRepository.findByOrderByHotScoreDesc(pageable);
List<SkuEs> skuEsList = pageModel.getContent();
return skuEsList;
}

//查询中药
@Override
public Page<SkuEs> search(Pageable pageable, SkuEsQueryVo skuEsQueryVo) {
Page<SkuEs> pageModel=null;
//向vo里面设置wareId
skuEsQueryVo.setWareId(AuthContextHolder.getWareId());
//根据springData命名规范定义方法查询
String keyword = skuEsQueryVo.getKeyword();
if (StringUtils.isEmpty(keyword)){
pageModel=skuRepository.findByCategoryIdAndWareId(skuEsQueryVo.getCategoryId(),skuEsQueryVo.getWareId(),pageable);
}else{
pageModel=skuRepository.findByKeywordAndWareId(skuEsQueryVo.getKeyword(),skuEsQueryVo.getWareId(),pageable);
}
//查询中药参加的优惠活动
List<SkuEs> skuEsList = pageModel.getContent();
if (CollectionUtils.isEmpty(skuEsList)){
List<Long> skuIdList = skuEsList.stream().map(SkuEs::getId).collect(Collectors.toList());
//远程调用service-activity
//返回Map<Long,List<String>>
Map<Long,List<String>> skuIdToRuleListMap=activityFeignClient.findActivity(skuIdList);
//封装获取数据到skuEs里面ruleList
if (skuIdToRuleListMap!=null){
skuEsList.forEach(skuEs -> {
skuEs.setRuleList(skuIdToRuleListMap.get(skuEs.getId()));
});
}
}
return pageModel;
}

//更新中药热度
@Override
public void incrHotScore(Long skuId) {
String key="hotScore";
Double hotScore = redisTemplate.opsForZSet().incrementScore(key, "skuId" + skuId, 1);
//规则
if (hotScore%10==0){
Optional<SkuEs> optionalSkuEs = skuRepository.findById(skuId);
SkuEs skuEs=optionalSkuEs.get();
skuEs.setHotScore(Math.round(hotScore));
skuRepository.save(skuEs);
}
}
}

repository层

public interface SkuRepository extends ElasticsearchRepository<SkuEs,Long> {

Page<SkuEs> findByOrderByHotScoreDesc(Pageable pageable);

Page<SkuEs> findByCategoryIdAndWareId(Long categoryId, Long wareId, Pageable pageable);

Page<SkuEs> findByKeywordAndWareId(String keyword, Long wareId, Pageable pageable);

}

基于Redisson的分布式锁生成订单,锁定库存

因为在分布式环境下,多个服务不能靠普通锁进行锁定,索引就用到了Redission分布式锁,

Redisson分布式锁解决分布式环境下并发安全问题,完全实现了juc的功能,不仅有锁,还都是分布式锁

img

假设此时两个用户的请求同时到来,但是落在了不同的机器上,那么这两个请求是可以同时执行了,还是会出现库存超卖的问题。为什么呢?因为上图中的两个A系统,运行在两个不同的JVM里面,他们加的锁只对属于自己JVM里面的线程有效,对于其他JVM的线程是无效的。因此,这里的问题是Java提供的原生锁机制在多机部署场景下失效了这是因为两台机器加的锁不是同一个锁(两个锁在不同的JVM里面)。那么,我们只要保证两台机器加的锁是同一个锁,问题不就解决了吗?此时,就该分布式锁登场了,分布式锁的思路是:在整个系统提供一个全局、唯一的获取锁的“东西”,然后每个系统在需要加锁时,都去问这个“东西”拿到一把锁,这样不同的系统拿到的就可以认为是同一把锁。至于这个“东西”,可以是Redis、Zookeeper,也可以是数据库

Redisson分布式锁解决分布式环境下并发安全问题,完全实现了juc的功能,不仅有锁,还都是分布式锁

img

步骤

  1. 添加Redisson配置类

service-util模块添加Redisson配置类

@Data
@Configuration
@ConfigurationProperties("spring.redis")
public class RedissonConfig {

private String host;

private String addresses;

private String password;

private String port;

private int timeout = 3000;
private int connectionPoolSize = 64;
private int connectionMinimumIdleSize=10;
private int pingConnectionInterval = 60000;
private static String ADDRESS_PREFIX = "redis://";

/**
* 自动装配
*
*/
@Bean
RedissonClient redissonSingle() {
Config config = new Config();
// 判断redis 的host是否为空
if(StringUtils.isEmpty(host)){
throw new RuntimeException("host is empty");
}
// 配置host,port等参数
SingleServerConfig serverConfig = config.useSingleServer()
//redis://127.0.0.1:7181
.setAddress(ADDRESS_PREFIX + this.host + ":" + port)
.setTimeout(this.timeout)
.setPingConnectionInterval(pingConnectionInterval)
.setConnectionPoolSize(this.connectionPoolSize)
.setConnectionMinimumIdleSize(this.connectionMinimumIdleSize);
// 判断进入redis 是否密码
if(!StringUtils.isEmpty(this.password)) {
serverConfig.setPassword(this.password);
}
// RedissonClient redisson = Redisson.create(config);
return Redisson.create(config);
}
}
  1. 添加OrderApiController方法
@Api(value = "Order管理", tags = "Order管理")
@RestController
@RequestMapping(value="/api/order")
public class OrderApiController {

@Resource
private OrderInfoService orderService;

@ApiOperation("确认订单")
@GetMapping("auth/confirmOrder")
public Result confirm() {
return Result.ok(orderService.confirmOrder());
}

@ApiOperation("生成订单")
@PostMapping("auth/submitOrder")
public Result submitOrder(@RequestBody OrderSubmitVo orderParamVo, HttpServletRequest request) {
// 获取到用户Id
Long userId = AuthContextHolder.getUserId();
return Result.ok(orderService.submitOrder(orderParamVo));
}

@ApiOperation("获取订单详情")
@GetMapping("auth/getOrderInfoById/{orderId}")
public Result getOrderInfoById(@PathVariable("orderId") Long orderId){
return Result.ok(orderService.getOrderInfoById(orderId));
}
}
  1. 添加OrderInfoService方法
public interface OrderInfoService extends IService<OrderInfo> {
/**
* 确认订单
*/
OrderConfirmVo confirmOrder();

//生成订单
Long submitOrder(OrderSubmitVo orderParamVo);

//订单详情
OrderInfo getOrderInfoById(Long orderId);
}
  1. 添加OrderInfoServiceImpl方法
@Override
public Long submitOrder(OrderSubmitVo orderSubmitVo) {
//添加当前用户
orderSubmitVo.setUserId(AuthContextHolder.getUserId());

// 1.防重:redis
String orderNo = orderSubmitVo.getOrderNo();
if (StringUtils.isEmpty(orderNo)){
throw new YYJJException(ResultCodeEnum.ILLEGAL_REQUEST);
}
String script = "if(redis.call('get', KEYS[1]) == ARGV[1]) then return redis.call('del', KEYS[1]) else return 0 end";
Boolean flag = (Boolean)redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(RedisConst.ORDER_REPEAT + orderNo), orderNo);
// if (!flag){
// throw new GmallException(ResultCodeEnum.REPEAT_SUBMIT);
// }

// 2.验库存并锁定库存
// List<Long> skuIdList = orderSubmitVo.getSkuIdList();
List<CartInfo> cartInfoList = cartFeignClient.getCartCheckedList(AuthContextHolder.getUserId());
List<CartInfo> commonSkuList = cartInfoList.stream().filter(cartInfo -> cartInfo.getSkuType() == SkuType.COMMON.getCode()).collect(Collectors.toList());
if(!CollectionUtils.isEmpty(commonSkuList)) {
List<SkuStockLockVo> commonStockLockVoList = commonSkuList.stream().map(item -> {
SkuStockLockVo skuStockLockVo = new SkuStockLockVo();
skuStockLockVo.setSkuId(item.getSkuId());
skuStockLockVo.setSkuNum(item.getSkuNum());
return skuStockLockVo;
}).collect(Collectors.toList());
//是否锁定
Boolean isLockCommon = productFeignClient.checkAndLock(commonStockLockVoList, orderSubmitVo.getOrderNo());
if (!isLockCommon){
throw new YYJJException(ResultCodeEnum.ORDER_STOCK_FALL);
}
}

List<CartInfo> seckillSkuList = cartInfoList.stream().filter(cartInfo -> cartInfo.getSkuType() == SkuType.SECKILL.getCode()).collect(Collectors.toList());
if(!CollectionUtils.isEmpty(seckillSkuList)) {
List<SkuStockLockVo> seckillStockLockVoList = seckillSkuList.stream().map(item -> {
SkuStockLockVo skuStockLockVo = new SkuStockLockVo();
skuStockLockVo.setSkuId(item.getSkuId());
skuStockLockVo.setSkuNum(item.getSkuNum());
return skuStockLockVo;
}).collect(Collectors.toList());
//是否锁定
Boolean isLockSeckill = seckillFeignClient.checkAndMinusStock(seckillStockLockVoList, orderSubmitVo.getOrderNo());
if (!isLockSeckill){
throw new YYJJException(ResultCodeEnum.ORDER_STOCK_FALL);
}
}

// 3.下单
Long orderId = null;
try {
orderId = this.saveOrder(orderSubmitVo, cartInfoList);

// 订单正常创建成功的情况下,发送消息定时关单
int normalOrderOvertime = orderSetService.getNormalOrderOvertime();
//rabbitService.sendDelayMessage(MqConst.EXCHANGE_ORDER_DIRECT, MqConst.ROUTING_CANCEL_ORDER, orderSubmitVo.getOrderNo(), normalOrderOvertime);
} catch (Exception e) {
e.printStackTrace();
// 出现异常立马解锁库存 标记订单时无效订单
//rabbitService.sendMessage(MqConst.EXCHANGE_ORDER_DIRECT, MqConst.ROUTING_ROLLBACK_STOCK, orderSubmitVo.getOrderNo());
throw new GmallException(ResultCodeEnum.CREATE_ORDER_FAIL);
}

// 5.异步删除购物车中对应的记录。不应该影响下单的整体流程
rabbitService.sendMessage(MqConst.EXCHANGE_ORDER_DIRECT, MqConst.ROUTING_DELETE_CART, orderSubmitVo.getUserId())
return orderId;
}

@Transactional(rollbackFor = {Exception.class})
public Long saveOrder(OrderSubmitVo orderSubmitVo, List<CartInfo> cartInfoList) {
Long userId = AuthContextHolder.getUserId();
if(CollectionUtils.isEmpty(cartInfoList)) {
throw new YYJJException(ResultCodeEnum.DATA_ERROR);
}
LeaderAddressVo leaderAddressVo = userFeignClient.getLeaderAddressVoByUserId(userId);
if(null == leaderAddressVo) {
throw new YYJJException(ResultCodeEnum.DATA_ERROR);
}

//计算购物项分摊的优惠减少金额,按比例分摊,退款时按实际支付金额退款
Map<String, BigDecimal> activitySplitAmountMap = this.computeActivitySplitAmount(cartInfoList);
Map<String, BigDecimal> couponInfoSplitAmountMap = this.computeCouponInfoSplitAmount(cartInfoList, orderSubmitVo.getCouponId());
//sku对应的订单明细
List<OrderItem> orderItemList = new ArrayList<>();
// 保存订单明细
for (CartInfo cartInfo : cartInfoList) {
OrderItem orderItem = new OrderItem();
orderItem.setId(null);
orderItem.setCategoryId(cartInfo.getCategoryId());
if(cartInfo.getSkuType() == SkuType.COMMON.getCode()) {
orderItem.setSkuType(SkuType.COMMON);
} else {
orderItem.setSkuType(SkuType.SECKILL);
}
orderItem.setSkuId(cartInfo.getSkuId());
orderItem.setSkuName(cartInfo.getSkuName());
orderItem.setSkuPrice(cartInfo.getCartPrice());
orderItem.setImgUrl(cartInfo.getImgUrl());
orderItem.setSkuNum(cartInfo.getSkuNum());
orderItem.setLeaderId(orderSubmitVo.getLeaderId());

//促销活动分摊金额
BigDecimal splitActivityAmount = activitySplitAmountMap.get("activity:"+orderItem.getSkuId());
if(null == splitActivityAmount) {
splitActivityAmount = new BigDecimal(0);
}
orderItem.setSplitActivityAmount(splitActivityAmount);

//优惠券分摊金额
BigDecimal splitCouponAmount = couponInfoSplitAmountMap.get("coupon:"+orderItem.getSkuId());
if(null == splitCouponAmount) {
splitCouponAmount = new BigDecimal(0);
}
orderItem.setSplitCouponAmount(splitCouponAmount);

//优惠后的总金额
BigDecimal skuTotalAmount = orderItem.getSkuPrice().multiply(new BigDecimal(orderItem.getSkuNum()));
BigDecimal splitTotalAmount = skuTotalAmount.subtract(splitActivityAmount).subtract(splitCouponAmount);
orderItem.setSplitTotalAmount(splitTotalAmount);
orderItemList.add(orderItem);
}

//保存订单
OrderInfo order = new OrderInfo();
order.setUserId(userId);
// private String nickName;
order.setOrderNo(orderSubmitVo.getOrderNo());
order.setOrderStatus(OrderStatus.UNPAID);
order.setProcessStatus(ProcessStatus.UNPAID);
order.setCouponId(orderSubmitVo.getCouponId());
order.setLeaderId(orderSubmitVo.getLeaderId());
order.setLeaderName(leaderAddressVo.getLeaderName());
order.setLeaderPhone(leaderAddressVo.getLeaderPhone());
order.setTakeName(leaderAddressVo.getTakeName());
order.setReceiverName(orderSubmitVo.getReceiverName());
order.setReceiverPhone(orderSubmitVo.getReceiverPhone());
order.setReceiverProvince(leaderAddressVo.getProvince());
order.setReceiverCity(leaderAddressVo.getCity());
order.setReceiverDistrict(leaderAddressVo.getDistrict());
order.setReceiverAddress(leaderAddressVo.getDetailAddress());
order.setWareId(cartInfoList.get(0).getWareId());

//计算订单金额
BigDecimal originalTotalAmount = this.computeTotalAmount(cartInfoList);
BigDecimal activityAmount = activitySplitAmountMap.get("activity:total");
if(null == activityAmount) activityAmount = new BigDecimal(0);
BigDecimal couponAmount = couponInfoSplitAmountMap.get("coupon:total");
if(null == couponAmount) couponAmount = new BigDecimal(0);
BigDecimal totalAmount = originalTotalAmount.subtract(activityAmount).subtract(couponAmount);
//计算订单金额
order.setOriginalTotalAmount(originalTotalAmount);
order.setActivityAmount(activityAmount);
order.setCouponAmount(couponAmount);
order.setTotalAmount(totalAmount);

BigDecimal profitRate = orderSetService.getProfitRate();
BigDecimal commissionAmount = order.getTotalAmount().multiply(profitRate);
order.setCommissionAmount(commissionAmount);

orderInfoMapper.insert(order);

//保存订单项
for(OrderItem orderItem : orderItemList) {
orderItem.setOrderId(order.getId());
}
orderItemService.saveBatch(orderItemList);

//更新优惠券使用状态
if(null != order.getCouponId()) {
activityFeignClient.updateCouponInfoUseStatus(order.getCouponId(), userId, order.getId());
}

//下单成功,记录用户商品购买个数
String orderSkuKey = RedisConst.ORDER_SKU_MAP + orderSubmitVo.getUserId();
BoundHashOperations<String, String, Integer> hashOperations = redisTemplate.boundHashOps(orderSkuKey);
cartInfoList.forEach(cartInfo -> {
if(hashOperations.hasKey(cartInfo.getSkuId().toString())) {
Integer orderSkuNum = hashOperations.get(cartInfo.getSkuId().toString()) + cartInfo.getSkuNum();
hashOperations.put(cartInfo.getSkuId().toString(), orderSkuNum);
}
});
redisTemplate.expire(orderSkuKey, DateUtil.getCurrentExpireTimes(), TimeUnit.SECONDS);

//发送消息
return order.getId();
}

private BigDecimal computeTotalAmount(List<CartInfo> cartInfoList) {
BigDecimal total = new BigDecimal(0);
for (CartInfo cartInfo : cartInfoList) {
BigDecimal itemTotal = cartInfo.getCartPrice().multiply(new BigDecimal(cartInfo.getSkuNum()));
total = total.add(itemTotal);
}
return total;
}

/**
* 计算购物项分摊的优惠减少金额
* 打折:按折扣分担
* 现金:按比例分摊
* @param cartInfoParamList
* @return
*/
private Map<String, BigDecimal> computeActivitySplitAmount(List<CartInfo> cartInfoParamList) {
Map<String, BigDecimal> activitySplitAmountMap = new HashMap<>();

//促销活动相关信息
List<CartInfoVo> cartInfoVoList = activityFeignClient.findCartActivityList(cartInfoParamList);

//活动总金额
BigDecimal activityReduceAmount = new BigDecimal(0);
if(!CollectionUtils.isEmpty(cartInfoVoList)) {
for(CartInfoVo cartInfoVo : cartInfoVoList) {
ActivityRule activityRule = cartInfoVo.getActivityRule();
List<CartInfo> cartInfoList = cartInfoVo.getCartInfoList();
if(null != activityRule) {
//优惠金额, 按比例分摊
BigDecimal reduceAmount = activityRule.getReduceAmount();
activityReduceAmount = activityReduceAmount.add(reduceAmount);
if(cartInfoList.size() == 1) {
activitySplitAmountMap.put("activity:"+cartInfoList.get(0).getSkuId(), reduceAmount);
} else {
//总金额
BigDecimal originalTotalAmount = new BigDecimal(0);
for(CartInfo cartInfo : cartInfoList) {
BigDecimal skuTotalAmount = cartInfo.getCartPrice().multiply(new BigDecimal(cartInfo.getSkuNum()));
originalTotalAmount = originalTotalAmount.add(skuTotalAmount);
}
//记录除最后一项是所有分摊金额, 最后一项=总的 - skuPartReduceAmount
BigDecimal skuPartReduceAmount = new BigDecimal(0);
if (activityRule.getActivityType() == ActivityType.FULL_REDUCTION) {
for(int i=0, len=cartInfoList.size(); i<len; i++) {
CartInfo cartInfo = cartInfoList.get(i);
if(i < len -1) {
BigDecimal skuTotalAmount = cartInfo.getCartPrice().multiply(new BigDecimal(cartInfo.getSkuNum()));
//sku分摊金额
BigDecimal skuReduceAmount = skuTotalAmount.divide(originalTotalAmount, 2, RoundingMode.HALF_UP).multiply(reduceAmount);
activitySplitAmountMap.put("activity:"+cartInfo.getSkuId(), skuReduceAmount);

skuPartReduceAmount = skuPartReduceAmount.add(skuReduceAmount);
} else {
BigDecimal skuReduceAmount = reduceAmount.subtract(skuPartReduceAmount);
activitySplitAmountMap.put("activity:"+cartInfo.getSkuId(), skuReduceAmount);
}
}
} else {
for(int i=0, len=cartInfoList.size(); i<len; i++) {
CartInfo cartInfo = cartInfoList.get(i);
if(i < len -1) {
BigDecimal skuTotalAmount = cartInfo.getCartPrice().multiply(new BigDecimal(cartInfo.getSkuNum()));

//sku分摊金额
BigDecimal skuDiscountTotalAmount = skuTotalAmount.multiply(activityRule.getBenefitDiscount().divide(new BigDecimal("10")));
BigDecimal skuReduceAmount = skuTotalAmount.subtract(skuDiscountTotalAmount);
activitySplitAmountMap.put("activity:"+cartInfo.getSkuId(), skuReduceAmount);

skuPartReduceAmount = skuPartReduceAmount.add(skuReduceAmount);
} else {
BigDecimal skuReduceAmount = reduceAmount.subtract(skuPartReduceAmount);
activitySplitAmountMap.put("activity:"+cartInfo.getSkuId(), skuReduceAmount);
}
}
}
}
}
}
}
activitySplitAmountMap.put("activity:total", activityReduceAmount);
return activitySplitAmountMap;
}

private Map<String, BigDecimal> computeCouponInfoSplitAmount(List<CartInfo> cartInfoList, Long couponId) {
Map<String, BigDecimal> couponInfoSplitAmountMap = new HashMap<>();

if(null == couponId) return couponInfoSplitAmountMap;
CouponInfo couponInfo = activityFeignClient.findRangeSkuIdList(cartInfoList, couponId);

if(null != couponInfo) {
//sku对应的订单明细
Map<Long, CartInfo> skuIdToCartInfoMap = new HashMap<>();
for (CartInfo cartInfo : cartInfoList) {
skuIdToCartInfoMap.put(cartInfo.getSkuId(), cartInfo);
}
//优惠券对应的skuId列表
List<Long> skuIdList = couponInfo.getSkuIdList();
if(CollectionUtils.isEmpty(skuIdList)) {
return couponInfoSplitAmountMap;
}
//优惠券优化总金额
BigDecimal reduceAmount = couponInfo.getAmount();
if(skuIdList.size() == 1) {
//sku的优化金额
couponInfoSplitAmountMap.put("coupon:"+skuIdToCartInfoMap.get(skuIdList.get(0)).getSkuId(), reduceAmount);
} else {
//总金额
BigDecimal originalTotalAmount = new BigDecimal(0);
for (Long skuId : skuIdList) {
CartInfo cartInfo = skuIdToCartInfoMap.get(skuId);
BigDecimal skuTotalAmount = cartInfo.getCartPrice().multiply(new BigDecimal(cartInfo.getSkuNum()));
originalTotalAmount = originalTotalAmount.add(skuTotalAmount);
}
//记录除最后一项是所有分摊金额, 最后一项=总的 - skuPartReduceAmount
BigDecimal skuPartReduceAmount = new BigDecimal(0);
if (couponInfo.getCouponType() == CouponType.CASH || couponInfo.getCouponType() == CouponType.FULL_REDUCTION) {
for(int i=0, len=skuIdList.size(); i<len; i++) {
CartInfo cartInfo = skuIdToCartInfoMap.get(skuIdList.get(i));
if(i < len -1) {
BigDecimal skuTotalAmount = cartInfo.getCartPrice().multiply(new BigDecimal(cartInfo.getSkuNum()));
//sku分摊金额
BigDecimal skuReduceAmount = skuTotalAmount.divide(originalTotalAmount, 2, RoundingMode.HALF_UP).multiply(reduceAmount);
couponInfoSplitAmountMap.put("coupon:"+cartInfo.getSkuId(), skuReduceAmount);

skuPartReduceAmount = skuPartReduceAmount.add(skuReduceAmount);
} else {
BigDecimal skuReduceAmount = reduceAmount.subtract(skuPartReduceAmount);
couponInfoSplitAmountMap.put("coupon:"+cartInfo.getSkuId(), skuReduceAmount);
}
}
}
}
couponInfoSplitAmountMap.put("coupon:total", couponInfo.getAmount());
}
return couponInfoSplitAmountMap;
}
  1. service-product模块创建方法
@ApiOperation(value = "锁定库存")
@PostMapping("inner/checkAndLock/{orderNo}")
public Boolean checkAndLock(@RequestBody List<SkuStockLockVo> skuStockLockVoList, @PathVariable String orderNo) {
return skuInfoService.checkAndLock(skuStockLockVoList, orderNo);
}
  1. SkuInfoService实现锁定库存方法
@Transactional(rollbackFor = {Exception.class})
@Override
public Boolean checkAndLock(List<SkuStockLockVo> skuStockLockVoList,
String orderToken) {
if (CollectionUtils.isEmpty(skuStockLockVoList)){
throw new GmallException(ResultCodeEnum.DATA_ERROR);
}

// 遍历所有,验库存并锁库存,要具备原子性
skuStockLockVoList.forEach(skuStockLockVo -> {
checkLock(skuStockLockVo);
});

// 只要有一个锁定失败,所有锁定成功的商品要解锁库存
if (skuStockLockVoList.stream().anyMatch(skuStockLockVo -> !skuStockLockVo.getIsLock())) {
// 获取所有锁定成功的,遍历解锁库存
skuStockLockVoList.stream().filter(SkuStockLockVo::getIsLock).forEach(skuStockLockVo -> {
skuInfoMapper.unlockStock(skuStockLockVo.getSkuId(), skuStockLockVo.getSkuNum());
});
// 响应锁定状态
return false;
}

// 如果所有都锁定成功的情况下,需要缓存锁定信息到redis。以方便将来解锁库存 或者 减库存
// 以orderToken作为key,以lockVos锁定信息作为value
this.redisTemplate.opsForValue().set(RedisConst.SROCK_INFO + orderToken, skuStockLockVoList);

// 锁定库存成功之后,定时解锁库存。
//this.rabbitTemplate.convertAndSend("ORDER_EXCHANGE", "stock.ttl", orderToken);
return true;
}

private void checkLock(SkuStockLockVo skuStockLockVo){
//公平锁,就是保证客户端获取锁的顺序,跟他们请求获取锁的顺序,是一样的。
// 公平锁需要排队
// ,谁先申请获取这把锁,
// 谁就可以先获取到这把锁,是按照请求的先后顺序来的。
RLock rLock = this.redissonClient
.getFairLock(RedisConst.SKUKEY_PREFIX + skuStockLockVo.getSkuId());
rLock.lock();

try {
// 验库存:查询,返回的是满足要求的库存列表
SkuInfo skuInfo = skuInfoMapper.checkStock(skuStockLockVo.getSkuId(), skuStockLockVo.getSkuNum());
// 如果没有一个仓库满足要求,这里就验库存失败
if (null == skuInfo) {
skuStockLockVo.setIsLock(false);
return;
}

// 锁库存:更新
Integer row = skuInfoMapper.lockStock(skuStockLockVo.getSkuId(), skuStockLockVo.getSkuNum());
if (row == 1) {
skuStockLockVo.setIsLock(true);
}
} finally {
rLock.unlock();
}
}

基于RabbitMQ实现中药材上下架、生成订单完成,删除采购车数据、支付完成之后,更新订单状态和扣减库存等。

rabbitMQ是我这个项目中大量用到的消息中间件,一是我觉得它是专门做这个消息中

消息队列提供一个异步通信机制,消息的发送者不必一直等待到消息被成功处理才返回,而是立即返回。消息中间件负责处理网络通信,如果网络连接不可用,消息被暂存于队列当中,当网络畅通的时候在将消息转发给相应的应用程序或者服务,当然前提是这些服务订阅了该队列。如果在商品服务和订单服务之间使用消息中间件,既可以提高并发量,又降低服务之间的耦合度。

RabbitMQ就是这样一款消息队列。RabbitMQ是一个开源的消息代理的队列服务器,用来通过普通协议在完全不同的应用之间共享数据。

使用步骤:

安装rabbitMQ

#拉取镜像
docker pull rabbitmq:3.8-management
#创建容器启动
docker run -d --restart=always -p 5672:5672 -p 15672:15672 --name rabbitmq rabbitmq:3.8-management

rabbitMQ服务后台

img

在common搭建rabbit_util模块,在rabbit_util引入依赖

<dependencies>
<!--rabbitmq消息队列-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
</dependencies>

添加service方法

@Service
public class RabbitService {

// 引入操作rabbitmq 的模板
@Autowired
private RabbitTemplate rabbitTemplate;

/**
* 发送消息
* @param exchange 交换机
* @param routingKey 路由键
* @param message 消息
* @return
*/
public boolean sendMessage(String exchange,String routingKey, Object message){
// 调用发送数据的方法
rabbitTemplate.convertAndSend(exchange,routingKey,message);
return true;
}

/**
* 发送延迟消息的方法
* @param exchange 交换机
* @param routingKey 路由键
* @param message 消息内容
* @param delayTime 延迟时间
* @return
*/
public boolean sendDelayMessage(String exchange,String routingKey, Object message, int delayTime){

// 在发送消息的时候设置延迟时间
rabbitTemplate.convertAndSend(exchange, routingKey, message, new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
// 设置一个延迟时间
message.getMessageProperties().setDelay(delayTime*1000);
return message;
}
});
return true;
}
}

配置mq消息转换器

@Configuration
public class MQConfig {

@Bean
public MessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
}

添加消息的确认配置

@Component
public class MQProducerAckConfig implements RabbitTemplate.ReturnCallback,RabbitTemplate.ConfirmCallback {

// 我们发送消息使用的是 private RabbitTemplate rabbitTemplate; 对象
// 如果不做设置的话 当前的rabbitTemplate 与当前的配置类没有任何关系!
@Autowired
private RabbitTemplate rabbitTemplate;

// 设置 表示修饰一个非静态的void方法,在服务器加载Servlet的时候运行。并且只执行一次!
@PostConstruct
public void init(){
rabbitTemplate.setReturnCallback(this);
rabbitTemplate.setConfirmCallback(this);
}

/**
* 表示消息是否正确发送到了交换机上
* @param correlationData 消息的载体
* @param ack 判断是否发送到交换机上
* @param cause 原因
*/
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
if(ack){
System.out.println("消息发送成功!");
}else {
System.out.println("消息发送失败!"+cause);
}
}

/**
* 消息如果没有正确发送到队列中,则会走这个方法!如果消息被正常处理,则这个方法不会走!
* @param message
* @param replyCode
* @param replyText
* @param exchange
* @param routingKey
*/
@Override
public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
System.out.println("消息主体: " + new String(message.getBody()));
System.out.println("应答码: " + replyCode);
System.out.println("描述:" + replyText);
System.out.println("消息使用的交换器 exchange : " + exchange);
System.out.println("消息使用的路由键 routing : " + routingKey);
}
}

这里举例上下架功能,其他的功能用法都是相似的

service-product添加发送MQ消息方法

引入依赖

<dependency>
<groupId>com.YYJJ</groupId>
<artifactId>rabbit-util</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

service-product配置文件添加MQ配置

rabbitmq:
host: 192.168.56.101
port: 5672
username: guest
password: guest
publisher-confirm-type: CORRELATED #发布确认模式,消息是否被成功发送到交换机
publisher-returns: true
listener:
simple:
prefetch: 1
concurrency: 3
acknowledge-mode: manual #消费端手动确认

SkuInfoServiceImpl的publish方法

@Autowired
private RabbitService rabbitService;

@Transactional(rollbackFor = {Exception.class})
@Override
public void publish(Long skuId, Integer status) {
// 更改发布状态
if(status == 1) {
SkuInfo skuInfoUp = new SkuInfo();
skuInfoUp.setId(skuId);
skuInfoUp.setPublishStatus(1);
skuInfoMapper.updateById(skuInfoUp);

//上架:发送mq消息同步es
rabbitService.sendMessage(MqConst.EXCHANGE_GOODS_DIRECT, MqConst.ROUTING_GOODS_UPPER, skuId);
} else {
SkuInfo skuInfoUp = new SkuInfo();
skuInfoUp.setId(skuId);
skuInfoUp.setPublishStatus(0);
skuInfoMapper.updateById(skuInfoUp);

//下架:发送mq消息同步es
rabbitService.sendMessage(MqConst.EXCHANGE_GOODS_DIRECT, MqConst.ROUTING_GOODS_LOWER, skuId);
}
}

service-search添加接收MQ消息方法

service-search引入依赖

<dependency>
<groupId>com.YYJJ</groupId>
<artifactId>rabbit-util</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

添加SkuReceiver接收MQ消息方法

@Component
public class SkuReceiver {

@Autowired
private SkuService skuService;

/**
* 上架
* @param skuId
* @param message
* @param channel
* @throws IOException
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = MqConst.QUEUE_GOODS_UPPER, durable = "true"),
exchange = @Exchange(value = MqConst.EXCHANGE_GOODS_DIRECT),
key = {MqConst.ROUTING_GOODS_UPPER}
))
public void upperSku(Long skuId, Message message, Channel channel) throws IOException {
if (null != skuId) {
skuService.upperSku(skuId);
}
/**
* 第一个参数:表示收到的消息的标号
* 第二个参数:如果为true表示可以签收多个消息
*/
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
}

/**
* 下架
* @param skuId
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = MqConst.QUEUE_GOODS_LOWER, durable = "true"),
exchange = @Exchange(value = MqConst.EXCHANGE_GOODS_DIRECT),
key = {MqConst.ROUTING_GOODS_LOWER}
))
public void lowerSku(Long skuId, Message message, Channel channel) throws IOException {
if (null != skuId) {
skuService.lowerSku(skuId);
}
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
}
}

扫码登录到底是怎么实现的?

​ 扫码登录的本质是,通过已经登录过的 App 应用,扫描未登录的 Web 端程序中的二维

码, 通过某种机制触发登录凭证的写入从而实现 Web 端自动登录的过程。

img

​ (图是网上找的,思路和图片都出自XX科技)

  • 首先,在网页端打开登录页面,展示一个二维码,这个二维码有一个唯一编号是服

务端生成的。然后浏览器定时轮询这个二维码的状态

  • 接着,APP 扫描这个二维码,把 APP 的 token 信息、二维码 ID 发送给 Server 端,

Server 收到请求后修改二维码的扫码状态,并生成一个临时 token

  • 此时,网页端展示的二维码状态会提示已扫码,待确认。 而 APP 端扫码之后,会

提示确认授权的操作。

  • 于是,用户确认登录后,携带临时 token 给到 server,server 端修改二维码状态

并为网页端生成授权 token

  • 最后,网页端轮询到状态变化并获取到 token,从而完成扫码授权。

订单超时自动取消功能如何设计

这个功能和我做的问卷超时功能有异曲同工之妙,但是在网上看,发现这样的场景有多种不同的解决方案,并且还添加了很多优化,这里我要做一下记录,之后可能还要优化我们的项目:

直接写个定时任务去轮询数据库,根据订单时间找到超时的订单把它取消就行了

  1. 轮询会存在延迟时间,也就是没办法准时实现订单的取消
  2. 轮询数据库,会给数据库造成很大的压力,如果订单表的数据量比较大的情况下,

轮询的效率也会比较低。

如果要考虑到性能、又要考虑到实时性,有没有更好的方案呢?

当然有,比如

  1. 时间轮算法(如图),这种算法是采用了一个环状数组+链表的方式来管理延迟任

务,我们只需要计算这个订单的超时时间,再加入到时间轮里面即可。

时间轮算法唯一的缺点就是无法持久化,所以需要在服务重启后做一次数据预热。

img

  1. 利用主流 MQ 中的延迟消息功能,消息发送到 Broker 上以后并不会立刻投递,而

是根据消息中设置的延迟时间去投递。我们只需要把新的订单并计算这个订单的超

时时间发送到 MQ 中即可。

MQ 这种实现方式在性能、可扩展性、稳定性上都比较好,是一个不错的选择。

项目中如何保证的接口幂等

这个概念是我第一次接触,认真学习了一下,了解了一下如何保证接口幂等

首先,什么是幂等?

简单来说,就是一个接口,使用相同的参数重复执行的情况下,对数据造成的改变只发生一次。 比如支付操作,如果支付接口被重复调了 N 次,那资金的扣减只发生一次,这就是幂等

然后说问题,

在分布式架构中,由于引入了网络通信导致一个请求,除了成功/失败以外,还多了一个未知状态。

也就是如果一次远程接口调用失败,有可能这个请求在服务端执行成功了。而客户端为了确保本次请求执行成功,可能会发起重试的操作,导致同一个接口被重复调用了多次

为了保证服务端接口的幂等性,我们就需要在服务端的接口中去识别当前请求是重复请求,从而不再进行数据的变更操作。

通常的解决方案有几种:

  • 使用数据库唯一索引的方式实现, 我们可以专门创建一个消息表,里面有一个消息内容的字段并且设置为唯一索引,每次收到消息以后生成 md5 值插入到这个消息表里面。一旦出现重复消息,就会抛异常,我们可以捕获这个异常来避免重复对数据做变更
  • 使用 Redis 里面的 setNx 命令,我们可以把当前请求中带有唯一标识的信息存储到Redis 里面,根据 setNx 命令返回的结果来判断是否是重复执行,如果是则丢弃该请求。
  • 使用状态机的方式来实现幂等,在很多的业务场景中,都会存在业务状态的流转,并且这些状态流转只会前进,所以我们在对数据进行修改的时候,只需要在条件里面带上状态,就能避免数据被重复修改的问题

不管采用哪种方案,核心本质都是需要去识别当前请求是重复请求。

消息推送中的已读消息和未读消息设计

(方案来自于咕泡科技)

“站内信”有两个基本功能:

点到点的消息传送。用户给用户发送站内信,管理员给用户发送站内信。

点到面的消息传送。管理员给用户(指定满足某一条件的用户群)群发消息

只需要设计一个消息内容表和一个用户通知表,当创建一条系统通知后,数据插入到消息内容表。消息内容包含了发送渠道,根据发送渠道决定后续动作。如果是站内渠道,在插入消息内容后异步地插入记录到用户通知表。

img

这个方案看起来没什么问题,但实际上,我们把所有用户通知的消息全部放在一个表里

面,如果有 10W 个用户,那么同样的消息需要存储 10W 条很明显,会带来两个问题:

  1. 随着用户量的增加,发送一次消息需要插入到数据库中的数据量会越来越大,导致

耗时会越来越长

  1. 用户通知表的数据量会非常大,对未读消息的查询效率会严重下降

所以上面这种方案很明显行不通,要解决这两个问题,有两个参考解决思路。

第一个方式(如图),先取消用户通知表, 避免在发送平台消息的时候插入大量重复数据问题。其次增加一个“message_offset”站内消息进度表,每个用户维护一个消息消费的进度 Offset。每个用户去获取未读消息的时候,只需要查询大于当前维护的 msg_id_offset的数据即可。在这种设计方式中,即便我们发送给 10W 人,也只需要在消息内容表里面插入一条记录即可。

在性能上和数据量上都有较大的提升。

img

第二种方式,和第一种方式类似,使用 Redis 中的 Set 集合来保存已经读取过的消息 id。使用 userid_read_message 作为 key,这样就可以为每个用户保存已经读取过的所有消息的 id。当用户读取了未读消息后, 就直接在 redis 的已读消息 id 的 set 中新增一条记录。这样,在已经得知到已读消息的数量和具体消息 id 的情况下,我们可以直接使用消息id 来查询没有消费过的数据

布隆过滤器到底是什么东西?它有什么用

首先想一个问题?如果想判断一个元素是否存在某个集合里面该怎么做?

一般的解决方案是先把所有元素保存起来,然后通过循环比较来确定。但是如果我们有几千万甚至上亿的数据的时候,虽然可以通过不同的数据结构来优化数据检索的时间复杂度,但是整体的效率依然很慢,而且会占用非常多的内存空间,这个问题该怎么解决呢?

这个时候,位图就派上了用场, BitMap 的基本原理就是用一个 bit 位来存储当前数据是否存在的状态值,也就是把一个数据通过 hash 运算取模后落在 bit 位组成的数组中,通过 1 对该位置进行标记。这种方式适用于大规模数据,但数据状态又不是很多的情况,通常是用来判断某个数据存不存在的。

布隆过滤器就是在位图的基础上做的一个优化设计

它的原理是,当一个元素被加入集合时,通过 K 个散列函数将这个元素映射成一个位数组中的 K 个点,把它们置为 1。检索的时候,使用同样的方式去映射,只要看到每个映射的位置的值是不是 1,就可以大概知道该元素是否存在集合中了。如果这些点有任何一个 0,则被检查的元素一定不在;如果都是 1,则被检查的元素很可能存在。

会员批量过期的方案怎么实现?

​ 有一张 200W 数据量的会员表,每个会员会有长短不一的到期时间,现在想在快到

期之前发送邮件通知提醒续费,该怎么实现?

首先把握住场景的核心问题:

  • 200W 数据意味着数据量比较大
  • 每个会员都有过期时间,需要能够筛选出快过期的会员

四种解决方案:

  1. 第一种,系统不主动轮询,而是等用户登录到系统以后,触发一次检查。如果发现会员的过期时间小于设定的阈值,就触发一次弹窗和邮件提醒。这种方式规避了轮询问题,不会对数据库和后端应用程序造成任何压力。 缺点是,如果用户一直不登陆,就一直无法实现会员过期,并且也无法提前去根据运营策略发送续期的提醒消息。
  2. 第二种,我们可以使用搜索引擎,比如 Solr、或者 Elasticsearch。把会员表里面的会员 id 和会员到期时间存储一份到搜索引擎中。搜索引擎的优势在于大数据量的快速检索,并且具有高可扩展性和高可靠性,非常适合大规模数据的处理。
  3. 第三种,可以使用 Redis 来实现。用户开通会员以后,在 Redis 里面存储这个会员 id,以及设置这个 id 的过期时间。然后可以使用 redis 的过期提醒功能,把配置项 notify-keyspace-events 改为 notify-keyspace-events “Ex”当 Redis 里面的 key 过期以后,会触发一个 key 过期事件,我们可以在应用程序中监听这个事件来处理。
  4. 第四种,可以直接使用 MQ 提供的延迟队列,当用户开通会员以后,直接计算这个会员的过期时间,然后发送一个延迟消息到 MQ 上,一旦消息达到过期时间,消费者就可以消费这个消息来触发会员过期的提醒。

日常开发中常见的限流算法

限流算法是一种系统保护策略,主要是避免在流量高峰导致系统被压垮,造成系统不可用的问题

日常开发中常见的限流算法有 4 种。

  1. 计数器限流,一般用在单一维度的访问频率限制上,比如短信验证码每隔 60s 只能发送一次,或者接口调用次数等,它的实现方法很简单,每调用一次就加 1,处理结束以后减一。

img

  1. 滑动窗口限流,本质上也是一种计数器,只是通过以时间为维度的可滑动窗口设计,来减少了临界值带来的并发超过阈值的问题。每次进行数据统计的时候,只需要统计这个窗口内每个时间刻度的访问量就可以了。Spring Cloud里面的熔断框架Hystrix ,以及Spring Cloud Alibaba里面的Sentinel都采用了滑动窗口来做数据统计

img

  1. 漏桶算法,它是一种恒定速率的限流算法,不管请求量是多少,服务端的处理效率是恒定的。基于 MQ 来实现的生产者消费者模型,其实算是一种漏桶限流算法

img

  1. 令牌桶算法,相对漏桶算法来说,它可以处理突发流量的问题。它的核心思想是,令牌桶以恒定速率去生成令牌保存到令牌桶里面,桶的大小是固定的,令牌桶满了以后就不再生成令牌。每个客户端请求进来的时候,必须要从令牌桶获得一个令牌才能访问,否则排队等待。 在流量低峰的时候,令牌桶会出现堆积,因此当出现瞬时高峰的时候,有足够多的令牌可以获取,因此令牌桶能够允许瞬时流量的处理。网关层面的限流、或者接口调用的限流,都可以使用令牌桶算法,像Google 的 Guava,和 Redisson 的限流,都用到了令牌桶算法 在我看来,限流的本质是实现系统保护,最终选择什么样的算法,一方面取决于统计的精准度,另一方面考虑限流维度和场景的需求。

一致性 Hash 算法出现的场景和如何解决

一致性 hash,是一种比较特殊的 hash 算法,它的核心思想是解决在分布式环境下,hash 表中可能存在的动态扩容和缩容的问题

  • 那么为什么会出现一致性 hash?

一般情况下,我们会使用 hash 表的方式以 key-value 的方式来存储数据,但是当数据量比较大的时候,我们就会把数据存储到多个节点上,然后通过 hash 取模的方法来决定当前 key 存储到哪个节点上。

img

这种方式有一个非常明显的问题,就是当存储节点增加或者减少的时候,原本的映射关系就会发生变化。也就是需要对所有数据按照新的节点数量重新映射一遍,这个涉及到大量的数据迁移和重新映射,迁移代价很大

  • 一致性 hash 的工作原理

一致性 hash 就是用来优化这种动态变化场景的算法,它的具体工作原理也很简单。

首先,一致性 Hash 是通过一个 Hash 环的数据结构来实现的,(如图),这个环的起点是 0,终点是 2^32-1。

也就是这个环的数据分布范围是[0,2^32-1]

img

然后我们把存储节点的 ip 地址作为 key 进行 hash 之后,会在 Hash 环上确定一个位置。

img

接下来,就是把需要存储的目标 key 使用 hash 算法计算后得到一个 hash 值,同样也会落到 hash 环的某个位置上。然后这个目标 key 会按照顺时针的方向找到离自己最近的一个节点进行数据存储。

img

  • 为什么一致性 Hash 比普通 hash 算法好

假设现在需要新增一个节点node4,那数据的映射关系的影响范围只限于node3和 node1,只有少部分的数据需要重新映射迁移就行了,如果是已经存在的节点 node1 因为故障下线了,只那只需要把原本分配在 node1 上的数据重新分配到 node2 上就行了。 同样对数据影响的范围非常小,所以,在我看来,一致性 hash 算法的好处是扩展性很强,在增加或者减少服务器的时候,数据迁移范围比较小。 另外,在一致性 Hash 算范里面,为了避免 hash 倾斜导致数据分配不均匀的情况,我们可以使用虚拟节点的方式来解决

设计一个秒杀系统

秒杀场景中有三个核心要素:

  1. 高性能
  2. 一致性
  3. 高可用性

如何提高性能?

秒杀场景核心的问题是如何解决海量请求带来的性能问题,那么我们如何在有限的资源下,尽最大的限度去提高服务器访问性能?总结有这几点:热点数据处理、流量削峰、资源隔离、服务器优化。

热点数据处理

1、什么是热点数据?

我理解的热点数据指的是用户请求量非常高的那些数据,在秒杀场景中,热点数据就是那些要被秒杀的商品数据。

这些热点请求会大量占用服务器的资源,如果不对这些数据进行处理,那么会严重占用资源,进而影响系统的性能,导致其他业务也受影响。

热点数据又可以分为“静态热点数据”和“动态热点数据”。

2、静态热点数据

静态热点数据指的是可以提前预知的热点数据,比如秒杀场景,需要参与本次秒杀的商家提前报名,并将秒杀的商品录入热点分析系统中。业务系统通过这次提前录入的热点数据,进行预加载,甚至可以将数据放入本地缓存中,这样做的好处可以有效缓解避缓存集群的压力,避免流量集中时压垮缓存集群。

如何更新本地缓存?

可用做法是将热点数据录入热点分析平台,本地对热点数据进行订阅,并根据订阅规则去更新本地缓存即可。

3、动态热点数据

动态指的就是不能提前预知哪些数据是热点的,需要通过数据收集与分析,或者通过大数据平台预测。

做法可以是通过在网关平台中做一个用于收集日志的异步日志收集系统,通过采集商品请求的日志,处理后发送到热点分析平台,热点分析平台通过一些列的分析计算将这些热点商品进行热点数据处理,后端通过订阅这些热点数据就可以识别哪些商品是热点数据了。

流量削峰

在服务器资源固定的情况下,说明处理能力是有峰值存在的,如果不对请求处理进行处理的话,很可能会在流量峰值的瞬间压垮服务器,但流量峰值存在的时间不长,其实服务器的处理能力大部分时间都是处于闲置状态,那么我们可不可以将峰值集中的请求分散到其他时间呢?

  1. 消息队列

消息队列除了在解耦、异步场景之外,最大的作用场景是用于流量削峰,面对海量流量请求,可以将这些请求数据用异步的方式先存放在消息队列中,而消息队列一般都能够存储大量消息,消息会被消费端订阅消费,这样就有效地将峰值均摊到其他时间进行处理了。

如上,消息队列就像我们平常见到的水库一样,当洪水来临时,拦住并对其进行储蓄,以减少对下游的冲击,避免了洪水的灾害。

目前有大量优秀的开源消息队列框架,如 RocketMQ、Kafka 等,而我之前在中通时主要负责消息平台的建设与维护工作,中通每天面对几千万的订单流量依然那么稳固,其中消息队列起了很大的“防洪”作用!

  1. 答题

除了利用消息队列对请求进行“储蓄”达到削峰的目的之外,还可以通过在用户发起请求前,对用户进行一些校验操作,比如答题、输入验证码等等,这种答题机制,除了可以防止买家在秒杀过程中使用作弊脚本之外,在秒杀场景中最主要的作还是将请求分散到各个时间点,秒杀场景一般都是集中在某个点进行,比如 0 点时刻,如果没有答题机制,几乎所有的流量都在 0 点时刻涌入服务器中,如果有答题机制,就能延缓用户的请求,从而达到请求分散到各个时间点的目的。

如何保持一致性?

秒杀场景,本质上就是在海量买家同时请求购买时,能够准确并将商品卖出去。

在秒杀的高并发读写请求过程中,需要保证商品不会发生“超卖”现象,因为秒杀的商品是数量一定的,但会有成千上万个用户在同一时间下单购买,在减扣库存过程中如何保证商品数量的准确性至关重要。

减扣库存方案分析

1、下单减扣库存

买家只要完成下单,立即减扣商品库存,这种方式实现是最简单而且也是最精准的,通常可以在下单时利用数据库事务能力即可保证减扣库存的准确性,但需要考虑买家下单后不付款的情况。

2、付款减扣库存

即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现买家下单后付不了款的情况,因为可能商品已经被其他人买走了。

当只有买家下单后,并且已完成付款,才执行库存的减扣,这种方式好处是避免了买家不付款导致实际没有卖出这么多商品的情况,但这种方式会造成用户体验不好,因为这会导致有些用户付款时商品有可能被人买走了导致付款失败的问题。

3、预扣库存

这种方式结合以上两种方式的优点,当买家下单后,预扣库存,只会其保留一定的时间,比如 10 分钟,在这段时间内如果买家不付款,则将库存自动释放,其它买家可以继续抢购。这种做法需要买家付款前,再做一次商品库是否还有保留,如果没有保留,则再次尝试预扣,预扣失败则不允许继续付款;如果有保留,付款完成后执行真正的减扣库存动作。

但预扣库存依然没有彻底解决减扣库存链路中存在的问题,比如有些买家可以在释放的瞬间立马又重新下单一次,相当于将库存无限地保留下去,因此我们还需要将记录用户下单次数,如果连续下单超过一定次数,或者超过下单并不付款次数,就拦截用户下单请求。

总结:

一般最简单的做法就是使用下单减库存的方式,因为在秒杀场景中,商品的性价比通常很高,秒杀就是创造一种只有少量买家能买到的场景,一般来说买家只要“秒”到商品了,极少情况会出现退款的,即使发生了少量退款,造成实际卖出去的商品会比数据上少,也是可以通过候补来解决。

如何减扣库存?

减扣库存动作应该放在哪里执行?

下面具体分析一下减扣库存的几种实现方式:

  1. 如果链路涉及的逻辑比较简单的,比如下单减库存这种方式,最简单的做法就是在下单时,利用数据库的本地事务机制进行对库存的减扣,比如使用 where 库存 >0不满足就回滚;
  2. 将库存数量值放在缓存中,比如 Redis,并做持久化处理。

需要注意的是,如果遇到减扣库存的逻辑很复杂,比如减扣库存之后需要在同一个事务中做一些其他事情,那么就不能使用第二种方式了,只能使用第一种方式在数据库层面上面操作,以保证同在一个事务中。面对这种情况,你可以将热点数据进行数据库隔离,把这些热点商品单独放在一个数据库中。

如何实现高可用性?

最后,为了保证秒杀系统的高可用性,必须要对系统进行兜底处理,以便遇到极端的情况系统依然能够运转,通常的做法有服务降级、服务限流、拒绝请求等方式处理。

服务降级

当请求量达到系统承受的能力时,需要对系统的一些非核心功能进行关闭操作,尽可能将资源留给秒杀核心链路。

比如在秒杀系统中,还存在其他非核心的功能,我们可以在系统中设计一些动态开关,比如在网关层在路由开关,将这些非核心的请求直接在最外层拒掉。

还有就是对页面展示的数据进行精简化,用降低用户体验换取核心链路的稳定运行。

服务限流

限流的目的是通过对并发访问/请求进行限速或者一个时间窗口内的的请求进行限速来保护系统,常用的有 QPS 限流,用户请求排队限流,需要设置过期时间,一旦超过过期时间则丢弃,这样做是为了用户请求可以做到快速失败的效果,这种机制在 RocketMQ 中也有相关的应用,RocketMQ broker 会对客户端请求进行排队限流处理,当请求在队列中超过了过期时间,则丢弃,客户端快速失败进行第二轮重试。

拒绝请求

如果服务降级、服务限流都不能解决问题,最后的兜底,那就是直接拒绝用户请求,比如直接给用户返回 “服务器繁忙,请稍后再试”等提示文案。只会发生在服务器负载过载时会启动,因此只会发生短暂不可用时刻,由于此时服务依然还在稳定运行中,等负载下降时,可以快速恢复正常服务。

在 2G 大小的文件中,找出高频 top100 的单词

  1. 把 2G 的文件进行分割成大小为 512KB 小文件,总共得到 2048 个小文件,避免一

次性读入整个文件造成内存不足。

  1. 定义一个长度为 2048 的 hash 表数组,用来统计每个小文件中单词出现的频率。
  2. 使用多线程并行遍历 2048 个小文件,针对每个单词进行 hash 取模运算分别存储

到长度为 2048 的 hash 表数组中

inthash=Math.abs(word.hashCode() %hashTableSize);

hashTables[hash].merge(word, 1, Integer::sum);

  1. 接着再遍历这 2048 个 hash 表,把频率前 100 的单词存入小顶堆中
  2. 最后,小顶堆中最终得到的 100 个单词,就是 top 100 了。

这种解决方案的核心思想是将大文件分割为多个小文件,然后采用分治和堆的算法,来

解决这个问题

设计一个发红包的API

让你设计一个微信发红包API,你会怎么设计,不能有人领到的红包里面没钱,红包数值精确到分。

如果是随机红包,根据发红包的人输入的钱数,默认精确到分,也就是0.01元。

最小的不可再分的单元就是0.01元。

如果红包的分数是10份,那么就把红包比如50元,那么可以生成1-4991的随机整数。

极端案例,一个人分得49.91元,剩余九个人每人分得0.01元。

第一个人分完,剩下的按照这个规则继续。

微信红包的规则为:

红包金额的区间为 0.01 - 平均值的2倍

该规则为 微信团队公布的算法。

这也就是说,假设给10个人发送100元的红包,那么:

第一个人得到金额的区间为[0.01,20]

假设前三个人领到的红包为50元,那么此时红包还剩下 7个人 没有领取红包,红包还剩下 50元 ,那么下一个人可以得到的最大金额为:

(100-50)/(10-3)*2=14.29

第四个人得到的金额的区间为[0.01,14.29]

实现一个登录拉黑功能,给你一批要拉黑的用户名和手机号,实现拉黑和把已经登陆的被拉黑的用户踢下线。

在Spring Boot项目中实现登录拉黑功能,可以采用以下一些思路和步骤:

1. 黑名单数据模型设计

首先,需要设计一个数据模型来存储黑名单中的用户名和手机号。

@Entity
public class Blacklist {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String phoneNumber;
// 省略其他字段和getter/setter方法
}

2. 黑名单管理接口

提供一个管理接口,允许管理员添加和删除黑名单中的用户名和手机号。

@RestController
@RequestMapping("/blacklist")
public class BlacklistController {
@Autowired
private BlacklistService blacklistService;

@PostMapping("/add")
public ResponseEntity<?> addBlacklist(@RequestBody Blacklist blacklist) {
blacklistService.addBlacklist(blacklist);
return ResponseEntity.ok().build();
}

@DeleteMapping("/remove/{id}")
public ResponseEntity<?> removeBlacklist(@PathVariable Long id) {
blacklistService.removeBlacklist(id);
return ResponseEntity.ok().build();
}
}

3. 用户认证拦截

在用户登录时,通过拦截器或过滤器检查用户是否在黑名单中。

@Component
public class BlacklistAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private BlacklistService blacklistService;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String username = ...; // 获取用户名
String phoneNumber = ...; // 获取手机号
if (blacklistService.isBlacklisted(username, phoneNumber)) {
// 用户在黑名单中,拒绝访问
response.sendError(HttpServletResponse.SC_FORBIDDEN, "账号已被拉黑");
return;
}
filterChain.doFilter(request, response);
}
}

4. 在线用户管理

对于已经登录的用户,需要有一种机制来检查他们是否被添加到黑名单中,并在必要时将其踢下线。

使用WebSocket

  • 通过WebSocket与在线用户保持连接。
  • 定时检查黑名单,如果发现在线用户被拉黑,则通过WebSocket发送下线通知。

使用轮询

  • 用户端定时发送请求到服务器,检查是否被拉黑。
  • 如果检测到用户被拉黑,服务器返回特定的响应,前端据此进行下线处理。

5. 用户会话管理

  • 使用Spring Session来管理用户会话。
  • 当用户被拉黑时,可以清除其会话。
@Component
public class BlacklistSessionListener implements SessionDestroyedEvent {
@Autowired
private SessionRepository sessionRepository;

@Override
public void onApplicationEvent(SessionDestroyedEvent event) {
String principalName = (String) event.getPrincipal();
// 根据用户名查找会话并清除
sessionRepository.deleteById(principalName);
}
}

6. 事件驱动

  • 使用事件发布/订阅模式,当黑名单更新时,发布事件。
  • 订阅者监听事件,如果发现有在线用户被拉黑,执行下线操作。

7. 前端交互

  • 前端页面需要处理来自后端的拉黑通知。
  • 可以设计一个提示框或弹窗,告知用户账号已被拉黑,并引导用户退出。

8. 安全性考虑

  • 确保黑名单数据的安全性,避免未授权访问。
  • 对敏感操作(如添加/删除黑名单)进行权限验证。

9. 性能优化

  • 考虑黑名单查询的性能,可能需要对数据库进行优化,如建立索引。
  • 对于大规模用户,考虑使用缓存来减少数据库访问。

10. 测试

  • 对所有新增的功能进行单元测试和集成测试,确保功能的正确性和稳定性。

怎么判断一个手机号是否为新用户

在Java面试中,判断一个手机号是否为新用户的问题通常涉及到数据库查询和用户管理的知识。以下是几种可能的解决方案:

1. 数据库查询

最直接的方法是在用户数据库中查询这个手机号是否存在。如果不存在,那么这个手机号就是新用户。

public boolean isNewUser(String phoneNumber) {
// 假设有一个UserRepository来访问数据库
return !userRepository.existsByPhoneNumber(phoneNumber);
}

2. 缓存检查

如果系统对性能要求较高,可以使用缓存来存储已注册的手机号。首先检查缓存,如果缓存中没有这个手机号,再查询数据库。

public boolean isNewUser(String phoneNumber) {
// 检查缓存
if (cache.containsKey(phoneNumber)) {
return false;
}
// 缓存中没有,查询数据库
return !userRepository.existsByPhoneNumber(phoneNumber);
}

3. 散列集合

在某些情况下,如果用户数据量不是非常大,可以在内存中使用HashSet来存储所有已注册的手机号。

public class UserRegistry {
private Set<String> registeredPhoneNumbers = new HashSet<>();

public boolean isNewUser(String phoneNumber) {
return !registeredPhoneNumbers.contains(phoneNumber);
}

// 其他方法,比如注册用户时更新HashSet
}

4. 异步检查

如果注册流程对实时性要求不高,可以采用异步的方式来检查手机号是否为新用户,以减少对主线程的阻塞。

public Future<Boolean> isNewUserAsync(String phoneNumber) {
// 异步执行数据库查询
return executor.submit(() -> !userRepository.existsByPhoneNumber(phoneNumber));
}

5. 批量检查

如果需要同时检查多个手机号,可以设计一个批量查询的方法,以减少数据库的访问次数。

public Map<String, Boolean> isNewUsers(Collection<String> phoneNumbers) {
Map<String, Boolean> isNewUserMap = new HashMap<>();
// 批量查询数据库
List<User> users = userRepository.findAllByPhoneNumberIn(phoneNumbers);
for (String phoneNumber : phoneNumbers) {
isNewUserMap.put(phoneNumber, !users.stream().anyMatch(user -> user.getPhoneNumber().equals(phoneNumber)));
}
return isNewUserMap;
}

6. 利用分布式系统

在分布式系统中,可以使用分布式缓存或分布式数据库来存储和查询手机号,以提高系统的扩展性和可用性。

7. 考虑数据一致性

在设计解决方案时,需要考虑数据一致性问题。例如,如果用户刚刚注册,但是数据还没有同步到所有的数据库副本或缓存节点,可能会错误地判断为新用户。

平台系统,好几个业务线,其中一个业务线消息生产过多,消息队列积压大量消息,去只想任务时大量操作在A业务里面获取,读取消息要很久,导致其他业务不能使用,如何快速处理

  1. 增加消费者线程数:

分析当前消费者线程的处理能力,适当增加消费者线程数,以加快消息的消费速度。

  1. 并发优化:

对A业务中的消息处理逻辑进行并发优化,如使用线程池、并行流等,提高消息处理的并发度。

  1. 批量获取与处理:

修改消息处理逻辑,使其能够批量获取消息并进行处理,减少每次获取和处理消息的开销。

  1. 消息过滤与优先级:

在消息队列中增加过滤机制,只处理重要或紧急的消息,暂时忽略非关键消息。

使用优先级队列,确保重要消息优先被处理。

  1. 异步处理:

如果A业务的处理逻辑不是实时性要求非常高的,可以考虑将其异步化处理,使用如Spig的

@Asyc注解或其他异步处理框架。

  1. 缓存策略:

如果八业务中存在频繁查询数据库或其他服务的操作,可以考虑使用缓存来减少查询次数,提高处

理速度。

  1. 限流与降级:

在消息生产者端实施限流策略,控制消息的生产速度。

对A业务进行降级处理,如暂时关闭一些非核心功能,确保系统整体稳定性。

  1. 队列拆分与扩容:

如果可能,将消息队列按业务线或消息类型进行拆分,降低单一队列的压力。

根据需要增加消息队列实例或调整其配置,提高队列的处理能力。

  1. 监控与告警:

加强消息队列和A业务的监控,实时观察队列长度、处理速度等指标。

设置合理的告警阈值,当出现异常时及时通知相关人员进行处理。

  1. 错误处理与重试:

完善错误处理逻辑,当消息处理失败时记录错误信息,并进行合理重试。

设置重试间隔和最大重试次数,避免无限重试导致的资源浪费和性能问题。

大型Excel上传到服务器解析到数据库的系统设计

设计一个用于大型Excel文件上传到服务器并解析到数据库的系统涉及多个步骤,需要仔细规划以确保性能和准确性。

需求分析

文件大小限制:确定系统能处理的最大文件大小。

文件格式:明确支持的Xcel文件格式(如.xs,Xsx)。

解析需求:确定需要解析的Excel内容(如特定的工作表、单元格范围等)。

数据库设计:根据Excel内容设计数据库表结构。

系统架构设计

总体流程:用户上传Ecl文件→后端接收并存储制临时文件一解析Excel内容→将数据写入数据库→清

理临时文件。

文件上传

前端:提供文件选择和上传的U界面。

后端:

接收前端上传的文件。

将文件保存到临时存储位置。

返回上传成功的响应给前端。

Excel解析

读取文件:使用Apache POI读取临时存储的Excel文件,使用easyExcel解析:利用easyExcel的流式读取特性,逐行解析Ecel内容,而不是一次性加载整个文件到内存。

解析内容:遍历工作表、行和单元格,提取需要的数据。

数据校验:对提取的数据讲行校验,确保数据的有效性和完整性

数据库写入

连接数据库:建立与数据库的连接。

批量写入:使用批处理技术将数据批量写入数据库,提高性能。

错误处理:捕获并处理数据库写入过程中可能出现的错误。

清理到临时文件

解析并写入数据库成功后,删除临时存储的Excel文件。

wx加好友怎么实时同步到好友列表

涉及到数据库操作、WebSocket、HTTP轮询或者Server-Sent Events等技术。

1.数据库模型:

首先,我们需要一个数据库来存储用户信息和好友关系。

2.添加好友请求:

当用户发起添加好友请求时,后端需要记录这个请求。

@RestController
@RequestMapping("/api/friends")
public class FriendController{
@Autowired
private FriendshipService friendshipService;
@PostMapping("/add")
public ResponseEntity<?>addFriend(@RequestBody AddFriendRequest request){
/验证请求参数
/发起添加好友请求
friendshipService.addFriendRequest(request.getFromUserId(),request.getToU
return ResponseEntity.ok(“请求已发送"):
}
}
@Service
public class FriendshipService{
@Autowired
private FriendshipRepository friendshipRepository;
public void addFriendRequest (int fromUserId,int toUserId){
Friendship friendship new Friendship(fromUserId,toUserId,"pending");
friendshipRepository.save(friendship);
//这里可以发送通知给被请求方,或者通过WebSocket推送消息
}
}

3.实时更新好友列表:

为了实时更新好友列表,可以使用VebSocketi进行双向通信。下面是一个简化的WebSocket实现:

VebSocket配置:

@Configuration
EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry){
registry.addEndpoint("/ws").withSockJS();
}
@Override
public void configureMessageBroker (MessageBrokerRegistry config){
config.enableSimpleBroker ("/topic");
config.setApplicationDestinationPrefixes("/app"):
}
}

配置WebSocket消息处理器

前端需要使用WebSocket客户端来连接到服务器,并监听/topic/friendsl以接收实时更新的好友列表。

1.用户发起好友请求

前端用户可以通过一个界面选择并发送好友请求给另一个用户。这个请求会发送到后端服务。

2.后端处理好友请求

后端服务接收到好友请求后,需要进行一系列的验证和处理,包括检查用户是否存在、是否已经发送过好友请求等。

3.存储好友请求

一旦好友请求被验证和处理,它应该被存储在数据库中,等待对方用户的确认。

4.通知对方用户

可以通过推送通知、邮件或其他方式通知对方用户收到了好友请求。

5.对方用户确认或拒绝好友请求

对方用户可以通过前端界面确认或拒绝好友请求。这一操作同样会发送到后端服务进行处理

6.更新好友关系

如果对方用户确认了好友请求,后端服务需要更新数据库中的好友关系,并可能需要触发一些额外的逻辑,比如通知新好友、发送欢迎消息等。

7.实时同步好友列表

为了实现好友列表的实时更新,可以使用WebSocket或Server–Sent Events(SSE)等技术。当好友关系发生变化时,后端服务可以主动向前端推送更新。

wx头像更换怎么实时同步更新给所有好友

在微信这样的社交应用中,当某个用户更换头像时,需要实时地将这个更新同步给该用户的所有好友。为了实现这一功能,通常需要采用以下几种关键技术:

1.消息队列:用于异步处理头像更新事件,并通知相关的服务进行同步。

2.缓存:为了减轻数据库压力和提高响应速度,通常会将好友关系和头像信息存储在缓存中。

3.WebSocket或长轮询:用于实时地将头像更新推送给好友。

以下是一个简化的实现方案:

  1. 用户上传新头像

用户通过前端界面选择并上传新的头像。前端将头像数据发送给后端。

  1. 后端处理头像更新
  • 后端接收到头像数据后,先存储到对象存储服务(如AWSS3、阿里云OSS等)或本地文件系统中,并获取头像的访问URL。
  • 更新数据库中该用户的头像URL。
  • 将头像更新事件发送到消息队列(如Kafka、RabbitMQ等)。
  1. 消息队列消费者处理头像更新事件
  • 消息队列的消费者监听头像更新事件
  • 当接收到头像更新事件时,从数据库中或缓存中获取该用户的好友列表。
  • 对于每个好友,生成一个包含新头像URL的更新消息。
  1. 将头像更新消息推送给好友
  • 如果系统使用WebSocket,则可以通过WebSocket连接将更新消息推送给在线的好友。
  • 如果好友不在线或WebSocket连接不可用,可以将更新消息存储在消息存储服务(如Redis)中,等待好友下次上线时拉取。
  • 对于使用长轮询的方式,可以在轮询请求中返回最新的更新消息
  1. 好友接收并显示新头像
  • 好友收到头像更新消息后,根据消息中的新头像URL加载并显示新头像。
  • 如果使用了缓存机制,可能还需要清理旧的头像缓存,确保显示的是最新的头像。

注意事项:

安全性:确保头像上传和处理的安全性,防止恶意文件上传和SQL注入等安全问题。

性能优化:对于大量用户和频繁的头像更新,需要优化数据库和缓存的读写性能,以及消息队列的处理速度。

容错处理:考虑在各个环节中添加适当的容错处理机制,如重试、降级等,以确保系统的稳定性和可用性。隐私保护:在处理用户头像等个人信息时,雲要尊守相关的隐私政策和法律法规

wx不同设备登录怎么保持消息一致性

  1. 用户登录与设备管理
  • 设备识别:当用户在新设备上登录时,系统应记录该设备与用户的绑定关系。可以使用设备D(如手机的MEI号、UUID等)作为唯一标识符。
  • 登录验证:确保使用安全的登录验证机制,如OAuh2.0或WT,以验证用户身份和设备合法性。
  1. 消息存储与同步
  • 消息存储:所有消息应存储在可靠的消息存储服务中,如数据库或消息队列(如Kafka、RabbitMQ等)。每条消息都应包含发送者、接收者、内容、时间戳等关键信息。
  • 消息同步机制:

当用户在新设备上登录时,系统应检查该用户的未读消息,并通过WebSocket、HTTP长轮询或推

送通知等方式将这些消息推送到新设备上。

对于实时消息,可以使用WebSocket建立持久连接,以便实时推送新消息到所有登录设备。

  1. 消息状态管理
  • 消息状态更新:当用户阅读消息时,系统应更新消息的状态(如“已读”状态)。这可以通过调用后端API来实现,API将更新数据库中消息的状态。
  • 状态同步:当消息状态更新后,系统应通知其他登录设备同步更新消息状态。这可以通过WebSocket的实时通信功能实现。
  1. 安全性考虑
  • 数据加密:传输和存储的消息应进行加密处理,以防止数据泄露。
  • 访问控制:确保只有授权的设备可以访问和接收用户的消息。使用访问令牌或身份验证机制来验证设备的合法性。
  1. 辅助措施
  • 冲突解决:在多个设备同时操作时(如同时标记消息为已读),需要实现冲突解决机制,确保数据的一致性。
  • 日志记录:记录所有与消息同步相关的操作日志,以便在出现问题时进行排查和审计。
  1. 技术和工具选择
  • 消息队列:使用高性能的消息队列服务来处理消息的存储和同步,确保消息的可靠性和实时性。
  • WebSocket:利用VebSockets实现实时通信和消息推送,确保消息的及时到达和同步。
  • 数据库:选择适合业务需求的数据库系统来存储消息和用户状态信息,确保数据的安全性和一致性

注意事项

  • 在实现过程中,需要考虑到不同网络环境和设备性能的差异,确保方案的健壮性和可扩展性。
  • 对于大量用户和高并发场景,需要进行充分的性能叙测试和优化,以确保系统的稳定性和性能。

拿一个身份信息去请求多个不同服务的积分接口,需要快速求出平均积分,如何实现?

  1. 定义积分接口:首先,我们需要定义各个服务提供的积分接口。这些接口应该有统一的请求和响应格

式,以便我们可以统一处理。

interface PointService t
CompletableFuture<Double>getPoints(String identity);
}

这里使用了CompletableFuture来代表一个异步的积分获取操作。

  1. 实现并发请求:接下来,我们可以使用Java的并发工具,如CompletableFuture或ExecutorService,来并发地请求各个服务的积分接口。
List<CompletableFuture<Double>>futures new ArrayList<>();
for (PointService service services){
futures.add(service.getPoints(identity));
}
  1. 等待所有请求完成:然后,我们需要等待所有的请求都完成。这可以通过completableFuture.allof方法实现。
CompletableFuture<Void>allFutures CompletableFuture.al10f(futures.toArray(new CompletableFuture))
a11 Futures.join()://等待所有请求完成
  1. 计算平均积分:当所有的请求都完成后,我们可以遍历futures:列表,获取每个服务的积分,并计算平均值。
List<Double>points futures.stream()
.map(CompletableFuture:join)/获取每个Futuref的结果
collect(Collectors.toList());
double sum points.stream().mapToDouble(Double:doubleValue).sum();
double average sum points.size():
  1. 异常处理:在实际的应用中,我们还需要考虑异常处理。如果某个服务请求失败,我们应该能够捕获这个异常,并记录或处理它,而不是让整个程序崩溃。这可以通过在Comp1etab1 eFuture.上添加异常处理函数来实现。
  2. 优化和扩展:这个方案是一个基本的实现,但在实际应用中可能还需要进一步优化和扩展。例如,如果服务的数量非常多,或者每个服务的响应时间很长,那么可能需要考虑使用更高效的并发控制策略,如连接池或线程池。此外,还可以考虑使用更复杂的并发控制工具,如Reactive Programming或Project Reactor,来更好地做处理并发请求和响应。

防沉迷系统时间登录的模拟

  1. 时间处理与比较:
  • 使用Java的日期和时间APl(如LocalDateTime、ZonedDateTimes等)来处理时间相关的逻辑。
  • 可以通过比较当前时间与设定的允许登录时间段来判断用户是否可以登录。
  1. 数据库管理:
  • 使用数据库(如MySQL、Oracles等)来存储用户的基本信息、登录记录以及防沉迷设置。
  • 通过查询数据库来判断用户的年龄和登录状态,以及记录用户的登录时间和行为。
  1. 认证与授权:
  • 使用JWT(JSON Web Tokens)或OAuth等认证机制来验证用户的身份和权限。
  • 对于未成年用户,可以根据其身份信息启用或调整防沉迷限制。
  1. 定时任务与调度:
  • 利用Java的定时任务框架(如Quartz、Spring Task等)来定期检查用户的登录状态和时间。
  • 可以设置定时任务来强制未成年用户在特定时间下线或限制其登绿。
  1. 日志记录与监控:
  • 使用日志框架(如Log4j、SLF4J等)记录用户的登绿行为、时间以及任何与防沉迷系统相关的操

作。

  • 通过监控工具实时监控系统的运行状态和用户行为,以便及时发现和处理异常情况。
  1. 异常处理与错误反馈:
  • 使用java的异常处理机制来处理在登录过程中可能出现的各种异常情况。
  • 对于不满足登录条件的用户,提供友好的错误提示和反馈。
  1. 缓存技术:
  • 利用缓存技术(如Redis、Memcached等)来提高系统的响应速度和性能。
  • 可以将用户的登录状态、时间等常用信息缓存起来,减少数据库访问次数。

现在有一个交易性的数据,日增长量可能达到千万级别的数据,交易完成后数据就不会发生变化了,但是会频繁地查询数据,请针对这个场景对数据库的设计和表的设计给出一些建议

  1. 表结构的设计

字段精简:只保留必要的字段,避免冗余和不必要的复杂结构。

选择合适的数据类型:使用最小的合适数据类型来存储数据,例如使用INT代替VARCHAR来存储整

数,使用DATETIME或TIMESTAMP来存储时间戳。

固定长度字段:尽量使用固定长度的字段,因为M小ySQL处理固定长度的字段更快。

  1. 索引优化

主键索引:确保表有一个唯一且简短的主键,这通常是交易引D或类似的唯一标识符。

查询优化索引:为经常用于查询的字段(如用户D、交易类型、时间等)创建索引。但要注意,

过多的索引会影响写入性能,因此需要权衡。

复合索引:如果经常按多个字段进行查询,考虑创建复合索引。

避免全表扫描:通过合适的索引来避免全表扫描,从而提高查询效率。

  1. 分区

水平分区:考虑按时间范围或某种业务逻辑对数据进行水平分区,这样可以将查询分散到不同的分

区上,提高查询效率。

  1. 读写分离

读写分离:实施读写分离策略,将读操作和写操作分散到不同的数据库实例成服务器上。可以使用

MySQL的主从复制功能来实现。

  1. 缓存

查询缓存:虽然MySQL有查询缓存功能,但在高并发和繁更新的场景下,查询缓存可能会导致性能下降。因此,需要根据实际情况决定是否启用查询缓存。

应用层缓存:使用Redis等内存数据库在应用层缓存频繁查询的数据,减少对MySQL的访问压力。

在使用数据库时,数据库的CPU使用率非常高,如何排查这个问题

一般而言,cpu使用率飙升可归纳为以下两点:

  • 大量的慢sql占用了cpu资源,拖垮了数据库,这类的慢sql常常表现为:查询的数据量过大,全表扫描、锁抢占甚至死锁、复杂查询等
  • QPS过高,本质上是数据库的承载的流量过大

慢sql优化思路

  • 1.扫描数据库记录数较多。

考虑表是否设置了合理的索引,表字段是否设置了合理的数据类型,sql是否有效的利用了索引等。

  • 2.sql中是否有做了大量的聚合、计算?

考虑将sql简化,把逻辑操作上浮到业务中去做。

  • 3.sql返回的记录数过多。 考虑分页实现,通过limit将一次请求转为多次请求。
  • 4.表中是否冗余字段过多? 表若为宽表,包含大量冗余字段,可考虑分表。
  • 5.库中是否有很多张表? 此时可考虑将表拆分到多个库中,分库。
  • 6.若库的读写较多,锁争抢激励,甚至死锁。 可考虑多库做读写分离。
  • 7.机器的本身性能较低,不符合业务需求。 可考虑机器升级了。

qps****过高优化思路。

  • 1.qps过高时,考虑是否可以使用缓存。
  • 2.使用批量操作,将多个操作合并为一次请求,但此种方式需要考虑是否可以一次批量的数据有多大,避免造成慢sql。
  • 3.考虑分库、读写分离,减少对一个机器的访问压力。
  • 4.机器升级,没什么是钱解决不了的。

实现b站弹幕设计

架构设计考虑以下几个场景:

  • 支持直播弹幕回放
  • 用户进入直播间可以推送最新几秒的弹幕数据
  • 长连模式和短连模式可以做降级切换

为了不影响读写的性能,采用读写分离架构。

  • 写服务:若不考虑历史弹幕可回放,可以直接使用 Redis 作为唯一存储。若考虑支持弹幕的回放,数据还是需要持久化,可以考虑使用 MySQL 或者 TiDB,暂且认为写入不是较大的瓶颈。如果有更高性能的写需求,HBase、OpenTSDB 等都可以解决问题。
  • 读服务:Redis 主要用于读缓存,缓存直播间最新的弹幕数据,采用直播间 ID 作为 Key。系统读服务最大 QPS = Redis 集群QPS。

Redis 存储结构选择:SortedSet。

  • 提交弹幕:ZADD,score 设置为时间戳。进一步优化可以只存储时间的 delta 值,减少数据存储量。
  • 弹幕查询:ZRANGEBYSCORE 定时轮询弹幕数据。

有什么问题?

  • 系统性能与 Redis 集群容量强相关,性能提升需要扩容 Redis,成本高。
  • Redis 重复请求较多,相同直播间会存在很多重复的轮询请求。

缓存优化

如果能让最新的实时弹幕数据都能命中本地缓存,那性能是最高的,同时大幅度降低了 Redis 的读取压力。所以弹幕读服务可以每秒轮询 Redis 数据,构建本地缓存。

热点问题:

  • 假设同时在线的直播间有 10000 个,读服务机器有 50 台,那么每秒轮询 Redis 的 QPS = 10000 * 50 = 50w,读取请求线性膨胀。
  • 本地内存的使用量也随直播间的数量增长而膨胀,每个直播间的缓存的数据量降低,导致本地缓存的命中率降低,容易导致 GC 频繁。

热点优化

如何降低本地缓存的使用量?

  • 因为火爆的直播间会占据整个平台大部分的流量,可以只针对火爆的直播间开启本地缓存。
  • 通过路由控制同一个直播间的请求分发到固定的几台机器,例如一致性 Hash 算法。通过减少读服务机器上的直播间数量,达到降低本地缓存使用量的目的。

上述方法可以有效地解决问题,但是不能解决流量不均衡的问题。不同直播间分配的机器资源不是拍脑袋定的,需要有理论依据,可以根据直播间的一些数据指标进行动态分配机器资源。

  • 增加对直播间数据指标的统计,如单机 QPS、集群 QPS、单机直播间在线数等。
  • 关于自适应的负载均衡又是一个可以深挖的话题,在这里我们讨论几个常用的方案,有可能结合起来使用效果更好。
    • 分桶:不同 QPS 的范围段划分为不同的桶,根据桶范围的不同分配的机器数量也是不同的。
    • 最大最小公平分配:根据直播间的 QPS 划分资源需求的权重比例,根据总机器的数量和权重比例来分配机器数量。
    • 启发式算法:遗传算法、蚁群算法等。

客户端长连接推送

为了保障客户端消息的推送性能和实时性,长连接基本是必备的,最新的消息可以直接采用长连接实时推送。

  • Push Server 从 Redis 中获取用户和直播间的订阅关系以及长连接信息。
  • 连接代理只负责与客户端保持长连接。
  • 海量的消息推送需要批量压缩。

弹幕回放

增加一组专门用于回放的 Redis 集群,同时增加回放的本地缓存,其余设计与上述方案保持一致。

主要讨论了以下几个点:

  • 读写分离的架构设计
  • 通过缓存优化读性能
  • 长连接的方案设计以及客户端的消息推送

消息队列,两条消息不同topic,不同时间到达,将两条消息关联起来,如何实现

  1. 消息ID与关联ID:
  • 当发送第一条消息时,生成一个唯一的关联ID(Correlation ID)。
  • 将这个关联ID作为消息的属性或头部与第一条消息一起发送。
  • 当需要发送第二条消息时,使用相同的关联D。
  • 在消息消费者端,当接收到消息时,检查关联性,并尝试将其与之前接收到的消息进行匹配。
  1. 外部存储:
  • 使用数据库或缓存系统(如Redis)作为外部存储来跟踪消息。
  • 当第一条消息到达时,将其关键信息(如消息D、时间戳等)存储在外部存储中,并使用一个唯一的键(例如基于消息的某个属性)进行索引。
  • 当第二条消息到达时,检查其是否包含与第一条消息相关联的属性。如果包含,则从外部存储中检索第一条消息的信息,并进行关联。
  1. 延迟队列或定时任务:
  • 如果第二条消息总是在第一条消息之后的一个可预测的时间窗口内到达,可以使用延迟队列或定时任务来处理。
  • 当第一条消息到达时,将其存储在某个临时位置(如数据库、内存队列等)。
  • 设置一个定时任务或延迟队列,等待一段时间后检查是否有与第一条消息相关的第二条消息。
  • 如果找到第二条消息,则将它们关联起来。
  1. 消息队列的高级功能:
  • 一些消息队列系统(如RabbitMQ、Apache Kafka等)提供了死信队列、消息重试等高级功能,可以帮助处理这种情况。
  • 例如,在RabbitMQ中,可以使用消息的TTL(Time-To-Live)和死信队列来处理超时未处理的消息
  • 在Apache Kafka中,可以使用流处理工具(如Kafka Streams或Flink)来处理跨多个topic的消息关联。
  1. 应用程序级别的关联:
  • 在应用程序级别实现消息关联逻辑。这意味着当应用程序消费消息时,它会维护一个内部状态或数据结构来跟踪和关联不同topic的消息。
  • 这需要应用程序能够处理可能的并发和状态管理问题。

一个表在不添加字段,不改动表结构的情况下,怎么增加新的属性

在Java后端开发中,如果需要在不添加字段和不改动表结构的情况下为一个表增加新的属性,可以考虑以下几种策略:

\1. 使用JSON字段:

如果数据库表中已经有一个JSON类型的字段,可以在该字段中添加新的属性。Java后端可以通过解析和修改JSON数据来实现。

\2. 使用视图(View):

在数据库中创建一个视图,该视图基于原表,并添加了需要的新属性(可能是通过计算得到的)。Java后端查询视图而非原表。

\3. 使用缓存:

在应用程序中使用缓存(如Redis)来存储额外的属性。每次表中的数据被访问或修改时,应用程序逻辑负责更新缓存中的属性。

\4. 使用中间件或服务:

创建一个中间件或服务,它在数据访问层和业务逻辑层之间工作,负责在不改变数据库表结构的情况下添加额外的属性。

\5. 使用应用程序逻辑:

在Java应用程序中添加逻辑来处理新的属性。例如,可以根据现有的数据计算新的属性值,或者从其他数据源获取这些值。

\6. 使用数据库触发器(如果适用):

如果数据库支持,可以创建触发器来自动填充额外的属性到另一个表或缓存中。但这通常需要对数据库结构有所改动。

\7. 使用数据库函数:

利用数据库的函数功能,在查询时动态计算新的属性值。

\8. 使用外部数据存储:

将新的属性存储在外部系统(如NoSQL数据库、搜索引擎等),通过应用程序逻辑来同步和查询这些数据。

\9. 使用数据库扩展(如果数据库支持):

某些数据库支持通过插件或扩展来添加新的功能,这可能允许在不改变表结构的情况下添加属性。

\10. 使用ORM框架的特性:

​ 如果使用ORM框架(如Hibernate),可以利用框架提供的特性来映射额外的属性,例如通过关联对象或动态映射。

设计一个累充奖励系统

一、系统架构设计

1.功能性需求

  • 用户充值记录:记录用户充值金额和时间。
  • 奖励规则配置:设置不同充值金额对应的奖励。
  • 奖励发放:根据用户的充值记录,自动发放奖励。

二、数据库设计

1.数据表设计

用户表(user):存储用户信息,如用户D、用户名等。

充值记录表(recharge_record砂:存储用户的充值信息,如用户ID、充值金额、充值时间等。

奖励规则表(reward_ru1e:存储奖励规则,如充值金额阈值、对应奖励等。

奖励发放记录表(reward_record):存储奖励发放记录,如用户ID、奖励内容、

发放时间等

2.索引设计

在recharge_.record表的user_id和recharge._time字段上建立索引,提高查询效率

在reward_ru1e表的充值金额阈值字段上建立索引,方便根据充值金额查找对应的奖励。

3.关联关系

通过用户lD,recharge_.recordi和reward_recor表可以与user表进行关联。

通过奖励规侧则lD,reward_record表可以与reward_rule表进行关联。

  1. 数据库性能优化:
    1. 定期优化查询语句,避免复杂的联表查询。
    2. 使用分页查询,避免一次性加载大量数据。
  2. 读写分离:
    1. 将读操作和写操作分离到不同的数据库服务器。
  3. 分库分表:
    1. 根据业务需求进行垂直或水平分库分表。
  4. 数据缓存:
    1. 对频繁访问且不常变更的数据使用缓存策略。
  5. 异步处理:
    1. 对于非实时性要求的操作,如奖励发放,可以采用异步处理。
  6. 数据备份与恢复:
    1. 定期备份数据库,确保数据安全。
  7. 高可用性:
    1. 通过主从复制、集群等方式提高数据库的可用性。
  8. 监控与告警:
    1. 实施数据库性能监控,设置阈值告警。
  9. 数据一致性:
    1. 使用事务管理确保数据操作的原子性、一致性、隔离性和持久性。

百万级别以上的数据是怎么删除的

当需要删除百万级的数据时,为了提高删除速度和减少对数据库性能的影响,可以采取以下一些优化技巧。

使用批量删除

为了减少数据库的IO开销,可以将删除操作分批进行。例如,将百万级数据分为一千个批次,每个批次删除一千行数据,可以使用循环语句结合LIMIT子句来实现。

SET autocommit=0;
SET unique_checks=0;
SET foreign_key_checks=0;

WHILE row_count > 0 DO
DELETE FROM table_name WHERE condition LIMIT 1000;
SET row_count = ROW_COUNT();
COMMIT;
END WHILE;

SET autocommit=1;
SET unique_checks=1;
SET foreign_key_checks=1;

在上面的示例中,我们关闭了自动提交(autocommit)、唯一键检查(unique_checks)和外键检查(foreign_key_checks)来优化删除过程。同时,采用循环的方式,每次删除一千行数据,并在每次删除后提交事务,直到所有的数据都被删除完。

禁止触发器和索引

删除大规模数据时,触发器和索引的维护可能会导致性能下降。为了提高删除速度,可以暂时禁止触发器和索引的维护,删除完成后再重新启用。可以使用以下语句来禁止和启用触发器和索引的维护。

-- 禁止触发器
SET session trigger_definer="DEFINER=your_definer_name@localhost";

-- 禁止索引维护
SET session sql_log_bin=0;
ALTER TABLE table_name DISABLE KEYS;

-- 启用索引维护
ALTER TABLE table_name ENABLE KEYS;
SET session sql_log_bin=1;

-- 启用触发器
SET session trigger_definer="";

使用分区表

对于分区表来说,删除数据只需删除相应分区即可,这样就避免了扫描整个表的操作,提高了删除速度。可以使用以下语句删除分区表中的数据。

ALTER TABLE table_name DROP PARTITION partition_name;

需要注意的是,使用分区表需要提前设计好分区策略,合理设置分区数量和范围,以便更好地支持删除操作。

其他优化技巧

  • 如果删除的数据涉及外键约束,可以先禁用外键约束再进行删除操作,以避免约束检查的开销。
  • 使用合适的索引,能够加快删除操作的速度。
  • 选择非繁忙时段进行删除操作,以避免对正常业务操作的影响。

多人调用请求,如果某一个人的请求发生了错误,如何具体定位到哪个人的调用出现了错误

  1. 请求跟踪ID:为每个请求生成一个唯一的跟踪ID(Trace ID),并将其存储在日志中。这样,即使在分布式系统中,也能追踪到请求的完整路径。
  2. 用户会话标识:使用用户会话标识(如Session ID或JWT Token)来关联请求和用户。确保每个请求都包含这个标识,并在日志中记录。
  3. 日志记录:使用日志框架(如Log4j、SLF4J等)记录详细的请求日志。日志应包括时间戳、请求ID、用户会话标识、请求的URL、方法、IP地址、错误信息等。
  4. 异常捕获:在代码中使用try-catch块捕获异常,并记录异常信息。确保异常信息中包含足够的上下文,以便于问题定位。
  5. 错误码和消息:为不同的错误类型定义错误码和错误消息,这样在日志中可以快速识别错误类型。
  6. **中间件/**拦截器:使用中间件或拦截器来处理请求和响应,记录每个请求的开始和结束时间,以及任何异常。
  7. 分布式****跟踪:如果系统是分布式的,可以使用分布式跟踪系统(如Zipkin、Jaeger等)来跟踪请求在各个服务之间的流动。
  8. 性能监控:使用性能监控工具(如New Relic、Datadog等)来监控服务的性能和健康状态。
  9. 错误报告工具:集成错误报告工具(如Sentry、Rollbar等),它们可以捕获异常并提供详细的错误报告。
  10. 审计日志:对于敏感操作,记录审计日志,包括操作人、操作时间、操作内容等信息。
  11. 用户反馈:允许用户提供反馈,这可以作为定位问题的辅助手段。
  12. 测试和验证:通过自动化测试(单元测试、集成测试等)来验证代码的正确性,减少错误发生的概率。
  13. 代码审查:定期进行代码审查,以发现潜在的错误和性能问题。
  14. 文档化:确保所有的错误处理和日志记录策略都有文档记录,以便团队成员理解和遵循

支付系统该如何设计

支付系统总览

img

核心系统解析

交易核心

交易核心把公司的业务系统和底层支付关联起来,让业务系统专注于业务,不必关心底层支付。

img

基础交易类型抽象

img

多表聚合 & 订单关联

img

支付核心

支付核心主要负责将多种支付类型进行抽象,变成充值、提现、退款、转账四种支付形态。同时,还要负责集成多种支付工具,对支付指令进行编排等等。

img

支付行为编排

其目的,是实现插件式开发、支付规则可配置的 灵活开发方式

img

异常处理

异常处理包括了 重复支付、部分支付、金额不一致、其他异常等异常场景。

img

渠道网关

img

资金核算

img

服务治理

平台统一上下文

通过确定系统边界、业务建模拆分之后,整个支付平台被拆分几十个服务,而如何保障在服务间流转业务信息不被丢失,是我们需要考虑的问题。平台统一上下文的要素信息(唯一业务标识码),在整个支付平台链路中全程传递,被用来解决这个问题。

数据一致性治理

大型的支付公司,内部都有非常严格和完备的数据一致性方案,比如采用业务侵入性非常大的分布式事务等,以牺牲开发效率来提升数据的稳定,是非常有必要的。而业务公司,如果不采用分布式事务又有哪些应对策略呢?

CAS 校验

img

幂等 & 异常补偿

img

对账

img

准实时对账

img

DB 拆分

img

异步化

支付是整个交易链路的核心环节,那么,怎么兼顾支付系统的稳定性和执行效率呢?是异步化。

消息异步化

img

外部支付调用异步化

img

这种同步调用的情况下,由于需要跨外部网络,响应的 RT 会非常长,可能会出现跨秒的情况。由于是同步调用,会阻塞整个支付链路。一旦 RT 很长且 QPS 比较大的情况下,服务会整体 hold 住,甚至会出现拒绝服务的情况。

img

因此,可以拆分获取凭证的操作,通过独立网关渠道前置服务,将获取的方式异步化,从前置网关获取内部凭证,然后由前置网关去异步调用第三方。

异步并行化

img

资金核算异步化

img

热点账户账务单独处理

img

记账事务切分

img

Redis中存储的value>50MB会出现什么问题?该如何解决

  1. 内存消耗过多:Redis是一个内存数据库,大value会占用大量的内存空间,可能导致内存迅速耗尽,影响其他key-value对的存储和性能。
  2. 网络传输效率下降:当需要通过网络传输大value时,会占用更多的网络带宽和传输时间,可能号致网络延迟或拥堵。
  3. 性能下降:读取、写入或操作大value需要更多的CPU时间和内存资源,可能导致Redis的响应延迟和整体性能下降。
  4. 持久化问题:如果启用了Redis的持久化(如RDB或AOF),大value可能导致特久化操作变慢,甚至可能因磁盘空间不足而失败。
  5. 数据安全问题:在某些情况下,如果Redis实例崩渍,大value可能无法完整恢复,从而导致数据丢失或损坏。

解决方案:

  1. 数据拆分:将大value:拆分成多个小value进行存储。例如,可以将一个大对象拆分成多个字段或子对象,分别作为不同的key进行存储。
  2. 使用适当的数据结构:根据数据的特性选择合适的Redis数据结构,如Hash、List、Set或Sorted Set,以便更有效地存储和检索数据。
  3. 外部存储与Redisi配合:对于非常大的数据,可以考虑使用外部存储系统(如分布式文件系统、对象存储或数据库)来存储实际的数据内容,并在Redis中仅保存指向这些数据的引用或标识符。
  4. 监控与报警:设置内存使用阈值,并监控Redis实例的内存使用情况。当内存使用接近阈值时,发报警机制,以便及时采取措施避免内存耗尽。
  5. 优化持久化策略:根据实际情况调整RDB或AOF的持久化配置,如调整持久化频率、使用增量持久化等方式来减少持久化对大value的影响。
  6. 备份与恢复策略:定期备份Redis数据,并制定完善的恢复策略,以便在出现数据丢失或损坏时能够迅速恢复。

设计注册中心,心跳机制如何维护和考虑设计

需要考虑的点:

服务注册 注册表结构设计 服务发现 服务订阅 服务推送 健康检查 集群同步:设计到数据同步,数据同步我们有哪些协议 raft 、distro、ZAB

选型

注册中心作为一个服务注册和发现的服务,必须是高可用的,所以应该是AP****模型;

1、注册中心提供节点信息的存储与扩展,使用mysql持久化,redis做缓存

2、提供权重设置,分组设置,可区分核心应用和非核心应用调用不同的分组,互不干扰

3、基于netty通过tcp协议,提供者和消费者与注册中心保持长连接

4、注册中心集群节点间可采用gossip协议维护集群节点的自动发现、转移和心跳

我们需要解决如下几个问题:

  • 服务如何注册
  • consumer如何知道provider
  • 服务注册中心如何高可用
  • 服务上下线,消费端如何动态感知

服务注册

服务列表保存通常有三种方式:本地内存、数据库、第三方缓存系统注册上去后,consumer需要服务地址的时候,就可以用相应key去注册中心获取对应的服务列表。

同一个服务注册中心,我们可以注册多个服务,比如用户服务、商品服务、订单服务…

服务消费

consumer端通过key获取指定的服务地址列表。

简单来说,我们就是引用了一个第三方的服务来存放我们的服务提供者列表。并且以key-value的形式存储,key我们可以理解为服务名称,value就是服务实例列表。

注册中心高可用

高可用无非就是做集群,我们可以对注册中心部署多个节点。在消费端consumer只需要知道一个服务注册中心集群地址cluster-url即可。

动态感知服务上下线

consumer拿到服务列表后,会把服务列表保存起来,保存到本地缓存里。

consumer通过一定的负载均衡算法,选择出一个地址,最后发起远程的调用。

如果我们的服务节点挂掉一个了,怎么办?

此时,服务注册中心的服务列表还是之前的列表,如果consumer调用到过掉的节点上,那岂不是会出问题呀。

所以,我们的服务注册中心需要知道哪个服务节点挂了,然后从对应服务列表里删除。

有种办法叫做心跳检测heartBeat,即就是服务注册中心,每隔一定时间去监测一下provider,如果监测到某个服务挂了,那就把对应服务地址从服务列表中删除。

可是不对呀,此时consumer端本地列表里还有过掉的服务地址,怎么办呢?

或者是,在增加一个新的服务节点

对于服务注册中心来说,就是服务列表里增加一个服务地址。

但是在消费端存在同样的问题,就是服务注册中心的服务列表和consumer端的服务列表不一样了。

如何让consumer端也动态感知呢?

其实很简单,此时,我们得思维换一下,因为consumer的服务列表是来自于服务注册中心,我们就可以把consumer理解为消费端,服务注册中心理解为服务端。此时,consumer端就可以去服务端(服务注册中心)拉取provider服务列表。

通常有两种方案:push和pull

push:服务注册中心主动推送服务列表给consumer。

pull:consumer主动从注册中心拉取服务列表。

不管是push还是pull,都会存在consumer和服务注册中心的通信管道。如果他们之间断开了,那就无法获取服务列表了。

还有就是服务注册中心知道consumer的地址

我们的网络通信,必然会存在监听的动作。

如果服务注册中心要push到consumer,此时他们之间需要建立一个会话,所以,在服务注册中心会维护一个会话管理的模块。还有一种方式就是consumer提供一个**API**,这个API给服务注册中心进行回调。

push有个不好点,那就是服务注册中心需要维护大量的会话,而且还需要对每个会话维持一个心跳,以便知晓这些会话状态,得确保这些consumer能收到数据,

另外就是pull,pull其实就相对push就简单多了。pull和我们前面说的心跳机制是类似的,consumer端启动定时任务,每个多久拉取服务注册中心的服务列表。pull也不需要去维护大量的会话,我只需要每隔多久调用接口拉取服务列表即可。但是这里还是会存在一个问题,因为是定时去拉取,所以会存在一定的数据延迟,比如consumer刚刚拉取服务列表,但就在拉取结束的后,某个服务provider挂了,consumer就要等下次拉取才知道对应服务provider挂了。

还有一种方式long-pull,也叫长****轮询,是上面两种方案的优化方案,consumer发起拉取请求时,先把这个请求hold住,当服务注册中心有发生变化后,consumer端能立马感知。

通过上面的服务注册、服务消费、注册中心高可用以及动态感知服务的上下线,这就是我们去实现一个服务注册中心的通用模型。

单核CPU,什么时候单线程任务比多线程任务更快

  1. 上下文切换开销:多线程任务需要在不同的线程之间进行上下文切换,这会产生一定的开销。如果任务是单线程的,就不需要进行上下文切换,可以减少这部分开销。
  2. 线程管理开销:操作系统需要管理多个线程,包括调度、同步和通信等,这会产生额外的开销。单线程任务则不需要这些管理开销。
  3. 任务性质:如果任务是计算密集型的,并且可以很好地利用CPU的计算资源,那么单线程可能比多线程更快,因为多线程需要额外的时间来处理线程间的同步和通信。
  4. I/O****密集型任务:对于I/O密集型任务,多线程可以提高性能,因为线程可以在等待I/O操作完成时让其他线程运行。但如果I/O操作非常快速或者任务主要是计算密集型的,那么单线程可能更高效。
  5. 线程数量:如果线程数量超过了CPU的核心数,那么额外的线程将不会带来性能上的提升,反而可能因为线程竞争和上下文切换而导致性能下降。
  6. 编程复杂性:编写和维护多线程程序通常比单线程程序要复杂得多。如果多线程程序没有正确地设计和优化,可能会导致性能问题,甚至比单线程版本更慢。
  7. 锁和同步机制:多线程程序中使用的锁和同步机制可能会成为性能瓶颈。如果锁竞争严重,或者锁的使用不当,可能会导致线程阻塞,从而降低性能。
  8. 任务的并行性:如果任务本身的并行性不高,即任务的各个部分不能很好地同时进行,那么多线程可能不会带来太大的性能提升。

第三方的接口没有访问到(支付),返回的过程没收到,怎么办

接口访问不到

在执行第三方接口调用任务时,如果遇到程序响应迟滞直至超时,或者直接抛出诸如Connection refused、Host is unreachable、SocketTimeoutException之类的网络异常情况,这明确指示了无法成功建立起与目标服务器的通信连接。产生此问题的根源可能源自于多种因素,其中包括但不限于网络状况不佳、服务器尚未启动、域名解析错误或接口地址有误等。

为应对这类问题,首要步骤是自查本地网络环境是否正常。一旦确定自身网络并无故障,可行的操作之一是运用ping命令对目标域名进行探测,以验证域名能否被正确解析并得到响应。若域名无法解析,则可能表明对方服务器DNS配置存在问题;即使域名可以解析,但如果ping测试结果显示响应异常或超时,说明目标服务端存在潜在故障。在这种情况下,及时与对方的技术团队取得联系,共享诊断信息,共同协作进行问题排查是一种有效的解决策略。

此外,我们调用第三方接口还有可能遇到以下问题:

接口突然没有返回数据/数据异常

原本正常的接口突然开始返回空数据,或者是返回的数据结构与预期不符,比如缺少必要的字段、数据格式错误、数据内容无效等,导致客户端无法正常解析和使用。

面对这类接口突然无响应或无法返回数据的问题,首先,我们需要从源头着手,全面核查请求参数和认证凭证的有效性。这包括仔细审查发送至接口的请求数据是否完整准确,以及确保使用的Token、Key等身份认证信息处于有效状态。同时,必须密切关注接口供应商是否有未提前公告的变更,如API版本升级、接口废弃等情况。

在代码实现层面上,为了能快速响应这类异常,我们应当对关键数据字段设置严格的监控与预警机制。例如,可以植入手动埋点并通过企业通讯工具(如钉钉消息、电子邮件提醒)实现即时告警。一旦监测到核心数据未能如期返回,系统应能立即发出警报,使开发人员能够在第一时间获知并处理此类问题,以防止其对整体业务流程造成干扰或经济损失。

以一个实际应用场景为例,当我们在上游系统中使用订单号向下游WMS系统查询出入库订单详情时,若发现特定订单号未能返回预期的订单信息,那么通过预先设定的监控和告警系统,我们将在第一时间接收到警告信息。在此基础上,应迅速与第三方系统的技术支持团队取得联系,查明原因并解决问题。同时,对于这类无法匹配的数据,应在业务流程中设立防护机制,及时拦截处理,以免对核心业务造成负面影响。

接口超时/异常,不稳定

由于网络抖动,或者第三方系统不稳定,部署,服务器负载不均、并发访问量过大等等问题,可能会导致调用接口时花费的时间超出预期设定的超时时间,从而引发TimeoutException;或者接收到HTTP状态码表明出现异常,如500 Internal Server Error、404 Not Found等。这种坑是我们平常最容易遇见的也是最头疼的所在,因此需要我们给予足够的重视。

对于这类异常,首先我们在调用接口时设置合理的超时时间,我们以使用Retrofit2调用http接口为例,设置其请求超时时间以及读取超时时间:

接口变更,版本迭代兼容性

第三方系统对API进行版本升级或服务调整属于常见现象,这种情况下,原有的接口可能面临无法继续使用的问题,或者返回的数据结构、格式可能发生变动,部分接口随着版本升级可能存在不向下兼容的情况,调用旧版接口在新版环境下可能失效。针对此类状况,最佳实践是始终保持对服务提供商通告的关注,一旦得知有关更新信息,应迅速作出响应,及时调整并更新调用接口的方式。在代码层面,有必要预先设计并实现一套接口版本管理和兼容性处理机制,以确保无论接口如何演变,系统都能够平滑地适应和处理。

接口变更时,采用接口参数动态化是一种有效的应对策略,其核心理念是让客户端调用接口时具备更强的灵活性和适应性,特别是在接口新增、删除或修改参数的情况下,比如采取Map,JSON接受参数(当然不是很推荐。。。。)。

并且,对接口进行严密的异常监测同样至关重要,通过实时监控接口调用的异常状况,能够在问题发生的第一时间发现并上报。及时与第三方系统的技术支持团队沟通协调,并采取相应的补救措施,能够最大限度地减少接口变动对业务连续性的影响,确保系统稳定高效运行。

API限制

在一定时间段内频繁调用接口,然后突然所有请求都开始失败,返回的错误提示可能是调用频率过高、超出配额等。这是由于大多数第三方API为了防止滥用,会对调用次数、频次或流量进行限制。我们应密切关注接口文档中的调用限制说明,并在代码中采取限流措施,如设置合适的请求间隔、使用令牌桶算法或漏桶算法控制请求速度。当然也要做好接口监控告警策略。

错误码定义混乱,字段结构不一致

我们常常会遇到接口文档与实际错误码定义、字段结构不一致的问题,例如文档中标明错误码400代表参数错误,但实际上可能收到的是404错误响应;又或者返回的数据结构与文档描述不相吻合,这使得我们难以精准识别并恰当处理结果。针对此类问题,应当采取以下策略:

首先,构建自定义错误处理机制,创建专门的错误处理类,对所有可能出现的错误码进行统一且明确的处理。这样,无论接口返回何种错误码,都能确保有一套标准的逻辑进行响应和记录。

其次,针对那些与文档描述不符或者含义模糊不清的错误码和字段,应及时与第三方系统的技术团队展开沟通交流,明确其真实含义和用途。这样的互动有助于确保接口对接的精确性,避免因对错误码或字段理解不准确而引发的系统内部错误。

对于接口文档与实际不符的情况,一方面要通过定制化的错误处理机制增强系统的容错性与一致性,另一方面要强化与第三方系统的沟通协作,确保对接接口的清晰性和准确性,从而有效避免潜在问题对自身系统产生的不良影响。

返回的数据格式不统一

对于同一个系统,接口返回的数据格式在不同场景下可能有所差异,例如有的时候返回JSON对象,有的时候却是字符串或其他格式,例如xml等。

针对这类问题,我们需要编写包容性较强的解析逻辑,确保在任何情况下都能准确解构并处理返回数据。创建多个数据模型类对应不同格式的数据,根据接口返回的内容决定使用哪个模型类进行反序列化。针对不同的数据格式编写适配器,确保数据能统一转换为应用程序可处理的格式。

如何在SpringBoot启动时执行特定代码

  1. 监听ApplicationContext事件:通过实现ApplicationListener<ContextRefreshedEvent>接口,监听ContextRefreshedEvent事件,可在Spring容器初始化完成后执行初始化逻辑。这种方式适用于需要在所有Bean加载完毕后进行全局性初始化操作的场景。
  2. 实现CommandLineRunner接口:Spring Boot启动后,会自动调用实现了CommandLineRunner接口的Bean的run方法,该方法可以处理命令行参数并执行启动时的特定操作。适用于需要根据命令行参数执行初始化逻辑或进行启动后一次性任务的情况。
  3. 实现ApplicationRunner接口:与CommandLineRunner类似,ApplicationRunner也在Spring Boot启动后执行其run方法,但其参数为ApplicationArguments,提供了更强大的命令行参数解析功能。适合处理键值对形式的命令行参数并据此执行初始化任务。
  4. 使用@PostConstruct注解:在Bean的方法上添加@PostConstruct注解,Spring会在该Bean的所有依赖注入完成后调用该方法进行初始化。这种方法用于单个Bean初始化完成后的特定逻辑,增强了代码的模块化和可维护性。
  5. @Bean注解中指定初始化方法:通过@Bean注解中的initMethod属性指定Bean的初始化方法,该方法在Bean实例化并完成注入后由Spring容器调用。这种方法适用于需要对特定Bean进行精细化初始化管理的场景。
  6. 实现InitializingBean接口:Bean实现InitializingBean接口并重写afterPropertiesSet方法,也能实现在依赖注入完成后执行初始化逻辑。虽然传统但不如使用@PostConstruct注解优雅,且增加了类的耦合度。
  7. 使用@EventListener注解:通过监听ApplicationReadyEvent等事件,可以在Spring Boot应用启动并准备就绪后执行初始化任务。这种方式延迟执行,适用于在所有Bean初始化完毕且应用已经完全启动后才需要进行的操作。

金额到底是用Long还是BigDecimal?

排除float和double

当然,对于金额,首先我们要排除的就是float和double。它们不适合用于精确的金融计算,因为float和double是基于IEEE 754标准的浮点数表示,它们无法精确地表示所有的十进制小数。这会导致在进行财务计算时出现舍入误差,这些误差可能会累积并导致不可预测的结果。

选择Long

Long类型在Java中用于存储64位整数。它的主要优点是速度快,因为整数运算在CPU层面是非常高效的。另外,Long类型也占用较少的内存,并且整数类型(BIGINT)在数据库中占用较少的存储空间。

但是Long类型在处理金额时有几个明显的缺点:

  1. 精度问题:Long只能存储整数,无法直接表示小数。使用Long来表示以分为单位的金额(例如,100表示1元),此时就会失去小数的精度。即使使用某种方式来表示小数(例如,乘以100或10000),也会遇到舍入误差的问题。并且这种计算方式也会增加计算的复杂度。
  2. 浮点数****问题:虽然这不是直接使用Long的问题,但如果你尝试将Long与浮点数(如double或float)进行转换以进行计算(比如汇率计算等),还是会遇到浮点数精度问题,这可能导致在财务计算中出现不可接受的误差。

在阿里巴巴的开发手册中建议使用Long。

但是在一些金融系统当中,对小数位要求比较高的,比如精确到小数点后6位,那么我们使用Long进行存储,每次在计算时都要除以或者乘以1000000,那么计算的开销就很大了。

并且,如果在需求确认时,我们无法知道金额要求的小数位,那我们使用Long也是不行的,我们并不知道需要乘以或者除以多少个0。

选择BigDecimal

BigDecimal是Java提供的一个类,用于任意精度的算术运算。它的主要优点是提供了高精度的计算,这对于金融和货币计算来说是非常重要的。BigDecimal可以表示任意大小的正数、负数或零,并可以精确控制舍入行为。并且在数据库中存储时也有对应的类型进行匹配,比如MySQL的DECIMAL类型提供了精确的数值存储,可以匹配BigDecimal的精度。

但是BigDecimal也有一些缺点:

  1. 性能:与Long相比,BigDecimal的性能较差。因为它的运算需要更多的内存和CPU时间。
  2. 复杂性:使用BigDecimal进行运算比使用Long或基本数据类型更复杂。你需要考虑舍入模式、精度等因素。
  3. 在数据库中需要更多的存储空间来存储小数部分。

而在Mysql的开发手册中,建议金额需要进行小数位计算时,存储要使用Decimal,否则我们要将金额乘以对应小数位的倍数变成BIGINT进行存储。

总结

基于上述对Long和BigDecimal的优缺点分析,我们可以得出以下结论:

在金额计算层面,即代码实现中,推荐使用BigDecimal进行所有与金额相关的计算。BigDecimal提供了高精度的数值运算,能够确保金额计算的精确性,避免了因浮点数精度问题导致的财务误差。使用BigDecimal可以简化代码逻辑,减少因处理精度问题而引入的复杂性。

而在数据库存储方面,我们需要根据具体需求进行权衡。如果业务需求已经明确金额只需精确到分(如某些国家/地区的货币最小单位为分),并且我们确信不会涉及到需要更高精度的小数计算,那么可以使用Long类型进行存储,将金额转换为最小货币单位(如分)进行存储。这样可以节省存储空间并提高查询性能。

但是如果业务需求中金额的小数位数不确定,或者可能涉及多位小数的计算(如国际货币交易等),那么最好使用DECIMAL或NUMERIC类型进行存储。这些类型提供了精确的数值存储,可以确保数据库中的数据与应用程序中的BigDecimal对象保持一致,避免数据转换过程中可能引入的精度损失。

记录日志功能的实现–AOP

记录日志的意义:

后台管理系统记录操作日志的意义非常重要,主要体现在以下几个方面:

1、安全性:操作日志可以记录管理员操作行为,以此来监控和防止管理员滥用权限或进行其他不当操作。如果后台管理系统没有记录操作日志,那么一旦出现不当操作,就无法对其进行追踪和定位,造成不可估量的安全风险。

2、追溯性:操作日志可以帮助管理员及时发现问题,并可以通过日志进行快速定位和处理。例如某个用户投诉自己的订单异常,管理员可以直接通过查询该订单的操作日志,找到问题所在并进行修改或解决。

因此,后台管理系统记录操作日志,对于维护系统的安全稳定性、保障客户数据的完整性和隐私性、提高系统及时响应和处理能力等方面具有重要意义,是保障企业正常运营和客户满意度的重要手段。

日志数据表结构:

CREATE TABLE `sys_oper_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志主键',
`title` varchar(50) DEFAULT '' COMMENT '模块标题',
`business_type` varchar(20) DEFAULT '0' COMMENT '业务类型(0其它 1新增 2修改 3删除)',
`method` varchar(100) DEFAULT '' COMMENT '方法名称',
`request_method` varchar(10) DEFAULT '' COMMENT '请求方式',
`operator_type` varchar(20) DEFAULT '0' COMMENT '操作类别(0其它 1后台用户 2手机端用户)',
`oper_name` varchar(50) DEFAULT '' COMMENT '操作人员',
`dept_name` varchar(50) DEFAULT '' COMMENT '部门名称',
`oper_url` varchar(255) DEFAULT '' COMMENT '请求URL',
`oper_ip` varchar(128) DEFAULT '' COMMENT '主机地址',
`oper_param` varchar(2000) DEFAULT '' COMMENT '请求参数',
`json_result` varchar(2000) DEFAULT '' COMMENT '返回参数',
`status` int DEFAULT '0' COMMENT '操作状态(0正常 1异常)',
`error_msg` varchar(2000) DEFAULT '' COMMENT '错误消息',
`oper_time` datetime DEFAULT NULL COMMENT '操作时间',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`update_time` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '删除标记(0:不可用 1:可用)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=67 DEFAULT CHARSET=utf8mb3 COMMENT='操作日志记录';

AOP记录日志的主要优点包括:

1、低侵入性:AOP记录日志不需要修改原有的业务逻辑代码,只需要新增一个切面即可。

2、统一管理:通过AOP记录日志可以将各个模块中需要记录日志的部分进行统一管理,降低了代码重复度,提高了代码可维护性和可扩展性。

3、提升效率:通过引入AOP记录日志,可以避免手动编写日志记录代码,减少了开发人员的工作量,提升了开发效率。

4、安全性:通过AOP记录日志,可以收集系统的操作日志,帮助管理员及时发现问题并进行调整,从而提高系统的安全性。

AOP记录日志的整体思想:

1、基于自定义注解来确定切入点【优势:可以通过自定义注解携带一些变化的参数,比如模块名称】

2、基于环绕通知来完成日志记录

切面类环境搭建

新建记录日志模块,模块中引入依赖

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
</dependency>

自定义Log注解

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Log { // 自定义操作日志记录注解

public String title() ; // 模块名称
public OperatorType operatorType() default OperatorType.MANAGE; // 操作人类别
public int businessType() ; // 业务类型(0其它 1新增 2修改 3删除)
public boolean isSaveRequestData() default true; // 是否保存请求的参数
public boolean isSaveResponseData() default true; // 是否保存响应的参数

}

OperatorType

操作人枚举类定义:

public enum OperatorType {                // 操作人类别
OTHER, // 其他
MANAGE, // 后台用户
MOBILE // 手机端用户
}

LogAspect

定义一个切面类,并且在该切面类中提供一个环绕通知方法

@Aspect
@Component
@Slf4j
public class LogAspect { // 环绕通知切面类定义

@Autowired
private AsyncOperLogService asyncOperLogService ;

@Around(value = "@annotation(sysLog)")
public Object doAroundAdvice(ProceedingJoinPoint joinPoint , Log sysLog) {

// 构建前置参数
SysOperLog sysOperLog = new SysOperLog() ;

LogUtil.beforeHandleLog(sysLog , joinPoint , sysOperLog) ;

Object proceed = null;
try {
proceed = joinPoint.proceed();
// 执行业务方法
LogUtil.afterHandlLog(sysLog , proceed , sysOperLog , 0 , null) ;
// 构建响应结果参数
} catch (Throwable e) { // 代码执行进入到catch中,
// 业务方法执行产生异常
e.printStackTrace(); // 打印异常信息
LogUtil.afterHandlLog(sysLog , proceed , sysOperLog , 1 , e.getMessage()) ;
throw new RuntimeException();
}

// 保存日志数据
asyncOperLogService.saveSysOperLog(sysOperLog);

// 返回执行结果
return proceed ;
}
}

EnableLogAspect

想让LogAspect这个切面类在其他的业务服务中进行使用,那么就需要该切面类纳入到Spring容器中。Spring Boot默认会扫描和启动类所在包相同包中的bean以及子包中的bean。而LogAspect切面类不满足扫描条件,因此无法直接在业务服务中进行使用。那么此时可以通过自定义注解进行实现,

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Import(value = LogAspect.class) // 通过Import注解导入日志切面类到Spring容器中
public @interface EnableLogAspect {

}

SysOperLog

定义一个与日志数据库表相对应的实体类:

@Data
public class SysOperLog extends BaseEntity {

private String title; // 模块标题
private String method; // 方法名称
private String requestMethod; // 请求方式
private String operatorType; // 操作类别(0其它 1后台用户 2手机端用户)
private Integer businessType ; // 业务类型(0其它 1新增 2修改 3删除)
private String operName; // 操作人员
private String operUrl; // 请求URL
private String operIp; // 主机地址
private String operParam; // 请求参数
private String jsonResult; // 返回参数
private Integer status; // 操作状态(0正常 1异常)
private String errorMsg; // 错误消息

}

LogAspect

添加工具类

public class LogUtil {

//操作执行之后调用
public static void afterHandlLog(Log sysLog, Object proceed,
SysOperLog sysOperLog, int status ,
String errorMsg) {
if(sysLog.isSaveResponseData()) {
sysOperLog.setJsonResult(JSON.toJSONString(proceed));
}
sysOperLog.setStatus(status);
sysOperLog.setErrorMsg(errorMsg);
}

//操作执行之前调用
public static void beforeHandleLog(Log sysLog,
ProceedingJoinPoint joinPoint,
SysOperLog sysOperLog) {

// 设置操作模块名称
sysOperLog.setTitle(sysLog.title());
sysOperLog.setOperatorType(sysLog.operatorType().name());

// 获取目标方法信息
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature() ;
Method method = methodSignature.getMethod();
sysOperLog.setMethod(method.getDeclaringClass().getName());

// 获取请求相关参数
ServletRequestAttributes requestAttributes = (ServletRequestAttributes)
RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
sysOperLog.setRequestMethod(request.getMethod());
sysOperLog.setOperUrl(request.getRequestURI());
sysOperLog.setOperIp(request.getRemoteAddr());

// 设置请求参数
if(sysLog.isSaveRequestData()) {
String requestMethod = sysOperLog.getRequestMethod();
if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod)) {
String params = Arrays.toString(joinPoint.getArgs());
sysOperLog.setOperParam(params);
}
}
sysOperLog.setOperName(AuthContextUtil.get().getUserName());
}
}

在模块中定义保存日志数据的service接口,然后在具体的业务服务中给出实现。

public interface AsyncOperLogService {                        // 保存日志数据
public abstract void saveSysOperLog(SysOperLog sysOperLog) ;
}

@Service
public class AsyncOperLogServiceImpl implements AsyncOperLogService {

@Autowired
private SysOperLogMapper sysOperLogMapper;

@Async // 异步执行保存日志操作
@Override
public void saveSysOperLog(SysOperLog sysOperLog) {
sysOperLogMapper.insert(sysOperLog);
}

}

注意:要想通过异步线程执行saveSysOperLog方法,那么此时就需要在启动类上添加**@EnableAsync**注解。

SysOperLogMapper持久层接口:

@Mapper
public interface SysOperLogMapper {
public abstract void insert(SysOperLog sysOperLog);
}

SysOperLogMapper.xml

  • 在SysOperLogMapper.xml映射文件中添加如下的SQL语句:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.lcp.lcp.mapper.SysOperLogMapper">

<insert id="insert" >
insert into sys_oper_log (
id,
title,
method,
request_method,
operator_type,
oper_name,
oper_url,
oper_ip,
oper_param,
json_result,
status,
error_msg
) values (
#{id},
#{title},
#{method},
#{requestMethod},
#{operatorType},
#{operName},
#{operUrl},
#{operIp},
#{operParam},
#{jsonResult},
#{status},
#{errorMsg}
)
</insert>

</mapper>

事务失效

当我们自定义了切面类以后,如果不注意异常的处理,那么此时就会出现事务失效的情况。

实例:

@Log(title = "角色菜单模块" , businessType = 2 )                
@Transactional
@Override
public void doAssign(AssginMenuDto assginMenuDto) {

// 根据角色的id删除其所对应的菜单数据
sysRoleMenuMapper.deleteByRoleId(assginMenuDto.getRoleId());

int a = 1 / 0 ; // 手动抛出异常

// 获取菜单的id
List<Map<String, Number>> menuInfo = assginMenuDto.getMenuIdList();
if(menuInfo != null && menuInfo.size() > 0) {
sysRoleMenuMapper.doAssign(assginMenuDto) ;
}

}

注意:不加@Log注解事务可以进行回滚,但是加上该注解以后事务就会失效。

问题分析

Spring的事务控制是通过aop进行实现的,在框架底层会存在一个事务切面类,当业务方法产生异常以后,事务切面类感知到异常以后事务进行回滚。

当系统中存在多个切面类的时候,Spring框架会按照**@Order注解的值对切面进行排序,@Order的值越小优先级越高,@Order的值越大优先级越低。优先级越高的切面类越优先执行,当我们没有给切面类指定排序值的时候,我们自定义的切面类的优先级和aop切面类的优先级相同,那么此时事务切面类的优先级要高于自定义切面类**,那么切面类的执行顺序如下所示:

当在自定义切面类中对异常进行了捕获,没有将异常进行抛出,那么此时事务切面类是感知不到异常的存在,因此事务失效。

问题解决

解决方案一:使用@Order注解提高自定义切面类的优先级

解决方案二:在自定义切面类的catch中进行异常的抛出

数据库数据批量导入es

当时想的是如果项目上线的时候,肯定要把数据库中的中药材数据全部导入到es索引库中,为了以防万一,如果数据量很大,一次性导入肯定会发生OOM,所以当时我就想到可以使用线程池的方式导入,利用CountDownLatch来控制,就能避免一次性加载过多,防止内存溢出

具体流程我画成了流程图:

暂时无法在飞书文档外展示此内容

public class ApMedicineServiceImpl{

@Autowired
private ApMedicineMapper apMedicineMapper;
@Autowired
private RestHighLevelclient client;
@Autowired
private ExecutorService executorService;
private static final String ARTICLE_ES_INDEX ="app_info_article";
private static final int PAGE_SIZE=2000;
@SneakyThrows
public void importAll(){
//总条数
int count apMedicineMapper.selectCount();
//总页数
int totalPagesize count PAGE_SIZE =0 count/PAGE_SIZE count/PAGE_SIZE +1;
//开始执行时间
Long startTime =System.currentTimeMillis();
//一共有多少页,就创建多少个CountDownLatch的计数
CountDownLatch countDownLatch =new CountDownLatch(totalPagesize);
int fromIndex;
List<SearchMedicineVo> medicineList =null;
for (int i =0;i<totalPagesize;i++){
//起始分页条数
fromIndex= i*PAGE_SIZE;
//查询文章
medicineList= apMedicineMapper.LoadMedicineList(fromIndex,PAGE_SIZE);
//创建线程,做批量插入es数据操作
TaskThread taskThread =new TaskThread(medicineList,countDownLatch);
//执行线程
executorService.execute(taskThread);
}
//调用await()方法,用来等待计数归零
countDownLatch.await();

Long endTime =System.currentTimeMillis();
log.info("es索引数据批量导入共:{}条,共消耗时间:{}秒",count,(endTime-startTime)/1000);
}

class TaskThread implementst Runnable{
List<SearchMedicineVo> medicineList;
CountDownLatch cdl;
public TaskThread(List<SearchMedicineVo> medicineList,CountDownLatch cdl){
this.medicineList =medicineList;
this.cdl=cdl;
}

public void run(){
//批量导入
BulkRequest bulkRequest new BulkRequest(ARTICLE_ES_INDEX);
for (SearchMedicineVo searchMedicineVo medicineList){
bulkRequest.add(new IndexRequest().id(searchMedicinevo.getId().tostring())
source(JSON.toJSONString(searchMedicineVo),XContentType.JSON));
}
//发送请求,批量添加数据到s索引库中
client.bulk(bulkRequest,Requestoptions.DEFAULT);
//让计数减
cdl.countDown();
}
}
}