java每日精进 3.11 【多租户】
1.多租户概念
1. 多租户是什么?
多租户,简单来说是指一个业务系统,可以为多个组织服务,并且组织之间的数据是隔离的。
例如说,在服务上部署了一个MyTanant系统,可以支持多个不同的公司使用。这里的一个公司就是一个租户,每个用户必然属于某个租户。因此,用户也只能看见自己租户下面的内容,其它租户的内容对他是不可见的。
2. 数据隔离方案
多租户的数据隔离方案,可以分成分成三种:
- DATASOURCE 模式:独立数据库
- SCHEMA(表隔离) 模式:共享数据库,独立 Schema
- COLUMN(行隔离) 模式:共享数据库,共享 Schema,共享数据表
2.1 DATASOURCE 模式
一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本也高。
- 优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。
- 缺点:增大了数据库的安装数量,随之带来维护成本和购置成本的增加。
2.2 SCHEMA(表隔离) 模式
多个或所有租户共享数据库,但一个租户一个表。
- 优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可以支持更多的租户数量。
- 缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵扯到其他租户的数据; 如果需要跨租户统计数据,存在一定困难。
2.3 COLUMN(行隔离) 模式
共享数据库,共享数据架构。租户共享同一个数据库、同一个表,但在表中通过 tenant_id
字段区分租户的数据。这是共享程度最高、隔离级别最低的模式。
- 优点:维护和购置成本最低,允许每个数据库支持的租户数量最多。
- 缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;数据备份和恢复最困难,需要逐表逐条备份和还原。
- 一般情况下,可以考虑采用 COLUMN 模式,开发、运维简单,以最少的服务器为最多的租户提供服务。
- 租户规模比较大,或者一些租户对安全性要求较高,可以考虑采用 DATASOURCE 模式,当然它也相对复杂的多。
- 不推荐采用 SCHEMA 模式,因为它的优点并不明显,而且它的缺点也很明显,同时对复杂 SQL 支持一般。
2.简单多租户实现
2.1依赖导入
<?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><groupId>org.example</groupId><artifactId>RabbitMq_Consumer</artifactId><version>1.0-SNAPSHOT</version><packaging>war</packaging><name>RabbitMq_Consumer Maven Webapp</name><!-- FIXME change it to the project's website --><url>http://www.example.com</url><!--SpringBoot依赖--><parent><groupId> org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.6.13</version></parent><properties><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><maven.compiler.source>17</maven.compiler.source><maven.compiler.target>17</maven.compiler.target></properties><dependencies><!-- Spring Boot Starter --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- Spring Data JPA --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency><!-- MySQL 驱动 --><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><!-- HikariCP 连接池 --><dependency><groupId>com.zaxxer</groupId><artifactId>HikariCP</artifactId></dependency><!-- Spring AOP --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency></dependencies></project>
2.2 application.yml
spring:datasource:url: jdbc:mysql://localhost:3306/multi_tenant_db?useSSL=false&serverTimezone=UTCusername: rootpassword: 123456hikari:maximum-pool-size: 10minimum-idle: 2jpa:hibernate:ddl-auto: updateshow-sql: true
2.1.1 spring.datasource
部分
这一部分配置了 Spring Boot 数据库连接的相关信息。
-
url
:jdbc:mysql://localhost:3306/multi_tenant_db?useSSL=false&serverTimezone=UTC
multi_tenant_db
:数据库名称,表明连接到名为multi_tenant_db
的数据库。useSSL=false
:禁用 SSL 连接。serverTimezone=UTC
:设置服务器的时区为 UTC,确保在不同时区中操作时的时间一致性。
-
username
:root
- 指定连接 MySQL 数据库时使用的用户名。这里使用的是 MySQL 默认的管理员用户
root
。
- 指定连接 MySQL 数据库时使用的用户名。这里使用的是 MySQL 默认的管理员用户
-
password
:123456
- 指定连接 MySQL 数据库时使用的密码。
-
hikari
:-
这部分是 HikariCP 连接池的配置,用于管理数据库连接池。
-
maximum-pool-size
:10
- 设置连接池中的最大连接数为 10。这意味着最多允许 10 个数据库连接同时存在。
-
minimum-idle
:2
- 设置连接池中最小的空闲连接数为 2。如果连接池中的空闲连接少于 2,HikariCP 会创建新的连接来满足最小连接数要求。
-
2.1.2 spring.jpa
部分
这一部分配置了与 JPA 相关的属性,JPA 用于在 Spring Boot 应用中执行数据库操作(例如实体类的持久化)。
-
hibernate.ddl-auto
:update
- 这个设置控制 Hibernate 的数据库模式自动更新行为。
update
表示每次应用启动时,Hibernate 会根据实体类的变化自动更新数据库模式(表结构)。如果数据库表与实体类不一致,Hibernate 会尝试调整数据库结构以匹配实体类。这对于开发阶段很有用,但生产环境中通常会设置为none
或validate
。
- 这个设置控制 Hibernate 的数据库模式自动更新行为。
-
show-sql
:true
- 设置为
true
时,Spring Boot 会将 SQL 语句打印到控制台,便于开发者查看实际执行的 SQL 语句,帮助调试和分析查询。
- 设置为
总结
- 这段配置连接到本地的 MySQL 数据库
multi_tenant_db
。 - 使用 HikariCP 连接池进行数据库连接管理,最大连接数为 10,最小空闲连接数为 2。
- 配置了 Hibernate 自动更新数据库结构,并在控制台显示 SQL 语句。、
3.复杂多租户实现
透明化的多租户能力,针对 Web、Security、DB、Redis、AOP、Job、MQ、Async 等多个层面进行封装。
3.1 创建新租户,以及其角色和对应的管理员
@PostMapping("/create")@Operation(summary = "创建租户")@PreAuthorize("@ss.hasPermission('system:tenant:create')")public CommonResult<Long> createTenant(@Valid @RequestBody TenantSaveReqVO createReqVO) {return success(tenantService.createTenant(createReqVO));}
创建租户以及其对应的角色和管理员用户
@Override@DSTransactional // 多数据源,使用 @DSTransactional 保证本地事务,以及数据源的切换public Long createTenant(TenantSaveReqVO createReqVO) {// 校验租户名称是否重复validTenantNameDuplicate(createReqVO.getName(), null);// 校验租户域名是否重复validTenantWebsiteDuplicate(createReqVO.getWebsite(), null);// 校验套餐被禁用TenantPackageDO tenantPackage = tenantPackageService.validTenantPackage(createReqVO.getPackageId());// 创建租户TenantDO tenant = BeanUtils.toBean(createReqVO, TenantDO.class);tenantMapper.insert(tenant);// 创建租户的管理员TenantUtils.execute(tenant.getId(), () -> {// 创建角色Long roleId = createRole(tenantPackage);// 创建用户,并分配角色Long userId = createUser(roleId, createReqVO);// 修改租户的管理员tenantMapper.updateById(new TenantDO().setId(tenant.getId()).setContactUserId(userId));});return tenant.getId();}
创建角色
@Override@Transactional(rollbackFor = Exception.class)@LogRecord(type = SYSTEM_ROLE_TYPE, subType = SYSTEM_ROLE_CREATE_SUB_TYPE, bizNo = "{{#role.id}}",success = SYSTEM_ROLE_CREATE_SUCCESS)public Long createRole(RoleSaveReqVO createReqVO, Integer type) {// 1. 校验角色validateRoleDuplicate(createReqVO.getName(), createReqVO.getCode(), null);// 2. 插入到数据库RoleDO role = BeanUtils.toBean(createReqVO, RoleDO.class).setType(ObjectUtil.defaultIfNull(type, RoleTypeEnum.CUSTOM.getType())).setStatus(ObjUtil.defaultIfNull(createReqVO.getStatus(), CommonStatusEnum.ENABLE.getStatus())).setDataScope(DataScopeEnum.ALL.getScope()); // 默认可查看所有数据。原因是,可能一些项目不需要项目权限roleMapper.insert(role);// 3. 记录操作日志上下文LogRecordContext.putVariable("role", role);return role.getId();}
创建用户
@Override@Transactional(rollbackFor = Exception.class)@LogRecord(type = SYSTEM_USER_TYPE, subType = SYSTEM_USER_CREATE_SUB_TYPE, bizNo = "{{#user.id}}",success = SYSTEM_USER_CREATE_SUCCESS)public Long createUser(UserSaveReqVO createReqVO) {// 1.1 校验账户配合tenantService.handleTenantInfo(tenant -> {long count = userMapper.selectCount();if (count >= tenant.getAccountCount()) {throw exception(USER_COUNT_MAX, tenant.getAccountCount());}});// 1.2 校验正确性validateUserForCreateOrUpdate(null, createReqVO.getUsername(),createReqVO.getMobile(), createReqVO.getEmail(), createReqVO.getDeptId(), createReqVO.getPostIds());// 2.1 插入用户AdminUserDO user = BeanUtils.toBean(createReqVO, AdminUserDO.class);user.setStatus(CommonStatusEnum.ENABLE.getStatus()); // 默认开启user.setPassword(encodePassword(createReqVO.getPassword())); // 加密密码userMapper.insert(user);// 2.2 插入关联岗位if (CollectionUtil.isNotEmpty(user.getPostIds())) {userPostMapper.insertBatch(convertList(user.getPostIds(),postId -> new UserPostDO().setUserId(user.getId()).setPostId(postId)));}// 3. 记录操作日志上下文LogRecordContext.putVariable("user", user);return user.getId();}
分配角色
// ========== 用户-角色的相关方法 ==========@Override@DSTransactional // 多数据源,使用 @DSTransactional 保证本地事务,以及数据源的切换@CacheEvict(value = RedisKeyConstants.USER_ROLE_ID_LIST, key = "#userId")public void assignUserRole(Long userId, Set<Long> roleIds) {// 获得角色拥有角色编号Set<Long> dbRoleIds = convertSet(userRoleMapper.selectListByUserId(userId),UserRoleDO::getRoleId);// 计算新增和删除的角色编号Set<Long> roleIdList = CollUtil.emptyIfNull(roleIds);Collection<Long> createRoleIds = CollUtil.subtract(roleIdList, dbRoleIds);Collection<Long> deleteMenuIds = CollUtil.subtract(dbRoleIds, roleIdList);// 执行新增和删除。对于已经授权的角色,不用做任何处理if (!CollectionUtil.isEmpty(createRoleIds)) {userRoleMapper.insertBatch(CollectionUtils.convertList(createRoleIds, roleId -> {UserRoleDO entity = new UserRoleDO();entity.setUserId(userId);entity.setRoleId(roleId);return entity;}));}if (!CollectionUtil.isEmpty(deleteMenuIds)) {userRoleMapper.deleteListByUserIdAndRoleIdIds(userId, deleteMenuIds);}}
3.2 租户上下文
TenantContextHolder是租户上下文,通过 ThreadLocal 实现租户编号的共享与传递。
通过调用 TenantContextHolder 的 #getTenantId()
静态方法,获得当前的租户编号。绝绝绝大多数情况下,并不需要。
/*** 多租户上下文 Holder** @author 芋道源码*/
public class TenantContextHolder {/*** 当前租户编号*/private static final ThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>();/*** 是否忽略租户*/private static final ThreadLocal<Boolean> IGNORE = new TransmittableThreadLocal<>();/*** 获得租户编号** @return 租户编号*/public static Long getTenantId() {return TENANT_ID.get();}/*** 获得租户编号。如果不存在,则抛出 NullPointerException 异常** @return 租户编号*/public static Long getRequiredTenantId() {Long tenantId = getTenantId();if (tenantId == null) {throw new NullPointerException("TenantContextHolder 不存在租户编号!可参考文档:"+ DocumentEnum.TENANT.getUrl());}return tenantId;}public static void setTenantId(Long tenantId) {TENANT_ID.set(tenantId);}public static void setIgnore(Boolean ignore) {IGNORE.set(ignore);}/*** 当前是否忽略租户** @return 是否忽略*/public static boolean isIgnore() {return Boolean.TRUE.equals(IGNORE.get());}public static void clear() {TENANT_ID.remove();IGNORE.remove();}}
3.3 Web层
默认情况下,前端的每个请求 Header 必须带上 tenant-id
,值为租户编号,即 system_tenant
表的主键编号;
如果不带该请求头,会报“租户的请求未传递,请进行排查”错误提示。
/*** 多租户 Context Web 过滤器* 将请求 Header 中的 tenant-id 解析出来,添加到 {@link TenantContextHolder} 中,这样后续的 DB 等操作,可以获得到租户编号。** @author 芋道源码*/
public class TenantContextWebFilter extends OncePerRequestFilter {@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws ServletException, IOException {// 设置Long tenantId = WebFrameworkUtils.getTenantId(request);if (tenantId != null) {TenantContextHolder.setTenantId(tenantId);}try {chain.doFilter(request, response);} finally {// 清理TenantContextHolder.clear();}}}
-
客户端发送HTTP请求,请求头中包含租户ID(例如
X-Tenant-Id: 123
)。 -
TenantContextWebFilter
拦截请求,提取租户ID并存储到TenantContextHolder
中。 -
请求进入业务逻辑层,业务代码可以通过
TenantContextHolder.getTenantId()
获取当前租户ID。 -
请求处理完成后,过滤器清理
TenantContextHolder
中的租户ID。
3.4 Security层
/*** 多租户 Security Web 过滤器* 1. 如果是登陆的用户,校验是否有权限访问该租户,避免越权问题。* 2. 如果请求未带租户的编号,检查是否是忽略的 URL,否则也不允许访问。* 3. 校验租户是合法,例如说被禁用、到期** @author 芋道源码*/
@Slf4j
public class TenantSecurityWebFilter extends ApiRequestFilter {private final TenantProperties tenantProperties;private final AntPathMatcher pathMatcher;private final GlobalExceptionHandler globalExceptionHandler;private final TenantFrameworkService tenantFrameworkService;public TenantSecurityWebFilter(TenantProperties tenantProperties,WebProperties webProperties,GlobalExceptionHandler globalExceptionHandler,TenantFrameworkService tenantFrameworkService) {super(webProperties);this.tenantProperties = tenantProperties;this.pathMatcher = new AntPathMatcher();this.globalExceptionHandler = globalExceptionHandler;this.tenantFrameworkService = tenantFrameworkService;}@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws ServletException, IOException {Long tenantId = TenantContextHolder.getTenantId();// 1. 登陆的用户,校验是否有权限访问该租户,避免越权问题。LoginUser user = SecurityFrameworkUtils.getLoginUser();if (user != null) {// 如果获取不到租户编号,则尝试使用登陆用户的租户编号if (tenantId == null) {tenantId = user.getTenantId();TenantContextHolder.setTenantId(tenantId);// 如果传递了租户编号,则进行比对租户编号,避免越权问题} else if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) {log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]",user.getTenantId(), user.getId(), user.getUserType(),TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod());ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(),"您无权访问该租户的数据"));return;}}// 如果非允许忽略租户的 URL,则校验租户是否合法if (!isIgnoreUrl(request)) {// 2. 如果请求未带租户的编号,不允许访问。if (tenantId == null) {log.error("[doFilterInternal][URL({}/{}) 未传递租户编号]", request.getRequestURI(), request.getMethod());ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(),"请求的租户标识未传递,请进行排查"));return;}// 3. 校验租户是合法,例如说被禁用、到期try {tenantFrameworkService.validTenant(tenantId);} catch (Throwable ex) {CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);ServletUtils.writeJSON(response, result);return;}} else { // 如果是允许忽略租户的 URL,若未传递租户编号,则默认忽略租户编号,避免报错if (tenantId == null) {TenantContextHolder.setIgnore(true);}}// 继续过滤chain.doFilter(request, response);}private boolean isIgnoreUrl(HttpServletRequest request) {// 快速匹配,保证性能if (CollUtil.contains(tenantProperties.getIgnoreUrls(), request.getRequestURI())) {return true;}// 逐个 Ant 路径匹配for (String url : tenantProperties.getIgnoreUrls()) {if (pathMatcher.match(url, request.getRequestURI())) {return true;}}return false;}}
-
tenantProperties
:用于获取租户相关的配置,例如忽略的 URL 列表。 -
pathMatcher
:用于匹配 URL 路径,支持 Ant 风格的路径匹配。 -
globalExceptionHandler
:全局异常处理器,用于处理校验过程中抛出的异常。 -
tenantFrameworkService
:租户服务,用于校验租户的合法性(如是否被禁用、是否到期等)。 -
构造函数接收四个参数,分别是租户配置、Web 配置、全局异常处理器和租户服务。
-
调用父类
ApiRequestFilter
的构造函数,并初始化本类的成员变量。 -
如果用户已登录(
user != null
),则进行租户权限校验:-
如果请求中没有传递租户编号,则使用登录用户的租户编号。
-
如果请求中传递了租户编号,则校验该租户编号是否与登录用户的租户编号一致。如果不一致,记录日志并返回 403 错误(无权访问)。
-
如果请求的 URL 不在忽略列表中(
!isIgnoreUrl(request)
),则进行以下校验:-
如果请求中没有传递租户编号,记录日志并返回 400 错误(请求参数错误)。
-
调用
tenantFrameworkService.validTenant(tenantId)
校验租户的合法性(如是否被禁用、是否到期等)。如果校验失败,调用全局异常处理器处理异常并返回错误信息。
-
-
如果请求的 URL 在忽略列表中,且未传递租户编号,则设置忽略租户编号(
TenantContextHolder.setIgnore(true)
)。 -
如果所有校验都通过,则继续执行过滤链中的下一个过滤器。
-
isIgnoreUrl
方法用于判断当前请求的 URL 是否在忽略列表中。 -
首先使用
CollUtil.contains
快速匹配,如果匹配成功则返回true
。 -
如果快速匹配失败,则逐个使用
AntPathMatcher
进行路径匹配。如果匹配成功,返回true
,否则返回false
。
-
3.4.1租户配置
@ConfigurationProperties(prefix = "yudao.tenant")
@Data
public class TenantProperties {/*** 租户是否开启*/private static final Boolean ENABLE_DEFAULT = true;/*** 是否开启*/private Boolean enable = ENABLE_DEFAULT;/*** 需要忽略多租户的请求** 默认情况下,每个请求需要带上 tenant-id 的请求头。但是,部分请求是无需带上的,例如说短信回调、支付回调等 Open API!*/private Set<String> ignoreUrls = Collections.emptySet();/*** 需要忽略多租户的表** 即默认所有表都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟*/private Set<String> ignoreTables = Collections.emptySet();/*** 需要忽略多租户的 Spring Cache 缓存** 即默认所有缓存都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟*/private Set<String> ignoreCaches = Collections.emptySet();}
3.4.2 Web 配置
@ConfigurationProperties(prefix = "yudao.web")
@Validated
@Data
public class WebProperties {@NotNull(message = "APP API 不能为空")private Api appApi = new Api("/app-api", "**.controller.app.**");@NotNull(message = "Admin API 不能为空")private Api adminApi = new Api("/admin-api", "**.controller.admin.**");@NotNull(message = "Admin UI 不能为空")private Ui adminUi;@Data@AllArgsConstructor@NoArgsConstructor@Validpublic static class Api {/*** API 前缀,实现所有 Controller 提供的 RESTFul API 的统一前缀*** 意义:通过该前缀,避免 Swagger、Actuator 意外通过 Nginx 暴露出来给外部,带来安全性问题* 这样,Nginx 只需要配置转发到 /api/* 的所有接口即可。** @see YudaoWebAutoConfiguration#configurePathMatch(PathMatchConfigurer)*/@NotEmpty(message = "API 前缀不能为空")private String prefix;/*** Controller 所在包的 Ant 路径规则** 主要目的是,给该 Controller 设置指定的 {@link #prefix}*/@NotEmpty(message = "Controller 所在包不能为空")private String controller;}@Data@Validpublic static class Ui {/*** 访问地址*/private String url;}}
3.4.3 全局异常处理器
GlobalExceptionHandler
/*** 处理所有异常,主要是提供给 Filter 使用* 因为 Filter 不走 SpringMVC 的流程,但是我们又需要兜底处理异常,所以这里提供一个全量的异常处理过程,保持逻辑统一。** @param request 请求* @param ex 异常* @return 通用返回*/public CommonResult<?> allExceptionHandler(HttpServletRequest request, Throwable ex) {if (ex instanceof MissingServletRequestParameterException) {return missingServletRequestParameterExceptionHandler((MissingServletRequestParameterException) ex);}if (ex instanceof MethodArgumentTypeMismatchException) {return methodArgumentTypeMismatchExceptionHandler((MethodArgumentTypeMismatchException) ex);}if (ex instanceof MethodArgumentNotValidException) {return methodArgumentNotValidExceptionExceptionHandler((MethodArgumentNotValidException) ex);}if (ex instanceof BindException) {return bindExceptionHandler((BindException) ex);}if (ex instanceof ConstraintViolationException) {return constraintViolationExceptionHandler((ConstraintViolationException) ex);}if (ex instanceof ValidationException) {return validationException((ValidationException) ex);}if (ex instanceof NoHandlerFoundException) {return noHandlerFoundExceptionHandler((NoHandlerFoundException) ex);}
// if (ex instanceof NoResourceFoundException) {
// return noResourceFoundExceptionHandler(request, (NoResourceFoundException) ex);
// }if (ex instanceof HttpRequestMethodNotSupportedException) {return httpRequestMethodNotSupportedExceptionHandler((HttpRequestMethodNotSupportedException) ex);}if (ex instanceof ServiceException) {return serviceExceptionHandler((ServiceException) ex);}if (ex instanceof AccessDeniedException) {return accessDeniedExceptionHandler(request, (AccessDeniedException) ex);}return defaultExceptionHandler(request, ex);}
上述注释说明了该方法的作用是为 Filter 提供统一的异常处理,由于 Filter 不遵循 SpringMVC 的异常处理流程,所以此方法用于兜底处理各种异常。
方法接收HttpServletRequest
类型的request
参数,代表 HTTP 请求;Throwable
类型的ex
参数,代表捕获到的异常对象。返回值类型为CommonResult<?>
,是一个通用的返回结果类。
异常类型判断及处理
if (ex instanceof MissingServletRequestParameterException) {return missingServletRequestParameterExceptionHandler((MissingServletRequestParameterException) ex);
}
/*** 处理 SpringMVC 请求参数缺失** 例如说,接口上设置了 @RequestParam("xx") 参数,结果并未传递 xx 参数*/@ExceptionHandler(value = MissingServletRequestParameterException.class)public CommonResult<?> missingServletRequestParameterExceptionHandler(MissingServletRequestParameterException ex) {log.warn("[missingServletRequestParameterExceptionHandler]", ex);return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数缺失:%s", ex.getParameterName()));}
当捕获到的异常是MissingServletRequestParameterException
(请求参数缺失异常)时,调用missingServletRequestParameterExceptionHandler
方法处理该异常,并返回处理结果。
if (ex instanceof MethodArgumentTypeMismatchException) {return methodArgumentTypeMismatchExceptionHandler((MethodArgumentTypeMismatchException) ex);
}
/*** 处理 SpringMVC 请求参数类型错误** 例如说,接口上设置了 @RequestParam("xx") 参数为 Integer,结果传递 xx 参数类型为 String*/@ExceptionHandler(MethodArgumentTypeMismatchException.class)public CommonResult<?> methodArgumentTypeMismatchExceptionHandler(MethodArgumentTypeMismatchException ex) {log.warn("[methodArgumentTypeMismatchExceptionHandler]", ex);return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数类型错误:%s", ex.getMessage()));}
若异常为MethodArgumentTypeMismatchException
(方法参数类型不匹配异常),则调用methodArgumentTypeMismatchExceptionHandler
方法处理并返回结果。
if (ex instanceof MethodArgumentNotValidException) {return methodArgumentNotValidExceptionExceptionHandler((MethodArgumentNotValidException) ex);
}
/*** 处理 SpringMVC 参数校验不正确*/@ExceptionHandler(MethodArgumentNotValidException.class)public CommonResult<?> methodArgumentNotValidExceptionExceptionHandler(MethodArgumentNotValidException ex) {log.warn("[methodArgumentNotValidExceptionExceptionHandler]", ex);FieldError fieldError = ex.getBindingResult().getFieldError();assert fieldError != null; // 断言,避免告警return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", fieldError.getDefaultMessage()));}
当遇到MethodArgumentNotValidException
(方法参数验证不通过异常),调用methodArgumentNotValidExceptionExceptionHandler
方法处理。
if (ex instanceof BindException) {return bindExceptionHandler((BindException) ex);
}
/*** 处理 SpringMVC 参数绑定不正确,本质上也是通过 Validator 校验*/@ExceptionHandler(BindException.class)public CommonResult<?> bindExceptionHandler(BindException ex) {log.warn("[handleBindException]", ex);FieldError fieldError = ex.getFieldError();assert fieldError != null; // 断言,避免告警return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", fieldError.getDefaultMessage()));}
若为BindException
(数据绑定异常),则通过bindExceptionHandler
方法处理。
if (ex instanceof ConstraintViolationException) {return constraintViolationExceptionHandler((ConstraintViolationException) ex);
}
/*** 处理 Validator 校验不通过产生的异常*/@ExceptionHandler(value = ConstraintViolationException.class)public CommonResult<?> constraintViolationExceptionHandler(ConstraintViolationException ex) {log.warn("[constraintViolationExceptionHandler]", ex);ConstraintViolation<?> constraintViolation = ex.getConstraintViolations().iterator().next();return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", constraintViolation.getMessage()));}
对于ConstraintViolationException
(约束违反异常),调用
constraintViolationExceptionHandler
方法处理。
if (ex instanceof ValidationException) {return validationException((ValidationException) ex);
}
/*** 处理 Dubbo Consumer 本地参数校验时,抛出的 ValidationException 异常*/@ExceptionHandler(value = ValidationException.class)public CommonResult<?> validationException(ValidationException ex) {log.warn("[constraintViolationExceptionHandler]", ex);// 无法拼接明细的错误信息,因为 Dubbo Consumer 抛出 ValidationException 异常时,是直接的字符串信息,且人类不可读return CommonResult.error(BAD_REQUEST);}
遇到ValidationException
(验证异常),调用validationException
方法处理。
if (ex instanceof NoHandlerFoundException) {return noHandlerFoundExceptionHandler((NoHandlerFoundException) ex);
}
/*** 处理 SpringMVC 请求地址不存在** 注意,它需要设置如下两个配置项:* 1. spring.mvc.throw-exception-if-no-handler-found 为 true* 2. spring.mvc.static-path-pattern 为 /statics/***/@ExceptionHandler(NoHandlerFoundException.class)public CommonResult<?> noHandlerFoundExceptionHandler(NoHandlerFoundException ex) {log.warn("[noHandlerFoundExceptionHandler]", ex);return CommonResult.error(NOT_FOUND.getCode(), String.format("请求地址不存在:%s", ex.getRequestURL()));}
若为NoHandlerFoundException
(未找到处理程序异常),则通过noHandlerFoundExceptionHandler
方法处理。
// if (ex instanceof NoResourceFoundException) {
// return noResourceFoundExceptionHandler(request, (NoResourceFoundException) ex);
// }
这部分代码被注释掉了,原本意图是当异常为NoResourceFoundException
(未找到资源异常)时,调用noResourceFoundExceptionHandler
方法处理,但目前不生效。
if (ex instanceof HttpRequestMethodNotSupportedException) {return httpRequestMethodNotSupportedExceptionHandler((HttpRequestMethodNotSupportedException) ex);
}
当捕获到HttpRequestMethodNotSupportedException
(HTTP 请求方法不支持异常),调用httpRequestMethodNotSupportedExceptionHandler
方法处理。
if (ex instanceof ServiceException) {return serviceExceptionHandler((ServiceException) ex);
}
若为自定的ServiceException
(服务异常),则通过serviceExceptionHandler
方法处理。
if (ex instanceof AccessDeniedException) {return accessDeniedExceptionHandler(request, (AccessDeniedException) ex);
}
/*** 处理 Spring Security 权限不足的异常** 来源是,使用 @PreAuthorize 注解,AOP 进行权限拦截*/@ExceptionHandler(value = AccessDeniedException.class)public CommonResult<?> accessDeniedExceptionHandler(HttpServletRequest req, AccessDeniedException ex) {log.warn("[accessDeniedExceptionHandler][userId({}) 无法访问 url({})]", WebFrameworkUtils.getLoginUserId(req),req.getRequestURL(), ex);return CommonResult.error(FORBIDDEN);}
遇到AccessDeniedException
(访问拒绝异常),调用accessDeniedExceptionHandler
方法处理。
默认异常处理
return defaultExceptionHandler(request, ex);
/*** 处理系统异常,兜底处理所有的一切*/@ExceptionHandler(value = Exception.class)public CommonResult<?> defaultExceptionHandler(HttpServletRequest req, Throwable ex) {// 情况一:处理表不存在的异常CommonResult<?> tableNotExistsResult = handleTableNotExists(ex);if (tableNotExistsResult != null) {return tableNotExistsResult;}// 情况二:处理异常log.error("[defaultExceptionHandler]", ex);// 插入异常日志createExceptionLog(req, ex);// 返回 ERROR CommonResultreturn CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg());}
如果上述特定的异常类型都不匹配,即遇到其他未知类型的异常,则调用defaultExceptionHandler
方法进行默认的异常处理,并返回结果。
整体来看,这段代码实现了一个较为全面的异常处理机制,根据不同的异常类型调用对应的处理方法,以保证在 Filter 中也能统一处理各种异常情况,并返回规范的结果。
3.4.4 租户服务
@RequiredArgsConstructor
public class TenantFrameworkServiceImpl implements TenantFrameworkService {private final TenantApi tenantApi;/*** 针对 {@link #getTenantIds()} 的缓存*/private final LoadingCache<Object, List<Long>> getTenantIdsCache = buildAsyncReloadingCache(Duration.ofMinutes(1L), // 过期时间 1 分钟new CacheLoader<Object, List<Long>>() {@Overridepublic List<Long> load(Object key) {return tenantApi.getTenantIdList().getCheckedData();}});/*** 针对 {@link #validTenant(Long)} 的缓存*/private final LoadingCache<Long, CommonResult<Boolean>> validTenantCache = buildAsyncReloadingCache(Duration.ofMinutes(1L), // 过期时间 1 分钟new CacheLoader<Long, CommonResult<Boolean>>() {@Overridepublic CommonResult<Boolean> load(Long id) {return tenantApi.validTenant(id);}});@Override@SneakyThrowspublic List<Long> getTenantIds() {return getTenantIdsCache.get(Boolean.TRUE);}@Override@SneakyThrowspublic void validTenant(Long id) {validTenantCache.get(id).checkError();}}
查询所有的tenentids,并放入缓存,检测时即可使用;
3.5 DB 层
基于 MyBatis Plus 的多租户功能,通过拦截器(TenantDatabaseInterceptor
)和基类(TenantBaseDO
)来实现数据库层面的多租户数据隔离。
-
多租户(Multi-tenancy):
-
多租户是一种架构模式,允许多个租户共享同一个应用程序实例,但每个租户的数据是隔离的。
-
在数据库层面,通常通过为每个表添加
tenant_id
字段来实现数据隔离。
-
-
TenantBaseDO
:-
这是一个抽象基类,用于扩展多租户功能。
-
所有需要支持多租户的实体类可以继承此类,自动获得
tenantId
字段。
-
-
TenantDatabaseInterceptor
:-
这是一个 MyBatis Plus 的拦截器,实现了
TenantLineHandler
接口。 -
它的作用是在 SQL 查询时自动添加
tenant_id
条件,并忽略某些不需要多租户处理的表。
-
/*** 基础实体对象** 为什么实现 {@link TransPojo} 接口?* 因为使用 Easy-Trans TransType.SIMPLE 模式,集成 MyBatis Plus 查询** @author 芋道源码*/
@Data
@JsonIgnoreProperties(value = "transMap") // 由于 Easy-Trans 会添加 transMap 属性,避免 Jackson 在 Spring Cache 反序列化报错
public abstract class BaseDO implements Serializable, TransPojo {/*** 创建时间*/@TableField(fill = FieldFill.INSERT)private LocalDateTime createTime;/*** 最后更新时间*/@TableField(fill = FieldFill.INSERT_UPDATE)private LocalDateTime updateTime;/*** 创建者,目前使用 SysUser 的 id 编号** 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。*/@TableField(fill = FieldFill.INSERT, jdbcType = JdbcType.VARCHAR)private String creator;/*** 更新者,目前使用 SysUser 的 id 编号** 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。*/@TableField(fill = FieldFill.INSERT_UPDATE, jdbcType = JdbcType.VARCHAR)private String updater;/*** 是否删除*/@TableLogicprivate Boolean deleted;}
/*** 拓展多租户的 BaseDO 基类** @author 芋道源码*/
@Data
@EqualsAndHashCode(callSuper = true)
public abstract class TenantBaseDO extends BaseDO {/*** 多租户编号*/private Long tenantId;}
-
@Data
:-
Lombok 注解,自动生成
getter
、setter
、toString
、equals
和hashCode
方法。
-
-
@EqualsAndHashCode(callSuper = true)
:-
Lombok 注解,表示在生成
equals
和hashCode
方法时,会考虑父类的字段。
-
-
TenantBaseDO
:-
这是一个抽象基类,继承自
BaseDO
(假设BaseDO
是一个通用的数据库实体基类)。 -
添加了
tenantId
字段,用于存储当前租户的 ID。 -
所有需要支持多租户的实体类可以继承此类。
-
/*** 基于 MyBatis Plus 多租户的功能,实现 DB 层面的多租户的功能** @author 芋道源码*/
public class TenantDatabaseInterceptor implements TenantLineHandler {private final Set<String> ignoreTables = new HashSet<>();public TenantDatabaseInterceptor(TenantProperties properties) {// 不同 DB 下,大小写的习惯不同,所以需要都添加进去properties.getIgnoreTables().forEach(table -> {ignoreTables.add(table.toLowerCase());ignoreTables.add(table.toUpperCase());});// 在 OracleKeyGenerator 中,生成主键时,会查询这个表,查询这个表后,会自动拼接 TENANT_ID 导致报错ignoreTables.add("DUAL");}@Overridepublic Expression getTenantId() {return new LongValue(TenantContextHolder.getRequiredTenantId());}@Overridepublic boolean ignoreTable(String tableName) {return TenantContextHolder.isIgnore() // 情况一,全局忽略多租户|| CollUtil.contains(ignoreTables, SqlParserUtils.removeWrapperSymbol(tableName)); // 情况二,忽略多租户的表}}
-
TenantDatabaseInterceptor
:-
这是一个 MyBatis Plus 的拦截器,实现了
TenantLineHandler
接口。 -
用于在 SQL 查询时自动添加
tenant_id
条件,并忽略某些不需要多租户处理的表。
-
-
ignoreTables
:-
一个
Set<String>
集合,用于存储不需要多租户处理的表名。 -
由于不同数据库对表名的大小写处理不同,代码中将表名转换为小写和大写并存储。
-
-
构造函数:
-
接收
TenantProperties
参数,用于获取配置中指定的忽略表。 -
将忽略的表名添加到
ignoreTables
集合中。 -
特别处理了 Oracle 数据库中的
DUAL
表,因为 MyBatis Plus 在生成主键时可能会查询此表,自动添加tenant_id
会导致错误。
-
-
getTenantId
:-
实现
TenantLineHandler
接口的方法,用于获取当前租户的 ID。 -
返回一个
Expression
对象,表示 SQL 中的租户 ID 值。 -
通过
TenantContextHolder.getRequiredTenantId()
获取当前租户 ID,并将其封装为LongValue
。
-
-
ignoreTable
:-
实现
TenantLineHandler
接口的方法,用于判断是否需要忽略某个表的多租户处理。 -
返回
true
表示忽略,false
表示不忽略。
-
-
忽略条件:
-
全局忽略多租户:
-
通过
TenantContextHolder.isIgnore()
判断是否全局忽略多租户。
-
-
忽略特定表:
-
通过
CollUtil.contains(ignoreTables, SqlParserUtils.removeWrapperSymbol(tableName))
判断表名是否在ignoreTables
集合中。 -
SqlParserUtils.removeWrapperSymbol(tableName)
用于去除表名的符号(例如反引号`
或双引号"
)。
-
-
工作流程
-
客户端发送请求,
TenantContextHolder
中存储了当前租户 ID。 -
执行 SQL 查询时,
TenantDatabaseInterceptor
拦截 SQL 并自动添加tenant_id = ?
条件。 -
如果表名在
ignoreTables
集合中,或者全局忽略多租户,则不添加tenant_id
条件。 -
查询结果返回给客户端。
3.6 Redis 层
一个支持多租户的 Redis 缓存管理器(TenantRedisCacheManager
),它在操作指定名称的缓存时,会自动将租户 ID 拼接到缓存名称中,从而实现多租户环境下的缓存隔离。
/*** 多租户的 {@link RedisCacheManager} 实现类** 操作指定 name 的 {@link Cache} 时,自动拼接租户后缀,格式为 name + ":" + tenantId + 后缀** @author airhead*/
@Slf4j
public class TenantRedisCacheManager extends TimeoutRedisCacheManager {private final Set<String> ignoreCaches;public TenantRedisCacheManager(RedisCacheWriter cacheWriter,RedisCacheConfiguration defaultCacheConfiguration,Set<String> ignoreCaches) {super(cacheWriter, defaultCacheConfiguration);this.ignoreCaches = ignoreCaches;}@Overridepublic Cache getCache(String name) {// 如果开启多租户,则 name 拼接租户后缀if (!TenantContextHolder.isIgnore()&& TenantContextHolder.getTenantId() != null&& !CollUtil.contains(ignoreCaches, name)) {name = name + ":" + TenantContextHolder.getTenantId();}// 继续基于父方法return super.getCache(name);}}
-
TenantRedisCacheManager
:-
继承自
TimeoutRedisCacheManager
,表示这是一个支持超时的 Redis 缓存管理器。
-
-
ignoreCaches
:-
一个
Set<String>
集合,用于存储不需要多租户处理的缓存名称。 -
这些缓存名称在拼接租户 ID 时会被忽略。
-
-
RedisCacheWriter cacheWriter
:Redis 缓存写入器,用于操作 Redis。 -
RedisCacheConfiguration defaultCacheConfiguration
:默认的 Redis 缓存配置。 -
Set<String> ignoreCaches
:不需要多租户处理的缓存名称集合。
-
getCache
:-
重写父类方法,用于获取指定名称的缓存。
-
如果开启了多租户功能,并且当前租户 ID 不为空,且缓存名称不在
ignoreCaches
集合中,则将租户 ID 拼接到缓存名称中。
-
-
拼接租户 ID:
-
格式为
name + ":" + tenantId
,例如userCache:123
,其中123
是租户 ID。 -
这种格式可以确保不同租户的缓存数据在 Redis 中是隔离的。
-
-
调用父类方法:
-
最终调用
super.getCache(name)
,基于拼接后的缓存名称获取缓存对象。
-
关键组件
-
TenantContextHolder
:-
一个线程上下文工具类,用于存储当前请求的租户 ID。
-
提供
getTenantId()
方法获取租户 ID。 -
提供
isIgnore()
方法判断是否全局忽略多租户。
-
-
TimeoutRedisCacheManager
:-
一个支持超时的 Redis 缓存管理器,可能是自定义的父类。
-
提供基础的缓存管理功能。
-
-
ignoreCaches
:-
用于存储不需要多租户处理的缓存名称。
-
例如,全局共享的缓存(如配置缓存)可以添加到
ignoreCaches
中。
-
工作流程
-
客户端发送请求,
TenantContextHolder
中存储了当前租户 ID。 -
在获取缓存时,
TenantRedisCacheManager
拦截缓存名称,并根据租户 ID 拼接缓存名称。 -
如果缓存名称在
ignoreCaches
集合中,或者全局忽略多租户,则不拼接租户 ID。 -
最终调用父类方法获取缓存对象。
3.7 AOP
1. @TenantIgnore
注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface TenantIgnore {
}
功能
-
这是一个自定义注解,用于标记某个方法在执行时忽略多租户的自动过滤。
-
主要适用于数据库(DB)场景,因为数据库的多租户过滤是通过在 SQL 中添加
tenant_id
条件实现的。 -
对于 Redis 和 MQ 场景,多租户的实现方式不同,因此该注解的意义有限。
注解属性
-
@Target({ElementType.METHOD})
:表示该注解只能用于方法上。 -
@Retention(RetentionPolicy.RUNTIME)
:表示该注解在运行时生效。 -
@Inherited
:表示该注解可以被子类继承。
2. TenantIgnoreAspect
切面
/*** 忽略多租户的 Aspect,基于 {@link TenantIgnore} 注解实现,用于一些全局的逻辑。* 例如说,一个定时任务,读取所有数据,进行处理。* 又例如说,读取所有数据,进行缓存。*/
@Aspect
@Slf4j
public class TenantIgnoreAspect {@Around("@annotation(tenantIgnore)")public Object around(ProceedingJoinPoint joinPoint, TenantIgnore tenantIgnore) throws Throwable {Boolean oldIgnore = TenantContextHolder.isIgnore();try {TenantContextHolder.setIgnore(true);// 执行逻辑return joinPoint.proceed();} finally {TenantContextHolder.setIgnore(oldIgnore);}}
}
功能
-
这是一个切面类,基于 Spring AOP 实现。
-
它的作用是拦截所有被
@TenantIgnore
注解标记的方法,在执行方法时临时关闭多租户过滤,方法执行完成后恢复原来的多租户过滤状态。
关键逻辑
-
@Around("@annotation(tenantIgnore)")
:-
使用
@Around
注解定义环绕通知,拦截所有被@TenantIgnore
注解标记的方法。 -
tenantIgnore
参数是@TenantIgnore
注解的实例。
-
-
保存旧状态:
-
Boolean oldIgnore = TenantContextHolder.isIgnore();
:保存当前的多租户过滤状态。
-
-
设置忽略多租户:
-
TenantContextHolder.setIgnore(true);
:临时关闭多租户过滤。
-
-
执行目标方法:
-
return joinPoint.proceed();
:执行被拦截的方法。
-
-
恢复旧状态:
-
TenantContextHolder.setIgnore(oldIgnore);
:方法执行完成后,恢复原来的多租户过滤状态。
-
3. 协同工作流程
-
标记方法:
-
在需要忽略多租户过滤的方法上添加
@TenantIgnore
注解。例如:@TenantIgnore public void processAllData() {// 读取所有租户的数据并进行处理 }
-
-
切面拦截:
-
当调用被
@TenantIgnore
注解标记的方法时,TenantIgnoreAspect
切面会拦截该方法。
-
-
临时关闭多租户过滤:
-
切面会调用
TenantContextHolder.setIgnore(true)
,临时关闭多租户过滤。
-
-
执行方法逻辑:
-
方法内部的数据库查询等操作不会自动添加
tenant_id
条件,从而可以访问所有租户的数据。
-
-
恢复多租户过滤:
-
方法执行完成后,切面会调用
TenantContextHolder.setIgnore(oldIgnore)
,恢复原来的多租户过滤状态。
-
4. 使用场景
-
定时任务:
-
某些定时任务需要读取所有租户的数据进行处理,可以使用
@TenantIgnore
注解。
-
-
缓存加载:
-
在加载全局缓存时,可能需要读取所有租户的数据,可以使用
@TenantIgnore
注解。
-
-
全局数据操作:
-
某些全局逻辑(如数据迁移、统计分析)需要访问所有租户的数据,可以使用
@TenantIgnore
注解。
-
5. 注意事项
-
Redis 和 MQ 场景:
-
该注解主要用于数据库场景,对于 Redis 和 MQ 场景,多租户的实现方式不同,因此该注解的意义有限。
-
-
线程安全性:
-
TenantContextHolder
是基于ThreadLocal
实现的,确保多线程环境下不会出现状态混乱。
-
-
与
TenantUtils#executeIgnore
的一致性:-
代码注释中提到,
TenantIgnoreAspect
的实现需要与TenantUtils#executeIgnore
保持一致,确保逻辑统一。
-
总结
-
@TenantIgnore
注解用于标记需要忽略多租户过滤的方法。 -
TenantIgnoreAspect
切面通过 AOP 拦截被注解标记的方法,在执行时临时关闭多租户过滤,执行完成后恢复原来的状态。 -
这种设计可以灵活地支持全局逻辑(如定时任务、缓存加载)访问所有租户的数据,同时确保多租户隔离的核心逻辑不受影响。
其他问题:
// RoleServiceImpl.java
public class RoleServiceImpl implements RoleService {@Resource@Lazy // 注入自己,所以延迟加载private RoleService self;@Override@PostConstruct@TenantIgnore // 忽略自动多租户,全局初始化缓存public void initLocalCache() {// ... 从数据库中,加载角色}@Scheduled(fixedDelay = SCHEDULER_PERIOD, initialDelay = SCHEDULER_PERIOD)public void schedulePeriodicRefresh() {self.initLocalCache(); // <x> 通过 self 引用到 Spring 代理对象}
}
-
@TenantIgnore
注解:-
标记
initLocalCache
方法忽略多租户过滤。 -
这样在加载角色缓存时,可以访问所有租户的数据,而不是只加载当前租户的数据。
-
-
self.initLocalCache()
:-
通过
self
调用initLocalCache
方法,确保 AOP 生效。 -
如果直接使用
this.initLocalCache()
,AOP 不会生效,因为 Spring AOP 是基于代理实现的,this
指向的是当前对象,而不是代理对象。
-
3.8 Job
多租户忽略功能,通过自定义注解 @TenantIgnore
和 Spring AOP 切面 TenantIgnoreAspect
,可以在某些方法上临时关闭多租户的自动过滤。
/*** 多租户 Job 注解*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantJob {
}
/*** 多租户 JobHandler AOP* 任务执行时,会按照租户逐个执行 Job 的逻辑* 注意,需要保证 JobHandler 的幂等性。因为 Job 因为某个租户执行失败重试时,之前执行成功的租户也会再次执行。*/
@Aspect
@RequiredArgsConstructor
@Slf4j
public class TenantJobAspect {private final TenantFrameworkService tenantFrameworkService;@Around("@annotation(tenantJob)")public void around(ProceedingJoinPoint joinPoint, TenantJob tenantJob) {// 获得租户列表List<Long> tenantIds = tenantFrameworkService.getTenantIds();if (CollUtil.isEmpty(tenantIds)) {return;}// 逐个租户,执行 JobMap<Long, String> results = new ConcurrentHashMap<>();AtomicBoolean success = new AtomicBoolean(true); // 标记,是否存在失败的情况XxlJobContext xxlJobContext = XxlJobContext.getXxlJobContext(); // XXL-Job 上下文tenantIds.parallelStream().forEach(tenantId -> {//先通过 parallel 实现并行;1)多个租户,是一条执行日志;2)异常的情况TenantUtils.execute(tenantId, () -> {try {XxlJobContext.setXxlJobContext(xxlJobContext);// 执行 JobObject result = joinPoint.proceed();results.put(tenantId, StrUtil.toStringOrEmpty(result));} catch (Throwable e) {results.put(tenantId, ExceptionUtil.getRootCauseMessage(e));success.set(false);// 打印异常XxlJobHelper.log(StrUtil.format("[多租户({}) 执行任务({}),发生异常:{}]",tenantId, joinPoint.getSignature(), ExceptionUtils.getStackTrace(e)));}});});// 记录执行结果if (success.get()) {XxlJobHelper.handleSuccess(JsonUtils.toJsonString(results));} else {XxlJobHelper.handleFail(JsonUtils.toJsonString(results));}}}
TenantJob功能
-
这是一个自定义注解,用于标记某个方法是多租户任务调度的方法。
-
被标记的方法会被
TenantJobAspect
切面拦截,按照租户逐个执行任务逻辑。
注解属性
-
@Target({ElementType.METHOD})
:-
表示该注解只能用于方法上。
-
-
@Retention(RetentionPolicy.RUNTIME)
:-
表示该注解在运行时生效,可以通过反射读取。
-
TenantJobAspect功能
-
这是一个 Spring AOP 切面类,用于拦截被
@TenantJob
注解标记的方法。 -
在任务执行时,按照租户逐个执行任务逻辑,并支持并行处理。
关键逻辑
-
获取租户列表:
-
通过
tenantFrameworkService.getTenantIds()
获取所有租户的 ID 列表。 -
如果租户列表为空,则直接返回。
-
-
逐个租户执行任务:
-
使用
tenantIds.parallelStream().forEach()
并行处理每个租户的任务。 -
对于每个租户,调用
TenantUtils.execute(tenantId, () -> { ... })
,在指定租户的上下文中执行任务逻辑。
-
-
任务执行逻辑:
-
在租户上下文中,调用
joinPoint.proceed()
执行被拦截的方法(即任务逻辑)。 -
如果任务执行成功,将结果保存到
results
中。 -
如果任务执行失败,捕获异常并记录错误信息,同时将
success
标记为false
。
-
-
记录执行结果:
-
如果所有租户的任务都执行成功,调用
XxlJobHelper.handleSuccess()
记录成功结果。 -
如果有租户的任务执行失败,调用
XxlJobHelper.handleFail()
记录失败结果。
-
协同工作流程
-
标记方法:
-
在需要多租户任务调度的方法上添加
@TenantJob
注解。例如:@TenantJob public void processData() {// 任务逻辑 }
-
-
切面拦截:
-
当任务调度框架(如 XXL-Job)调用被
@TenantJob
注解标记的方法时,TenantJobAspect
切面会拦截该方法。
-
-
逐个租户执行任务:
-
切面会获取所有租户的 ID 列表,并逐个租户执行任务逻辑。
-
每个租户的任务在独立的上下文中执行,确保数据隔离。
-
-
记录执行结果:
-
切面会记录每个租户的任务执行结果,并根据结果调用
XxlJobHelper.handleSuccess()
或XxlJobHelper.handleFail()
。
-
4. 使用场景
-
多租户任务调度:
-
某些任务需要为每个租户独立执行,例如数据同步、报表生成等。
-
-
并行处理:
-
使用
parallelStream()
并行处理每个租户的任务,提高执行效率。
-
-
任务幂等性:
-
由于任务可能会重试,需要确保任务逻辑的幂等性,避免重复执行导致数据不一致。
-
注意事项
-
任务幂等性:
-
由于任务可能会重试,需要确保任务逻辑的幂等性,避免重复执行导致数据不一致。
-
-
异常处理:
-
捕获任务执行过程中的异常,并记录错误信息,确保任务调度框架能够正确处理失败情况。
-
-
并行处理:
-
使用
parallelStream()
并行处理每个租户的任务时,需要注意线程安全问题。
-
示例代码
// JobHandler.java public class JobHandler {@TenantJobpublic void processData() {// 任务逻辑} }
关键点
-
@TenantJob
注解:-
标记
processData
方法为多租户任务调度的方法。
-
-
任务逻辑:
-
在
processData
方法中实现具体的任务逻辑。
-
总结
-
@TenantJob
注解用于标记多租户任务调度的方法。 -
TenantJobAspect
切面通过 AOP 拦截被注解标记的方法,按照租户逐个执行任务逻辑,并支持并行处理。 -
这种设计可以灵活地支持多租户任务调度,确保每个租户的任务能够独立执行,同时提高任务执行效率。
3.9 MQ
通过租户对 MQ 层面的封装,实现租户上下文,可以继续传递到 MQ 消费的逻辑中,避免丢失的问题。实现原理是:
- 发送消息时,MQ 会将租户上下文的租户编号,记录到 Message 消息头
tenant-id
上。 - 消费消息时,MQ 会将 Message 消息头
tenant-id
,设置到租户上下文的租户编号。
/*** 多租户的 RabbitMQ 初始化器** @author 芋道源码*/
public class TenantRabbitMQInitializer implements BeanPostProcessor {@Overridepublic Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {if (bean instanceof RabbitTemplate) {RabbitTemplate rabbitTemplate = (RabbitTemplate) bean;rabbitTemplate.addBeforePublishPostProcessors(new TenantRabbitMQMessagePostProcessor());}return bean;}}
-
postProcessAfterInitialization
:在 Bean 初始化完成后调用。 -
RabbitTemplate
:RabbitMQ 的消息发送模板。 -
addBeforePublishPostProcessors
:添加一个消息处理器,在消息发送前执行。
/*** RabbitMQ 消息队列的多租户 {@link ProducerInterceptor} 实现类** 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中* 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现** @author 芋道源码*/
public class TenantRabbitMQMessagePostProcessor implements MessagePostProcessor {@Overridepublic Message postProcessMessage(Message message) throws AmqpException {Long tenantId = TenantContextHolder.getTenantId();if (tenantId != null) {message.getMessageProperties().getHeaders().put(HEADER_TENANT_ID, tenantId);}return message;}}
-
results
:用于存储每个租户的任务执行结果。 -
success
:用于标记是否有租户的任务执行失败。 -
XxlJobContext
:XXL-Job 的上下文对象,用于在任务执行过程中传递上下文信息。 -
tenantIds.parallelStream().forEach(tenantId -> { ... })
:并行处理每个租户的任务。 -
TenantUtils.execute(tenantId, () -> { ... })
:在指定的租户上下文中执行任务。 -
joinPoint.proceed()
:执行被拦截的任务方法。 -
如果任务执行成功,将结果存入
results
中;如果执行失败,记录异常信息并标记success
为false
。
-
如果所有租户的任务都执行成功,调用
XxlJobHelper.handleSuccess
记录成功结果。 -
如果有租户的任务执行失败,调用
XxlJobHelper.handleFail
记录失败结果。
3.10 Async异步租户
核心功能:自动修改 ThreadPoolTaskExecutor
,确保 @Async
任务可以继承父线程的 ThreadLocal 数据,防止上下文丢失。
- BeanPostProcessor:Spring 的 Bean 后置处理器,允许在 Bean 初始化前后 进行额外操作。
- 这里的 作用 是在 Spring 容器创建
ThreadPoolTaskExecutor
线程池时,修改其行为。
-
BeanPostProcessor
是 Spring 提供的一个扩展点,允许在 Spring 容器初始化 Bean 之前或之后对 Bean 进行自定义处理。 -
这里通过实现
BeanPostProcessor
,对ThreadPoolTaskExecutor
类型的 Bean 进行增强。 -
这是
BeanPostProcessor
的一个方法,会在 Spring 容器初始化 Bean 之前调用。 -
在该方法中,判断当前 Bean 是否是
ThreadPoolTaskExecutor
类型,如果是,则对其进行增强。
executor.setTaskDecorator(TtlRunnable::get)
-
TaskDecorator
是 Spring 提供的一个接口,用于对提交到线程池的任务进行装饰。 -
TtlRunnable::get
是TransmittableThreadLocal
提供的方法,用于将当前线程的TransmittableThreadLocal
上下文传递到异步任务中。 -
通过设置
TaskDecorator
,确保异步任务在执行时能够正确获取到父线程的TransmittableThreadLocal
上下文。
3.11 Rpc
✅ TenantRequestInterceptor
作用是 在 Feign 请求中,自动添加租户 ID 到 Header
,实现 租户 ID 透传。
✅ 适用于 微服务架构,确保不同服务之间能正确识别租户,实现数据隔离。
✅ 结合 TenantContextHolder
和 Filter
,可以在 每个服务 中 正确解析租户 ID。
在 多租户微服务架构 中,这是 核心组件,可以确保跨服务调用时,租户 ID 不会丢失! 🚀
/*** Tenant 的 RequestInterceptor 实现类:Feign 请求时,将 {@link TenantContextHolder} 设置到 header 中,继续透传给被调用的服务*/
public class TenantRequestInterceptor implements RequestInterceptor {@Overridepublic void apply(RequestTemplate requestTemplate) {Long tenantId = TenantContextHolder.getTenantId();if (tenantId != null) {requestTemplate.header(HEADER_TENANT_ID, String.valueOf(tenantId));}}}
- 实现
RequestInterceptor
接口:RequestInterceptor
是 Feign 的拦截器接口,用于在请求发送前,对请求进行修改。 - 作用:在 Feign 发送 HTTP 请求前,将 租户 ID 放入
header
,让下游服务知道当前请求来自哪个租户。
requestTemplate.header(...)
用于修改 Feign 请求,在请求的 HTTP Header 中 添加租户 ID。- 这样,调用 下游微服务 时,它能从
Header
里获取到tenantId
,从而知道这个请求属于哪个租户。
假设我们的系统有 两个微服务:
order-service
(订单服务)user-service
(用户服务)
在 order-service
配置 Feign 客户端
@FeignClient(name = "user-service", path = "/users", configuration = TenantFeignConfig.class)
public interface UserFeignClient {@GetMapping("/{id}")UserDTO getUser(@PathVariable("id") Long id);
}
这里的 TenantFeignConfig.class
配置了 Feign 拦截器,用于 自动透传租户 ID。
配置 Feign 拦截器
@Configuration
public class TenantFeignConfig {@Beanpublic RequestInterceptor tenantRequestInterceptor() {return new TenantRequestInterceptor();}}
@Configuration
:标明这是一个 Spring 配置类,会在 Spring 启动时被加载。@Bean
:向 Spring 容器 注册RequestInterceptor
,让 Feign 在发送请求时使用这个拦截器。
order-service
发起 Feign 请求
UserDTO user = userFeignClient.getUser(1L);
实际会发送http请求
GET http://user-service/users/1
Headers:tenant-id: 123 // 自动带上了租户 ID
在 user-service
解析租户 ID
@Component
public class TenantFilter implements Filter {@Overridepublic void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)throws IOException, ServletException {HttpServletRequest httpRequest = (HttpServletRequest) request;String tenantId = httpRequest.getHeader("tenant-id");if (StrUtil.isNotEmpty(tenantId)) {TenantContextHolder.setTenantId(Long.valueOf(tenantId));}try {chain.doFilter(request, response);} finally {TenantContextHolder.clear();}}
}
这样,user-service
就能从 Header
解析租户 ID,并在整个请求链中使用它。
4. 租户独立域名
-
子域名解析:
-
用户在浏览器中访问
a.iocoder.cn
或b.iocoder.cn
。 -
Nginx 通过泛域名解析(
*.iocoder.cn
)将请求转发到前端项目(如 Vue3 管理后台)。
-
-
租户识别:
-
前端根据当前访问的域名(
window.location.host
),向后端请求对应的租户 ID。 -
后端根据域名查询
system_tenant
表,返回对应的租户 ID。
-
-
租户上下文传递:
-
前端在后续请求中携带租户 ID(如通过 HTTP Header 或请求参数)。
-
后端根据租户 ID 处理租户特定的逻辑。
-
表结构设计
CREATE TABLE system_tenant (id BIGINT PRIMARY KEY COMMENT '租户 ID',name VARCHAR(255) NOT NULL COMMENT '租户名称',website VARCHAR(255) NOT NULL COMMENT '租户独立域名',-- 其他字段...
);
Nginx 配置
Nginx 通过泛域名解析将所有子域名请求转发到前端项目。
server {listen 80;server_name ~^(.*)\.iocoder\.cn$; # 泛域名解析,匹配所有子域名location / {proxy_pass http://frontend-app; # 前端项目地址proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;}
}
前端根据当前访问的域名,向后端请求租户 ID,并在后续请求中携带租户 ID。
后端根据域名查询租户 ID,并在后续请求中根据租户 ID 处理租户特定的逻辑。
相关文章:
java每日精进 3.11 【多租户】
1.多租户概念 1. 多租户是什么? 多租户,简单来说是指一个业务系统,可以为多个组织服务,并且组织之间的数据是隔离的。 例如说,在服务上部署了一个MyTanant系统,可以支持多个不同的公司使用。这里的一个公…...
2.2 企业级ESLint/Prettier规则定制
文章目录 1. 为什么需要企业级代码规范2. 工具选型对比3. 完整配置流程3.1 项目初始化3.2 ESLint深度配置3.3 Prettier精细配置3.4 解决规则冲突4. 高级定制方案4.1 自定义ESLint规则4.2 扩展Prettier插件5. 团队协作策略5.1 配置共享方案5.2 版本控制策略6. CI/CD集成7. 常见问…...
Ubuntu 源码安装 Qt5
1.开发背景 Ubuntu 下安装指定版本的 Qt,最新的Qt官方已经不支持 Qt5.15.2 版本以下版本,所以有必要用旧的源码编译 Qt 库。 2.开发需求 源码安装 Qt5.12.2 3.开发环境 开发环境:Ubuntu18.04 目标版本:Qt5.12.2 4.实现步骤 4…...
【Linux】权限相关知识点
思考 我们平时使用Linux创建文件或目录时的默认权限是多少? [rootlocalhost test]: mkdir dir [rootlocalhost test]: touch file [rootlocalhost test]: ll total 0 drwxr-xr-x 2 root root 6 Mar 8 15:23 dir #755 -rw-r--r-- 1 root root 0 Mar 8 15:23 f…...
SSH 安全致命漏洞:渗透路径与防御策略
作为远程管理的核心协议,SSH 的 22 端口在全球服务器中广泛部署,却也成为攻击者的首要目标。本文将以技术视角还原黑客通过 22 端口渗透的完整路径,并结合最新漏洞(如 CVE-2024-6387)提供防御建议,帮助企业…...
使用ngnix进行负载均衡部署deepseek蒸馏版
使用ngnix进行负载均衡部署deepseek蒸馏版 一、安装及配置nginx1.1.安装依赖:1.2. 导入Nginx签名密钥1.3. 添加Nginx软件源1.4. 更新软件包列表并安装Nginx1.5. 启动Nginx服务1.6. 验证安装1.7.修改配置文件,将自己的内容加进去1.8、重新加载Nginx配置:二、模型启动2.1、分布…...
面试之《TypeScript泛型》
在 TypeScript(TS)里,泛型是一项极为重要的特性,它能让你编写可复用、类型安全且灵活的代码。以下从多个方面为你详细介绍 TS 中的泛型: 基本概念 泛型允许你创建可重用的组件,这些组件能够处理多种数据类…...
PyTorch系列教程:Tensor.view() 方法详解
这篇简明扼要的文章是关于PyTorch中的tensor.view()方法的介绍与应用,与reshape()方法的区别,同时给出示例进行详细解释。 Tensor基础 Tensor(张量)的视图是一个新的Tensor,它与原始Tensor共享相同的底层数据,但具有不同的形状或…...
IDEA(十一)调整新版本的工具栏显示Git操作(pull、commit、push、revert等)
目录 一、背景二、操作步骤2.1 开启新 UI 样式2.2 设置 Tool Window 工具栏 一、背景 好久没有更新 IDEA 了,更新之后发现 IDEA 的工具栏消失了。一番操作之后,终于把 IDEA 的工具栏的设置调整好了,在此进行记录调整步骤,供大家学…...
基于Prometheus+Grafana的Deepseek性能监控实战
文章目录 1. 为什么需要专门的大模型监控?2. 技术栈组成2.1 vLLM(推理引擎层)2.2 Prometheus(监控采集层)2.3 Grafana(数据可视化平台)3. 监控系统架构4. 实施步骤4.1 启动DeepSeek-R1模型4.2 部署 Prometheus4.2.1 拉取镜像4.2.2 编写配置文件4.2.3 启动容器4.3 部署 G…...
windows下docker的安装
前言 早期的docker只能在Linux下使用,随着技术的发展,目前docker在Windows下也能方便的使用了。 一、docker的下载 从docker官网下载“docker desktop” 下载这个: 二、Windows下docker的安装 安装完毕后,重启的系统进行登录&am…...
Nginx正向代理HTTPS配置指南(仅供参考)
要使用Nginx作为正向代理访问HTTPS网站,需通过CONNECT方法建立隧道。以下是操作详细步骤: 1. 安装Nginx及依赖模块 需要模块:ngx_http_proxy_connect_module(支持CONNECT方法)。 安装方式:需重新编译Nginx…...
01_LVGL 对象与盒子模型详解
1. LVGL 的对象 在LVGL中,⽤⼾界⾯的 基本组成部分 是对象(控件),也称为 Widgets。例如,⼀个 按钮、标签、图像、列表、图表 或者 ⽂本区域。所有的对象都使⽤ lv_obj_t 指针作为句柄进⾏引⽤。之后可以使⽤该指针…...
【redis】string应用场景:共享会话和手机验证码
文章目录 共享会话实现思路 手机验证码实现思路伪代码实现生成验证码验证验证码 共享会话 实现思路 如果每个应用服务器,维护自己的会话数据,此时彼此之间胡共享,用户请求访问到不同的服务器上,就可能会出现一些不能正确处理的情…...
【保姆级教程】使用 oh-my-posh 和 clink 打造个性化 PowerShell 和 CMD
内容预览 ≧∀≦ゞ Windows终端美化指南:美化你的命令行界面!引言一、准备工作包管理器:scoop为什么选择使用 Scoop 安装?安装 scoop 字体终端离线安装步骤配置 Windows Terminal 二、配置美化 PowerShell安装 oh-my-posh激活 oh-…...
刷leetcode hot100--动态规划3.11
第一题:最长递增子序列[10:53] 1.dp数组及下标含义:dp[n]:nums[0...n]的最长严格递增子序列长度【无法进行后续比较】 dp[n]以nums[n]结尾的最长严格递增子序列对应的长度 2.初始化:注意!!这里应该初始化为1&#x…...
网络安全基础与应用习题 网络安全基础答案
1.列出并简要给出SSH的定义。 正确答案: 答:6.10传输层协议:提供服务器身份验证、数据保密性和数据完整性,并具有前向保密性(即,如果在一个会话期间密钥被破坏,则知识不会影响早期会话的安全性&…...
利用python生成excel中模板范围对应的shape文件
利用python生成excel中模板范围对应的shape文件 # -*- coding: utf-8 -*- import os.pathimport pandas as pd from shapely.geometry import Polygon from shapely.wkt import dumps import argparse# 创建解析器 parser argparse.ArgumentParser(description"这是一个…...
方案精读:IBM方法论-IT规划方法论
该文档聚焦 IT 规划方法论,适合企业高层管理者、IT 部门负责人、业务部门主管以及参与企业信息化建设的相关人员阅读。 (本解读资料已包含在绑定资源内) 主要内容围绕 IT 规划展开:首先明确 IT 规划需基于企业核心战略࿰…...
JAVA面试_进阶部分_正确使用 Volatile 变量
Java 语言中的 volatile 变量可以被看作是一种 “程度较轻的 synchronized”;与 synchronized 块相比,volatile 变量所需的编码较少,并且运行时开销也较少,但是它所能实现的功能也仅是 synchronized 的一部分。本文介绍了几种有效…...
ArcGIS Pro中字段的新建方法与应用
一、引言 在地理信息系统(GIS)的数据管理和分析过程中,字段操作起着至关重要的作用。 无论是进行地图制作、空间分析还是数据统计,字段都是承载属性信息的基本单元。 ArcGIS Pro作为一款功能强大的GIS软件,为用户提…...
c++ 中的引用
引用与指针经常混淆,总结一下 文章目录 1. 引用与指针的区别2. 引用传递数组3. 通过引用传递容器和类4. 多线程传递容器时用 std:: ref 替代引用传递 1. 引用与指针的区别 引用(Reference):引用是变量的别名,本质上不…...
使用jest测试用例之入门篇
Jest使用 Jest 是由 Facebook 开发的一个 js 测试框架,jest 主要侧重于被用于做单元测试和集成测试 安装 npm i jest -D运行 **package.json**里面配置命令 // scripts添加测试脚本 {"test": "jest" /* 运行后便会使用 jest 执行所有的 .t…...
k8s面试题总结(十四)
什么是Helm? Helm是一个k8s的包管理工具,它简化了应用程序在k8s集群中的部署,管理和维护。类似于rpm包和yum之间的关系。 K8s传统方式:类似于rpm安装包的方式,逐步进行安装,遇到依赖还得解决依赖问题 he…...
后端面试高频笔试题(非常规LeetCode类型)
目录 1. 常见的五种单例模式的实现⽅式 2. 约瑟夫环 (递归) 3. 交替打印奇偶数 (Semaphore、synchronized搭配wait、notify) 4. 交替打印 ABC (Semaphore) 5. 三个线程交替打印 1 到 99 (Semap…...
el-table 通过 slot=“header“ 自定义表头,遇到数据不更新的问题。
从表中可以看到我要的数据为空,但是在控制台输出数据又不为空,由此判断是自定义表头的内容未在数据变化时触发重新渲染 在 Element UI 官方示例中,若通过旧式插槽语法 slot"header" 实现自定义表头,并在表头内集成 el-s…...
ESP32S3N16R8驱动ST7701S屏幕(vscode+PlatfoemIO)
1.开发板配置 本人开发板使用ESP32S3-wroom1-n16r8最小系统板 由于基于vscode与PlatformIO框架开发,无espidf框架,因此无法直接烧录程序,配置开发板参数如下: 在platformio.ini文件中,配置使用esp32-s3-devkitc-1开发…...
ios 小组件和数据共享
创建主工程就不必讲了 1 创建小组件 创建子工程 [new Target ] 选择 [ Widger Extension] 小组件入口是WidgetBundle文件,可以进行多个小组件的调试 TestWidget2文件是主要操作,小组件使用swiftUI布局,使用 AppIntent进行事件处理ÿ…...
鸿蒙开发可以从事的岗位
学完鸿蒙开发方向后,可以从事的岗位主要集中在以下几个领域: 鸿蒙系统开发工程师 负责鸿蒙操作系统的开发、优化、维护和更新工作,包括系统层、框架层、应用层的开发等。 嵌入式软件开发工程师 鸿蒙系统广泛应用于物联网设备、智能硬件等领域…...
深度学习和机器学习的差异
一、技术架构的本质差异 传统机器学习(Machine Learning)建立在统计学和数学优化基础之上,其核心技术是通过人工设计的特征工程(Feature Engineering)构建模型。以支持向量机(SVM)为例…...
OpenCV常用函数以及使用场景
类别函数名参数功能使用场景经验值/注意事项返回值图像 I/Ocv2.imread()filename (str): 文件路径。flags (int, 可选): 读取标志。常用值: * cv2.IMREAD_COLOR (默认): 读取彩色图像 (BGR)。 * cv2.IMREAD_GRAYSCALE: 读取灰度图像。 * cv2.IMREAD_UNCHANGED: 读取包含 Alpha…...
【iOS逆向与安全】sms短信转发插件与上传服务器开发
一、目标 一步步分析并编写一个短信自动转发的deb插件 二、工具 mac系统已越狱iOS设备:脱壳及frida调试IDA Pro:静态分析测试设备:iphone6s-ios14.1.1三、步骤 1、守护进程 守护进程(daemon)是一类在后台运行的特殊进程,用于执行特定的系统任务。例如:推送服务、人…...
Linux内核实时机制19 - RT调度器2 - 更新时间 update_curr_rt
update_curr_rt update_curr_rt函数用来更新当前实时进程的运行时间统计值,//kernel/sched/rt.c 1009 static void update_curr_rt(struct rq *rq) 1010 {...
《Android应用性能优化全解析:常见问题与解决方案》
目录 一、UI卡顿/掉帧 二、内存泄漏(Memory Leak) 三、ANR(Application Not Responding) 四、列表滑动卡顿(RecyclerView/ListView) 五、冷启动耗时过长 六、内存抖动(Memory Churn&#x…...
Mybatis批量更新数据
批量传参样例: [{"sid": "111", "createTime": "2025-03-11 09:12:00", "pbilId": "pbil_id_111"}, {"sid": "222", "createTime": "2025-03-11 09:13:00"…...
HTML 超链接(简单易懂较详细)
在 HTML 中,超链接是通过 <a> 标签(anchor tag)创建的。超链接允许用户通过点击文本、图像或其他元素跳转到另一个网页、文件或页面的特定部分。本文将详细介绍 HTML 超链接的语法、属性和应用场景。 一、基本语法 <a href"U…...
计算机网络--访问一个网页的全过程
文章目录 访问一个网页的全过程应用层在浏览器输入URL网址http://www.aspxfans.com:8080/news/index.aspboardID5&ID24618&page1#r_70732423通过DNS获取IP地址生成HTTP请求报文应用层最后 传输层传输层处理应用层报文建立TCP连接传输层最后 网络层网络层对TCP报文进行处…...
LLaMA-Factory训练DeepSeek大模型+本地部署
前言 前面我们介绍了基于硅基流动训练私有大模型《10分钟教你微调自己的私有大模型》,该平台有三个不好的点就是可选的模型有限,训练时间需要排队等待耗时长,另外还要 给钱。今天我们换一个平台,使用:魔搭平台 llama…...
mapbox-gl的Popup的使用详解
下面提供一个完整的 HTML 示例代码,展示了如何使用 mapbox-gl 的 Popup。代码中包含了两种使用方式: 在地图加载时直接创建一个 Popup;结合 Marker,在点击 Marker 或地图任意位置时显示 Popup。请确保将 YOUR_MAPBOX_ACCESS_TOKEN 替换为你自己的 Mapbox 访问令牌。下面是代…...
【K8s】专题十六(3):Kubernetes 包管理工具之 Helm 语法
本文内容均来自个人笔记并重新梳理,如有错误欢迎指正! 如果对您有帮助,烦请点赞、关注、转发、订阅专栏! 专栏订阅入口 | 精选文章 | Kubernetes | Docker | Linux | 羊毛资源 | 工具推荐 | 往期精彩文章 【Docker】(全…...
仅仅使用pytorch来手撕transformer架构(3):编码器模块和编码器类的实现和向前传播
仅仅使用pytorch来手撕transformer架构(2):编码器模块和编码器类的实现和向前传播 往期文章: 仅仅使用pytorch来手撕transformer架构(1):位置编码的类的实现和向前传播 最适合小白入门的Transformer介绍 仅仅使用pytorch来手撕transformer…...
LWIP网络模型及接口简介(DAY 01)
目录 1.网络协议分层模型 2. LWIP三种编程接口 1.网络协议分层模型 其中各层级的封装与拆封过程 2. LWIP三种编程接口 LwIP 提供了三种编程接口,分别为 RAW/Callback API、NETCONN API、SOCKET API。它们的易用性从左到右依次提高,而执行效率从左到右依…...
Docker构建启动jar包
Docker构建启动jar包 1、首先是把java服务打包成jar包 mvn clean install -Dmaven.skip.testtrue package -Pprod这个命令的意思是,跳过测试,打包prod环境。 2、编写Dockerfile文件 # 拉取jdk8作为基础镜像 FROM registry.supos.ai/library/openjdk:…...
基于Python+Vue开发的电影订票管理系统源码+运行步骤
项目简介 该项目是基于PythonVue开发的电影订票管理系统(前后端分离),这是一项为大学生课程设计作业而开发的项目。该系统旨在帮助大学生学习并掌握Python编程技能,同时锻炼他们的项目设计与开发能力。通过学习基于Python的电影订…...
javase集合框架List篇
一、Vector和ArrayList、LinkedList联系和区别,分别的使用场景 ArrayList:底层是数组实现,线程不安全,查询和修改非常快,但是增加和删除慢 LinkedList: 底层是双向链表,线程不安全,查询和修改…...
Kafka×DeepSeek:智能决策破取经八十一难!
《西游记》的故事中,唐僧师徒四人历经九九八十一难,从东土大唐前往西天取经。一路上,火焰山酷热难耐、通天河水位忽高忽低、妖怪神出鬼没…… 现在,唐僧师徒取经路上的种种难题,在KafkaDeepSeek双引擎加持下有了全新解…...
python-leetcode-反转字符串中的元音字母
345. 反转字符串中的元音字母 - 力扣(LeetCode) 使用双指针的方法高效地反转字符串中的元音字母。以下是 Python 代码实现: def reverseVowels(s: str) -> str:vowels set("aeiouAEIOU")s list(s) # 将字符串转换为列表&…...
Blender UV纹理贴图,导出FBX到Unity
加载ps好的模型贴图。右下角选择《材质》基础色里面选择《图像纹理》,选择你的图片。 选择上面UV选项卡。左上角选择UV编辑器。选中物体,TAB进入编辑模式。即可调整映射的图像范围。 其中渲染设置可以在左侧下边脱出。 导出带纹理FBX模型 路径选择复…...
AttributeError: module ‘backend_interagg‘ has no attribute ‘FigureCanvas‘
AttributeError: module backend_interagg has no attribute FigureCanvas 这个错误通常是由于 Matplotlib 的后端配置问题引起的。具体来说,Matplotlib 在尝试加载某个后端时,发现该后端模块中缺少必要的属性(如 FigureCanvas)&a…...
调试正常 ≠ 运行正常:Keil5中MicroLIB的“量子态BUG”破解实录
调试正常 ≠ 运行正常:Keil5中MicroLIB的“量子态BUG”破解实录——从勾选一个选项到理解半主机模式,嵌入式开发的认知升级 📌 现象描述:调试与烧录的诡异差异 在线调试时 程序正常运行 - 独立运行时 设备无响应 ! 编译过程 0 Err…...