引言
我们在书写CURD的时候,首先要考虑项目需求和项目场景,在单机模式下,我们基本的思路是在一台服务器中进行操作,我们要考虑优化数据库,我们在书写后端代码时候,也会思考要用内存查询还是sql查询亦或是同时使用,若我们要提升性能,我们不得以进行升级,使用redis缓存和自定义序列化,甚至进行缓存预热发布定时任务并要控制定时任务执行,那我们就设计到了开锁,人数再次增加,我们要进行硬件上的优化,就是出更多的经费加更多的服务器,那么我们对于后端,就要进行分布式任务,再最常用的登录注册,就要遇到分布式session,对于redis,单机缓存就无法进行,所以要使用分布式缓存和分布式锁,当然在项目中还会遇到更多的问题和项目新场景,本专栏会记录这些热点场景并持续更新
优化数据库
常用方法
合理建立索引。在合适的字段上建立索引,例如在WHERE和ORDER BY命令上涉及的列建立索引,可根据EXPLAIN来查看是否用了索引还是全表扫描
索引优化,SQL优化。索引要符合最左匹配原则等
建立分区。对关键字段建立水平分区,比如时间字段,若查询条件往往通过时间范围来进行查询,能提升不少性能
利用缓存。利用Redis等缓存热点数据,提高查询效率
限定数据的范围。比如:用户在查询历史信息的时候,可以控制在一个月的时间范围内
读写分离。经典的数据库拆分方案,主库负责写,从库负责读
通过分库分表的方式进行优化,主要有垂直拆分和水平拆分
数据异构到es
冷热数据分离。几个月之前不常用的数据放到冷库中,最新的数据比较新的数据放到热库中
升级数据库类型,换一种能兼容MySQL的数据库(OceanBase、TiDB等)
外键
外键是一种约束,这个约束的存在,会保证表间数据的关系始终完整。外键的存在,并非全然没有优点。
外键可以保证数据的完整性和一致性,级联操作方便。而且使用外键可以将数据完整性判断托付给了数据库完成,减少了程序的代码量。
虽然外键能够保证数据的完整性,但是会给系统带来很多缺陷。
并发问题。在使用外键的情况下,每次修改数据都需要去另外一个表检查数据,需要获取额外的锁。若是在高并发大流量事务场景,使用外键更容易造成死锁。
扩展性问题。比如从MySQL迁移到Oracle,外键依赖于数据库本身的特性,做迁移可能不方便。
不利于分库分表。在水平拆分和分库的情况下,外键是无法生效的。将数据间关系的维护,放入应用程序中,为将来的分库分表省去很多的麻烦。
外键只存在于关联表中
场景一:选择关联表还是添加字段
选择一:添加字段
优点:查询方便、不用新建关联表
哪怕性能低,可以用缓存。
缺点: 多一列,减少性能
选择二:加一个关联表=
关联表的应用场景:查询灵活,可以正查反查
缺点:要多建一个表、多维护一个表
重点:企业大项目开发中尽量减少关联查询,很影响扩展性,而且会影响查询性能
选择搜索方式
场景一:and和or选择
首先我们在项目中分析是一对一关系或者是一对多多对多关系时,我们建立了数据库,那么当非一对一关系进行搜索时候,我们要进行分析,我们拿经典的用户和标签这种多对多关系为例 在不建立关联表的情况下,直接根据标签查询,那么是多个标签都存在的情况下才搜索出来还是有任何一个标签存在即可搜索出来。那么我们用like模糊查询的时候,中间我们用and和or的选择,取决于上述场景。
查询方式
我们依旧使用上述经典的例子,根据标签查询用户
- sql查询(实现简单,可以通过拆分查询进一步优化)
private List<User> searchUsersByTagsBySQL(List<String> tagNameList) { if (CollectionUtils.isEmpty(tagNameList)) { //抛出异常 } QueryWrapper<User> queryWrapper = new QueryWrapper<>(); // 拼接 and 查询 // like '%Java%' and like '%Python%' for (String tagName : tagNameList) { queryWrapper = queryWrapper.like("tags", tagName); } List<User> userList = userMapper.selectList(queryWrapper); return userList.stream().map(user->{getSafetyUser(user)}).collect(Collectors.toList()); }
stream().map().collect() 是 Java 8中用于对集合进行转换和收集的常见操作。这个操作通常被称为流式操作(Stream API)。
user -> {getSafetyUser(user)} 是一个 Lambda 表达式,它接受一个参数 user,并将其传递给getSafetyUser 脱敏方法进行处理。
- 内存查询(灵活,可以通过并发进一步优化)
@Override public List<User> searchUsersByTags(List<String> tagNameList) { if (CollectionUtils.isEmpty(tagNameList)) { //抛出异常 } // 1. 先查询所有用户 QueryWrapper<User> queryWrapper = new QueryWrapper<>(); List<User> userList = userMapper.selectList(queryWrapper); Gson gson = new Gson(); // 2. 在内存中判断是否包含要求的标签 return userList.stream().filter(user -> { String tagsStr = user.getTags(); Set<String> tempTagNameSet = gson.fromJson(tagsStr, new TypeToken<Set<String>>() { }.getType()); tempTagNameSet = Optional.ofNullable(tempTagNameSet).orElse(new HashSet<>()); for (String tagName : tagNameList) { if (!tempTagNameSet.contains(tagName)) { return false; } } return true; }).map(this::getSafetyUser).collect(Collectors.toList()); }
先进行流式处理然后使用filter操作对流中的每个用户进行过滤。过滤条件是通过一个Lambda表达式定义的。Lambda表达式中的user -> { … }表示对每个用户进行操作。
程序是否能够根据用户提供的参数进行有效的判断和处理。如果参数可以被分析,那么程序可以根据参数的不同值选择不同的查询方式,以达到更好的查询效果。例如,如果用户提供了标签数作为参数,程序可以根据标签数的大小选择不同的查询方式,以提高查询效率。
如果参数不可分析,那么程序就需要采用一些其他的策略来处理查询。例如,可以并发地执行多个查询,然后选择最快返回结果的查询作为最终结果。这种策略可以在数据库连接和内存空间足够的情况下使用,以提高查询效率。
还可以 SQL 查询与内存计算相结合,比如先用 SQL 过滤掉部分 tag
后端整合 Swagger + Knife4j 接口文档
什么是接口文档?写接口信息的文档,每条接口包括:
- 请求参数
- 响应参数
- 错误码
- 接口地址
- 接口名称
- 请求类型
- 请求格式
- 备注
- 为什么要接口文档?
- 有个书面内容(背书或者归档),便于大家参考和查阅,便于 沉淀和维护 ,拒绝口口相传
- 接口文档便于前端和后端开发对接,前后端联调的 介质 。后端 => 接口文档 <= 前端
- 好的接口文档支持在线调试、在线测试,可以作为工具提高我们的开发测试效率
Swagger 原理:
引入依赖(Swagger 或 Knife4j:https://doc.xiaominfo.com/knife4j/documentation/get_start.html)
自定义 Swagger 配置类
定义需要生成接口文档的代码位置(Controller)
千万注意:线上环境不要把接口暴露出去!!!可以通过在 SwaggerConfig 配置文件开头加上 @Profile({"dev", "test"}) 限定配置仅在部分环境开启
启动即可
可以通过在 controller 方法上添加 @Api、@ApiImplicitParam(name = “name”,value = “姓名”,required = true) @ApiOperation(value = “向客人问好”) 等注解来自定义生成的接口描述信息
如果 springboot version >= 2.6,需要添加如下配置:
spring: mvc: pathmatch: matching-strategy: ANT_PATH_MATCHER
config目录
@Configuration @EnableSwagger2WebMvc @Profile({"dev", "test"}) public class SwaggerConfig { @Bean(value = "defaultApi2") public Docket defaultApi2() { return new Docket(DocumentationType.SWAGGER_2) .apiInfo(apiInfo()) .select() // 这里一定要标注你控制器的位置 .apis(RequestHandlerSelectors.basePackage("")) .paths(PathSelectors.any()) .build(); } /** * api 信息 * @return */ private ApiInfo apiInfo() { return new ApiInfoBuilder() .title("") .description("") .termsOfServiceUrl("") .contact(new Contact("")) .version("1.0") .build(); } }
session共享——分布式
用户在 A 登录,所以 session(用户登录信息)存在了 A 上,结果请求 B 时,B 没有用户信息,所以不认识。
解决方案:将登录信息存储到都能找到的地方
用户信息的读取,登录的判断很频繁,所以我们选择redis,redis基于内存,读写性能很高
操作方法
配置好reids后
spring: # redis 配置 redis: port: 6379 host: localhost database: 0
引入spring-session-data-redis
再改个配置
序列化器——自定义序列化
@Configuration public class RedisTemplateConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(connectionFactory); redisTemplate.setKeySerializer(RedisSerializer.string()); return redisTemplate; } }
RedisTemplate 是 Spring Data Redis 提供的一个用于操作 Redis 数据库的模板类,它封装了 Redis
的常用操作,提供了更加方便和易用的 API。在这个配置类中,我们创建了一个 RedisTemplate
实例,并设置了它的连接工厂和键序列化器。这样,我们就可以在其他的 Spring Bean 中注入 RedisTemplate
实例,然后使用它来进行 Redis 数据库的操作。
进一步优化——缓存预热
问题描述:第一次登录还是很慢
解决方案:缓存预热
优点:
解决上面的问题,可以让用户始终访问很快
缺点:
增加开发成本(你要额外的开发、设计)
预热的时机和时间如果错了,有可能你缓存的数据不对或者太老
需要占用额外空间
注意点:
缓存预热的意义(新增少、总用户多)
缓存的空间不能太大,要预留给其他缓存空间
缓存数据的周期(此处每天一次)
操作思路
- 定时
// 每天执行,预热推荐用户 @Scheduled(cron = "0 31 0 * * *")
- 模拟触发
使用定时任务
- Spring Scheduler(spring boot 默认整合了)
- Quartz(独立于 Spring 存在的定时任务框架)
- XXL-Job 之类的分布式任务调度平台(界面 + sdk)
- 在定时任务中,同一时间段只有某些线程用户能访问到服务器—— 锁
再一次优化——分布式锁
当用户访问量增大到不得以添加服务器时,我们就要考虑分布式任务
也就是说在前面的情况是在一个JVM的情况,当我们有一台服务器的情况下就在定好的时间中执行一次,若我们有一百台服务器时,我们就要执行一百次,那我们如何让一百台服务器只执行一次呢?
分布式锁
为啥需要分布式锁?
- 有限资源的情况下,控制同一时间(段)只有某些线程(用户 / 服务器)能访问到资源。
- 单个锁只对单个 JVM 有效
分布式锁实现的关键
抢锁机制
怎么保证同一时间只有 1 个服务器能抢到锁?
核心思想 就是:先来的人先把数据改成自己的标识(服务器 ip),后来的人发现标识已存在,就抢锁失败,继续等待。
等先来的人执行方法结束,把标识清空,其他的人继续抢锁。
我们使用的是redis进行锁
注意事项
- 用完锁要释放(腾地方)√
- 锁一定要加过期时间 √
- 如果方法执行时间过长,锁提前过期了?
可能会出现多个线程同时获取到锁的情况,从而导致数据不一致或者并发问题。
方案:
延长锁的过期时间:可以根据方法执行时间的预估值,将锁的过期时间设置为方法执行时间的两倍或三倍,以确保方法执行完毕后锁还未过期。
使用续租机制:在获取锁的同时,启动一个定时任务,定时续租锁的过期时间,确保锁在方法执行期间不会过期。
使用分布式计数器:在获取锁之前,先获取一个分布式计数器的值,执行完方法后再将计数器的值减一,只有当计数器的值为 0 时才释放锁,这样可以确保锁的释放时间不会早于方法执行时间。
释放锁的时候,有可能先判断出是自己的锁,但这时锁过期了,最后还是释放了别人的锁
这种情况可能会发生,因为在分布式环境下,网络延迟等因素可能导致锁的过期时间被延长,从而导致其他线程或进程获取到了已经过期的锁。为了避免这种情况,可以在释放锁之前再次验证锁的持有者是否是当前线程或进程。
Redis 如果是集群(而不是只有一个 Redis),如果分布式锁的数据不同步怎么办?
Redisson 的基于 Redis 集群的分布式锁实现方式会自动进行数据同步,确保不同的 Redis 节点之间的数据一致性。当一个Redis 节点上的锁被释放时,Redisson 会自动将释放锁的消息广播到其他 Redis 节点,以便其他节点能够及时更新锁的状态。
示例代码
@Component @Slf4j public class PreCacheJob { @Resource private UserService userService; @Resource private RedisTemplate<String, Object> redisTemplate; @Resource private RedissonClient redissonClient; // 重点用户 private List<Long> mainUserList = Arrays.asList(1L); // 每天执行,预热推荐用户 @Scheduled(cron = "0 31 0 * * *") public void doCacheRecommendUser() { RLock lock = redissonClient.getLock("xx:precachejob:docache:lock"); try { // 只有一个线程能获取到锁 if (lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) { System.out.println("getLock: " + Thread.currentThread().getId()); for (Long userId : mainUserList) { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); Page<User> userPage = userService.page(new Page<>(1, 20), queryWrapper); String redisKey = String.format("xx:user:recommend:%s", userId); ValueOperations<String, Object> valueOperations = redisTemplate.opsForValue(); // 写缓存 try { valueOperations.set(redisKey, userPage, 30000, TimeUnit.MILLISECONDS); } catch (Exception e) { log.error("redis set key error", e); } } } } catch (InterruptedException e) { log.error("doCacheRecommendUser error", e); } finally { // 只能释放自己的锁 if (lock.isHeldByCurrentThread()) { System.out.println("unLock: " + Thread.currentThread().getId()); lock.unlock(); } } } }
具体解释如下:
@Component 注解表示这是一个 Spring Bean,会被 Spring 自动扫描并注入到容器中。
@Slf4j 注解表示使用 Lombok 自动生成日志对象 log。
@Resource 注解表示注入依赖,这里注入了 UserService、RedisTemplate 和 RedissonClient。
private List<Long> mainUserList = Arrays.asList(1L); 表示重点用户的 ID 列表,这里只有一个用户 ID。
@Scheduled(cron = "0 31 0 * * *") 表示每天 0 点 31 分执行一次定时任务。
public void doCacheRecommendUser() 是定时任务的方法。
RLock lock = redissonClient.getLock("xx:precachejob:docache:lock"); 表示获取
Redisson 的分布式锁实例,锁的名称为 xx:precachejob:docache:lock。
if (lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) 表示尝试获取锁,如果获取到了锁,则执行缓存预热的任务。
QueryWrapper<User> queryWrapper = new QueryWrapper<>(); 表示创建一个 Mybatis-Plus 的查询条件对象。
Page<User> userPage = userService.page(new Page<>(1, 20), queryWrapper); 表示调用 UserService 的 page 方法,查询用户列表。
String redisKey = String.format("xx:user:recommend:%s", userId); 表示根据用户 ID 构造 Redis 缓存的 key。
ValueOperations<String, Object> valueOperations = redisTemplate.opsForValue(); 表示获取 Redis 缓存操作对象。
valueOperations.set(redisKey, userPage, 30000, TimeUnit.MILLISECONDS); 表示将查询到的用户列表写入 Redis 缓存中,缓存有效期为 30 秒。
lock.unlock(); 表示释放锁。
总体来说,这段代码的作用是每天定时预热推荐用户的缓存,使用 Redisson 实现了分布式锁,确保只有一个线程能够执行缓存预热的任务。
也就是说至此我们解决了验证登陆注册的部分问题,那么我们在权限控制该如何处理呢
权限控制
在我们对于权限开启关闭的项目需求不是需要的时候,可以使用最传统的if方法进行判断
- 首先可以先定义个常量接口用来存储管理员和普通用户
- 在service实现类中写是否为管理员的方法
@Override public boolean isAdmin(HttpServletRequest request) { // 仅管理员可查询 Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE); User user = (User) userObj; return user != null && user.getUserRole() == UserConstant.ADMIN_ROLE; } @Override public boolean isAdmin(User loginUser) { return loginUser != null && loginUser.getUserRole() == UserConstant.ADMIN_ROLE; }
具体解释如下:
方法有两个重载,分别接收 HttpServletRequest 和 User 两个参数。
public boolean isAdmin(HttpServletRequest request) 方法接收一个 HttpServletRequest 对象,用于从 Session 中获取当前登录用户信息。
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE); 表示从 Session
中获取名为 USER_LOGIN_STATE 的属性,该属性存储了当前登录用户的信息。
User user = (User) userObj; 表示将获取到的用户信息转换为 User 对象。
return user != null && user.getUserRole() == UserConstant.ADMIN_ROLE; 表示判断用户是否为管理员,如果用户不为空且用户角色为管理员,则返回 true,否则返回
false。
public boolean isAdmin(User loginUser) 方法接收一个 User 对象,用于判断该用户是否为管理员。
return loginUser != null && loginUser.getUserRole() == UserConstant.ADMIN_ROLE; 表示判断用户是否为管理员,如果用户不为空且用户角色为管理员,则返回 true,否则返回
false。
总体来说,这段代码是一个通用的权限控制方法,用于判断用户是否为管理员。可以在需要进行管理员权限控制的地方调用该方法,以实现权限控制的功能。
在 Spring Boot 中,常用的权限控制技术包括:
1.== Spring Security==:Spring Security 是一个功能强大的安全框架,提供了基于角色的访问控制、基于 URL 的访问控制、单点登录、OAuth2 等功能,可以很好地满足大多数应用的权限控制需求。
Shiro:Shiro 是一个轻量级的安全框架,提供了身份认证、授权、加密等功能,可以与 Spring Boot 集成,使用起来比较简单。
JWT:JWT(JSON Web Token)是一种轻量级的身份认证和授权机制,可以在客户端和服务端之间传递信息,使用起来比较方便,但需要自己实现授权逻辑。