首页 文章 精选 留言 我的

精选列表

搜索[图像理解],共10000篇文章
优秀的个人博客,低调大师

Sentinel Java 客户端限流原理解析

一、从一次 HTTP 请求开始 在一个生产环境中,服务节点通常暴露了成百上千个 HTTP 接口对外提供服务。为了保证系统的稳定性,核心 HTTP 接口往往需要配置限流规则。给 HTTP 接口配置限流,可以防止突发或恶意的高并发请求耗尽服务器资源(如 CPU、内存、数据库连接等),从而避免服务崩溃或引发雪崩效应。 基础示例 假设我们有下面这样一个 HTTP 接口,需要给它配置限流规则: @RestController @RequiredArgsConstructor @RequestMapping("/demo") public class DemoController { @RequestMapping("/hello") @SentinelResource("test_sentinel") public String hello() { return "hello world"; } } 使用起来非常简单。首先我们可以选择给接口加上 @SentinelResource 注解(也可以不加,如果不加 Sentinel 客户端会使用请求路径作为资源名,详细原理在后面章节讲解),然后到流控控制台给该资源配置流控规则即可。 二、限流规则的加载 限流规则的生效,是从限流规则的加载开始的。聚焦到客户端的 RuleLoader 类,可以看到它支持了多种规则的加载: 流控规则; 集群限流规则; 熔断规则; ...... RuleLoader 核心逻辑 RuleLoader 类的核心作用是将这些规则加载到缓存中,方便后续使用: public class RuleLoader { /** * 加载所有 Sentinel 规则到内存缓存 * * @param sentinelRules 包含各种规则的配置对象 */ public static void loadRule(SentinelRules sentinelRules) { if (sentinelRules == null) { return; } // 加载流控规则 FlowRuleManager.loadRules(sentinelRules.getFlowRules()); // 加载集群流控规则 RuleManager.loadClusterFlowRule(sentinelRules.getFlowRules()); // 加载参数流控规则 ParamFlowRuleManager.loadRules(sentinelRules.getParamFlowRules()); // 加载参数集群流控规则 RuleManager.loadClusterParamFlowRule(sentinelRules.getParamFlowRules()); // 加载熔断规则 DegradeRuleManager.loadRules(sentinelRules.getDegradeRules()); // 加载参数熔断规则 ParamDegradeRuleManager.loadRules(sentinelRules.getParamDegradeRules()); // 加载系统限流规则 SystemRuleManager.loadRules(sentinelRules.getSystemRules()); } } 流控规则加载详情 以流控规则的加载为例深入FlowRuleManager.loadRules 方法可以看到其完整的加载逻辑: public static void loadRules(List<FlowRule> rules) { // 通过动态配置属性更新规则值 currentProperty.updateValue(rules); } updateValue 方法负责通知所有监听器配置变更: public boolean updateValue(T newValue) { // 如果新旧值相同,无需更新 if (isEqual(value, newValue)) { return false; } RecordLog.info("[DynamicSentinelProperty] Config will be updated to: " + newValue); // 更新配置值 value = newValue; // 通知所有监听器配置已更新 for (PropertyListener<T> listener : listeners) { listener.configUpdate(newValue); } return true; } FlowPropertyListener 是流控规则变更的具体监听器实现: private static final class FlowPropertyListener implements PropertyListener<List<FlowRule>> { @Override public void configUpdate(List<FlowRule> value) { // 构建流控规则映射表(按资源名分组) Map<String, List<FlowRule>> rules = FlowRuleUtil.buildFlowRuleMap(value); if (rules != null) { // 清空旧规则 flowRules.clear(); // 加载新规则 flowRules.putAll(rules); } RecordLog.info("[FlowRuleManager] Flow rules received: " + flowRules); } } 三、SentinelServletFilter 过滤器 在 Sentinel 中,所有的资源都对应一个资源名称和一个 Entry。Entry 可以通过对主流框架的适配自动创建,也可以通过注解的方式或调用 API 显式创建。Entry 是限流的入口类,通过 @SentinelResource 注解的限流本质上也是通过 AOP 的方式进行了对 Entry 类的调用。 Entry 的编程范式 Entry 类的标准使用方式如下: // 资源名可使用任意有业务语义的字符串,比如方法名、接口名或其它可唯一标识的字符串 try (Entry entry = SphU.entry("resourceName")) { // 被保护的业务逻辑 // do something here... } catch (BlockException ex) { // 资源访问阻止,被限流或被降级 // 在此处进行相应的处理操作 } Servlet Filter 拦截逻辑 对于一个 HTTP 资源,在没有显式标注 @SentinelResource 注解的情况下,会有一个 Servlet Filter 类 SentinelServletFilter 统一进行拦截: public class SentinelServletFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest sRequest = (HttpServletRequest) request; Entry urlEntry = null; try { // 获取并清理请求路径 String target = FilterUtil.filterTarget(sRequest); // 统一 URL 清理逻辑 // 对于 RESTful API,必须对 URL 进行清理(例如将 /foo/1 和 /foo/2 统一为 /foo/:id), // 否则上下文和资源的数量会超过阈值 SentinelUrlCleaner urlCleaner = SentinelUrlCleaner.SENTINEL_URL_CLEANER; if (urlCleaner != null) { target = urlCleaner.clean(sRequest, target); } // 如果请求路径不为空且非安全扫描,则进入限流逻辑 if (!StringUtil.isEmpty(target) && !isSecScan) { // 解析来源标识(用于来源限流) String origin = parseOrigin(sRequest); // 确定上下文名称 String contextName = webContextUnify ? WebServletConfig.WEB_SERVLET_CONTEXT_NAME : target; // 使用 WEB_SERVLET_CONTEXT_NAME 作为当前 Context 的名字 ContextUtil.enter(contextName, origin); // 根据配置决定是否包含 HTTP 方法 if (httpMethodSpecify) { String pathWithHttpMethod = sRequest.getMethod().toUpperCase() + COLON + target; // 实际进入到限流统计判断逻辑,资源名是 "方法:路径" urlEntry = SphU.entry(pathWithHttpMethod, ResourceTypeConstants.COMMON_WEB, EntryType.IN); } else { // 实际进入到限流统计判断逻辑,资源名是请求路径 urlEntry = SphU.entry(target, ResourceTypeConstants.COMMON_WEB, EntryType.IN); } } // 继续执行后续过滤器 chain.doFilter(request, response); } catch (BlockException e) { // 处理被限流的情况 HttpServletResponse sResponse = (HttpServletResponse) response; // 返回限流页面或重定向到其他 URL WebCallbackManager.getUrlBlockHandler().blocked(sRequest, sResponse, e); } catch (IOException * ServletException * RuntimeException e2) { // 记录异常信息用于统计 Tracer.traceEntry(e2, urlEntry); throw e2; } finally { // 释放 Entry 资源 if (urlEntry != null) { urlEntry.exit(); } // 退出当前上下文 ContextUtil.exit(); } } } 四、SentinelResourceAspect 切面 如果在接口上标注了 @SentinelResource 注解,还会有另外的逻辑处理。Sentinel 定义了一个单独的 AOP 切面 SentinelResourceAspect 专门用于处理注解限流。 SentinelResource 注解定义 先来看看 @SentinelResource 注解的完整定义: @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface SentinelResource { /** * Sentinel 资源的名称(即资源标识) * 必填项,不能为空 */ String value() default ""; /** * 资源的入口类型(入站 IN 或出站 OUT) * 默认为出站(OUT) */ EntryType entryType() default EntryType.OUT; /** * 资源的分类(类型) * 自 1.7.0 版本起支持 */ int resourceType() default 0; /** * 限流或熔断时调用的 block 异常处理方法的名称 * 默认为空(即不指定) */ String blockHandler() default ""; /** * blockHandler 所在的类 * 如果与原方法不在同一个类,需要指定此参数 */ Class<?>[] blockHandlerClass() default {}; /** * 降级(fallback)方法的名称 * 默认为空(即不指定) */ String fallback() default ""; /** * 用作通用的默认降级方法 * 该方法不能接收任何参数,且返回类型需与原方法兼容 */ String defaultFallback() default ""; /** * fallback 所在的类 * 如果与原方法不在同一个类,需要指定此参数 */ Class<?>[] fallbackClass() default {}; /** * 需要被追踪并触发 fallback 的异常类型列表 * 默认为 Throwable(即所有异常都会触发 fallback) */ Class<? extends Throwable>[] exceptionsToTrace() default {Throwable.class}; /** * 指定需要忽略的异常类型(即这些异常不会触发 fallback) * 注意:exceptionsToTrace 和 exceptionsToIgnore 不应同时使用; * 若同时存在,exceptionsToIgnore 优先级更高 */ Class<? extends Throwable>[] exceptionsToIgnore() default {}; } 实际使用示例 下面是一个完整的使用示例,展示了 @SentinelResource 注解的各种配置方式: @RestController public class SentinelController { @Autowired private ISentinelService service; @GetMapping(value = "/hello/{s}") public String apiHello(@PathVariable long s) { return service.hello(s); } } public interface ISentinelService { String hello(long s); } @Service @Slf4j public class SentinelServiceImpl implements ISentinelService { /** * Sentinel 提供了 @SentinelResource 注解用于定义资源 * * @param s 输入参数 * @return 返回结果 */ @Override // value:资源名称,必需项(不能为空) // blockHandler:对应处理 BlockException 的函数名称 // fallback:用于在抛出异常的时候提供 fallback 处理逻辑 @SentinelResource(value = "hello", blockHandler = "exceptionHandler", fallback = "helloFallback") public String hello(long s) { log.error("hello:{}", s); return String.format("Hello at %d", s); } /** * Fallback 函数 * 函数签名与原函数一致,或加一个 Throwable 类型的参数 */ public String helloFallback(long s) { log.error("helloFallback:{}", s); return String.format("Halooooo %d", s); } /** * Block 异常处理函数 * 参数最后多一个 BlockException,其余与原函数一致 */ public String exceptionHandler(long s, BlockException ex) { // Do some log here. log.error("exceptionHandler:{}", s); ex.printStackTrace(); return "Oops, error occurred at " + s; } } SentinelResourceAspect 核心逻辑 @SentinelResource 注解由 SentinelResourceAspect 切面处理,核心逻辑如下: @Aspect public class SentinelResourceAspect extends AbstractSentinelAspectSupport { @Pointcut("@annotation(com.alibaba.csp.sentinel.annotation.SentinelResource)") public void sentinelResourceAnnotationPointcut() { } @Around("sentinelResourceAnnotationPointcut()") public Object invokeResourceWithSentinel(ProceedingJoinPoint pjp) throws Throwable { // 获取目标方法 Method originMethod = resolveMethod(pjp); // 获取注解信息 SentinelResource annotation = originMethod.getAnnotation(SentinelResource.class); if (annotation == null) { throw new IllegalStateException("Wrong state for SentinelResource annotation"); } // 获取资源配置信息 String resourceName = getResourceName(annotation.value(), originMethod); EntryType entryType = annotation.entryType(); int resourceType = annotation.resourceType(); Entry entry = null; try { // 创建限流入口 entry = SphU.entry(resourceName, resourceType, entryType, pjp.getArgs()); // 执行原方法 Object result = pjp.proceed(); return result; } catch (BlockException ex) { // 处理被限流异常 return handleBlockException(pjp, annotation, ex); } catch (Throwable ex) { // 处理业务异常 Class<? extends Throwable>[] exceptionsToIgnore = annotation.exceptionsToIgnore(); // 优先检查忽略列表 if (exceptionsToIgnore.length > 0 && exceptionBelongsTo(ex, exceptionsToIgnore)) { throw ex; } // 检查异常是否在追踪列表中 if (exceptionBelongsTo(ex, annotation.exceptionsToTrace())) { traceException(ex); // 执行 fallback 逻辑 return handleFallback(pjp, annotation, ex); } // 没有 fallback 函数可以处理该异常,直接抛出 throw ex; } finally { // 释放 Entry 资源 if (entry != null) { entry.exit(1, pjp.getArgs()); } } } /** * 处理 BlockException * * blockHandler / blockHandlerClass 说明: * - blockHandler:对应处理 BlockException 的函数名称,可选项 * - blockHandler 函数签名:与原方法相匹配并且最后加一个额外的参数,类型为 BlockException * - blockHandler 函数默认需要和原方法在同一个类中 * - 若希望使用其他类的函数,则可以指定 blockHandlerClass 为对应的类的 Class 对象 * - 注意:blockHandlerClass 中对应的函数必须为 static 函数,否则无法解析 */ protected Object handleBlockException(ProceedingJoinPoint pjp, SentinelResource annotation, BlockException ex) throws Throwable { // 执行 blockHandler 方法(如果配置了的话) Method blockHandlerMethod = extractBlockHandlerMethod(pjp, annotation.blockHandler(), annotation.blockHandlerClass()); if (blockHandlerMethod != null) { Object[] originArgs = pjp.getArgs(); // 构造参数:原方法参数 + BlockException Object[] args = Arrays.copyOf(originArgs, originArgs.length + 1); args[args.length - 1] = ex; try { // 根据 static 方法与否进行不同的调用 if (isStatic(blockHandlerMethod)) { return blockHandlerMethod.invoke(null, args); } return blockHandlerMethod.invoke(pjp.getTarget(), args); } catch (InvocationTargetException e) { // 抛出实际的异常 throw e.getTargetException(); } } // 如果没有 blockHandler,则尝试执行 fallback return handleFallback(pjp, annotation, ex); } /** * 处理 Fallback 逻辑 * * fallback / fallbackClass 说明: * - fallback:fallback 函数名称,可选项,用于在抛出异常的时候提供 fallback 处理逻辑 * - fallback 函数可以针对所有类型的异常(除了 exceptionsToIgnore 里面排除掉的异常类型)进行处理 * * fallback 函数签名和位置要求: * - 返回值类型必须与原函数返回值类型一致 * - 方法参数列表需要和原函数一致,或者可以额外多一个 Throwable 类型的参数用于接收对应的异常 * - fallback 函数默认需要和原方法在同一个类中 * - 若希望使用其他类的函数,则可以指定 fallbackClass 为对应的类的 Class 对象 * - 注意:fallbackClass 中对应的函数必须为 static 函数,否则无法解析 */ protected Object handleFallback(ProceedingJoinPoint pjp, String fallback, String defaultFallback, Class<?>[] fallbackClass, Throwable ex) throws Throwable { Object[] originArgs = pjp.getArgs(); // 执行 fallback 函数(如果配置了的话) Method fallbackMethod = extractFallbackMethod(pjp, fallback, fallbackClass); if (fallbackMethod != null) { // 构造参数:根据 fallback 方法的参数数量决定是否添加异常参数 int paramCount = fallbackMethod.getParameterTypes().length; Object[] args; if (paramCount == originArgs.length) { args = originArgs; } else { args = Arrays.copyOf(originArgs, originArgs.length + 1); args[args.length - 1] = ex; } try { // 根据 static 方法与否进行不同的调用 if (isStatic(fallbackMethod)) { return fallbackMethod.invoke(null, args); } return fallbackMethod.invoke(pjp.getTarget(), args); } catch (InvocationTargetException e) { // 抛出实际的异常 throw e.getTargetException(); } } // 如果没有 fallback,尝试使用 defaultFallback return handleDefaultFallback(pjp, defaultFallback, fallbackClass, ex); } } 五、流控处理核心逻辑 从入口函数开始,我们深入到流控处理的核心逻辑。 入口函数调用链 public class SphU { /** * 创建限流入口 * * @param name 资源名称 * @param resourceType 资源类型 * @param trafficType 流量类型(IN 或 OUT) * @param args 参数数组 * @return Entry 对象 * @throws BlockException 如果被限流则抛出此异常 */ public static Entry entry(String name, int resourceType, EntryType trafficType, Object[] args) throws BlockException { return Env.sph.entryWithType(name, resourceType, trafficType, 1, args); } public static Entry entry(String name, EntryType trafficType, int batchCount) throws BlockException { return Env.sph.entry(name, trafficType, batchCount, OBJECTS0); } } public class CtSph implements Sph { @Override public Entry entry(String name, EntryType type, int count, Object... args) throws BlockException { StringResourceWrapper resource = new StringResourceWrapper(name, type); return entry(resource, count, args); } public Entry entry(ResourceWrapper resourceWrapper, int count, Object... args) throws BlockException { return entryWithPriority(resourceWrapper, count, false, args); } /** * 带优先级的入口方法,这是限流的核心逻辑 */ private Entry entryWithPriority(ResourceWrapper resourceWrapper, int count, boolean prioritized, Object... args) throws BlockException { Context context = ContextUtil.getContext(); // 如果上下文数量超过阈值,则不进行规则检查 if (context instanceof NullContext) { // NullContext 表示上下文数量超过了阈值,这里只初始化 Entry,不进行规则检查 return new CtEntry(resourceWrapper, null, context); } // 如果没有上下文,使用默认上下文 if (context == null) { context = InternalContextUtil.internalEnter(Constants.CONTEXT_DEFAULT_NAME); } // 如果全局开关关闭,则不进行规则检查 if (!Constants.ON) { return new CtEntry(resourceWrapper, null, context); } // 获取或创建 ProcessorSlotChain(责任链) ProcessorSlot<Object> chain = lookProcessChain(resourceWrapper); /* * 如果资源(slot chain)数量超过 {@link Constants.MAX_SLOT_CHAIN_SIZE}, * 则不进行规则检查 */ if (chain == null) { return new CtEntry(resourceWrapper, null, context); } // 创建 Entry 对象 Entry e = new CtEntry(resourceWrapper, chain, context); try { // 执行责任链进行规则检查 chain.entry(context, resourceWrapper, null, count, prioritized, args); } catch (BlockException e1) { // 如果被限流,释放 Entry 并抛出异常 e.exit(count, args); throw e1; } catch (Throwable e1) { // 这不应该发生,除非 Sentinel 内部存在错误 log.warn("Sentinel unexpected exception,{}", e1.getMessage()); } return e; } } ProcessorSlotChain 功能插槽链 lookProcessChain 方法实际创建了 ProcessorSlotChain 功能插槽链。ProcessorSlotChain 采用责任链模式,将不同的功能(限流、降级、系统保护)组合在一起。 SlotChain 的获取与创建 ProcessorSlot<Object> lookProcessChain(ResourceWrapper resourceWrapper) { // 先从缓存中获取 ProcessorSlotChain chain = chainMap.get(resourceWrapper); if (chain == null) { // 双重检查锁,保证线程安全 synchronized (LOCK) { chain = chainMap.get(resourceWrapper); if (chain == null) { // Entry 大小限制 if (chainMap.size() >= Constants.MAX_SLOT_CHAIN_SIZE) { return null; } // 创建新的 SlotChain chain = SlotChainProvider.newSlotChain(); // 使用不可变模式更新缓存 Map<ResourceWrapper, ProcessorSlotChain> newMap = new HashMap<ResourceWrapper, ProcessorSlotChain>(chainMap.size() + 1); newMap.putAll(chainMap); newMap.put(resourceWrapper, chain); chainMap = newMap; } } } return chain; } SlotChain 的构建 public class DefaultSlotChainBuilder implements SlotChainBuilder { @Override public ProcessorSlotChain build() { ProcessorSlotChain chain = new DefaultProcessorSlotChain(); // 通过 SPI 加载所有 ProcessorSlot 并排序 List<ProcessorSlot> sortedSlotList = SpiLoader.loadPrototypeInstanceListSorted(ProcessorSlot.class); for (ProcessorSlot slot : sortedSlotList) { // 只处理继承自 AbstractLinkedProcessorSlot 的 Slot if (!(slot instanceof AbstractLinkedProcessorSlot)) { RecordLog.warn("The ProcessorSlot(" + slot.getClass().getCanonicalName() + ") is not an instance of AbstractLinkedProcessorSlot, can't be added into ProcessorSlotChain"); continue; } // 将 Slot 添加到责任链尾部 chain.addLast((AbstractLinkedProcessorSlot<?>) slot); } return chain; } } SlotChain 的功能划分 Slot Chain 可以分为两部分: 统计数据构建部分(statistic):负责收集各种指标数据; 判断部分(rule checking):根据规则判断是否限流。 官方架构图很好地解释了各个 Slot 的作用及其负责的部分。目前 ProcessorSlotChain 的设计是一个资源对应一个,构建好后缓存起来,方便下次直接取用。 各 Slot 的执行顺序 以下是 Sentinel 中各个 Slot 的默认执行顺序: NodeSelectorSlot ↓ ClusterBuilderSlot ↓ StatisticSlot ↓ ParamFlowSlot ↓ SystemSlot ↓ AuthoritySlot ↓ FlowSlot ↓ DegradeSlot NodeSelectorSlot - 上下文节点选择 这个功能插槽主要为资源下不同的上下文创建对应的 DefaultNode(实际用于统计指标信息)。解释一下Sentinel中的Node是什么,简单来说就是每个资源统计指标存放的容器,只不过内部由于不同的统计口径(秒级、分钟及)而分别有不同的统计窗口。Node在Sentinel不是单一的结构,而是总体上形成父子关系的树形结构。 不同的调用会有不同的 context 名称,如在当前 MVC 场景下,上下文为 sentinel_web_servlet_context。 public class NodeSelectorSlot extends AbstractLinkedProcessorSlot<Object> { /** * 同一个资源在不同上下文中的 DefaultNode 映射 */ private volatile Map<String, DefaultNode> map = new HashMap<String, DefaultNode>(10); @Override public void entry(Context context, ResourceWrapper resourceWrapper, Object obj, int count, boolean prioritized, Object... args) throws Throwable { // 从映射表中获取当前上下文对应的节点 DefaultNode node = map.get(context.getName()); if (node == null) { // 双重检查锁,保证线程安全 synchronized (this) { node = map.get(context.getName()); if (node == null) { // 创建新的 DefaultNode node = new DefaultNode(resourceWrapper, null); // 使用写时复制更新缓存 HashMap<String, DefaultNode> cacheMap = new HashMap<String, DefaultNode>(map.size()); cacheMap.putAll(map); cacheMap.put(context.getName(), node); map = cacheMap; // 构建调用树 ((DefaultNode) context.getLastNode()).addChild(node); } } } // 设置当前上下文的当前节点 context.setCurNode(node); // 继续执行后续 Slot fireEntry(context, resourceWrapper, node, count, prioritized, args); } @Override public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) { fireExit(context, resourceWrapper, count, args); } } ClusterBuilderSlot - 集群节点构建 这个功能槽主要用于创建 ClusterNode。ClusterNode 和 DefaultNode 的区别是: DefaultNode 是特定于上下文的(context-specific); ClusterNode 是不区分上下文的(context-independent),用于统计该资源在所有上下文中的整体数据。 @public class ClusterBuilderSlot extends AbstractLinkedProcessorSlot<DefaultNode> { /** * 全局 ClusterNode 映射表 */ private static volatile Map<ResourceWrapper, ClusterNode> clusterNodeMap = new HashMap<>(); private static final Object lock = new Object(); private volatile ClusterNode clusterNode = null; @Override public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, boolean prioritized, Object... args) throws Throwable { // 创建 ClusterNode(如果不存在) if (clusterNode == null) { synchronized (lock) { if (clusterNode == null) { // 创建集群节点 clusterNode = new ClusterNode(resourceWrapper.getName(), resourceWrapper.getResourceType()); // 更新全局映射表 HashMap<ResourceWrapper, ClusterNode> newMap = new HashMap<>(Math.max(clusterNodeMap.size(), 16)); newMap.putAll(clusterNodeMap); newMap.put(node.getId(), clusterNode); clusterNodeMap = newMap; } } } // 将 ClusterNode 设置到 DefaultNode 中 node.setClusterNode(clusterNode); // 如果有来源标识,则创建 origin node if (!"".equals(context.getOrigin())) { Node originNode = node.getClusterNode().getOrCreateOriginNode(context.getOrigin()); context.getCurEntry().setOriginNode(originNode); } // 继续执行后续 Slot fireEntry(context, resourceWrapper, node, count, prioritized, args); } } StatisticSlot - 统计插槽 StatisticSlot 是 Sentinel 最重要的类之一,用于根据规则判断结果进行相应的统计操作。 统计逻辑说明 entry 的时候: 依次执行后续的判断 Slot; 每个 Slot 触发流控会抛出异常(BlockException 的子类); 若有 BlockException 抛出,则记录 block 数据; 若无异常抛出则算作可通过(pass),记录 pass 数据。 exit 的时候: 若无 error(无论是业务异常还是流控异常),记录 complete(success)以及 RT,线程数 -1。 记录数据的维度: 线程数 +1; 记录当前 DefaultNode 数据; 记录对应的 originNode 数据(若存在 origin); 累计 IN 统计数据(若流量类型为 IN)。 @public class StatisticSlot extends AbstractLinkedProcessorSlot<DefaultNode> { @Override public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, boolean prioritized, Object... args) throws Throwable { try { // 此位置会调用 SlotChain 中后续的所有 Slot,完成所有规则检测 fireEntry(context, resourceWrapper, node, count, prioritized, args); // 请求通过,增加线程数和通过数 // 代码运行到这个位置,就证明之前的所有 Slot 检测都通过了 // 此时就可以统计请求的相应数据了 // 增加线程数(+1) node.increaseThreadNum(); // 增加通过请求的数量(这里涉及到滑动窗口算法) node.addPassRequest(count); // 省略其他统计逻辑... } catch (PriorityWaitException ex) { // 如果是优先级等待异常,记录优先级等待数 node.increaseThreadNum(); if (context.getCurEntry().getOriginNode() != null) { context.getCurEntry().getOriginNode().increaseThreadNum(); } if (resourceWrapper.getEntryType() == EntryType.IN) { // 记录入站统计数据 Constants.ENTRY_NODE.increaseThreadNum(); } throw ex; } catch (BlockException e) { // 如果被限流,记录被限流数 // 省略 block 统计逻辑... throw e; } catch (Throwable ex) { // 如果发生业务异常,记录异常数 // 省略异常统计逻辑... throw ex; } } @Override public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) { // 若无 error(无论是业务异常还是流控异常),记录 complete(success)以及 RT,线程数-1 // 记录数据的维度:线程数+1、记录当前 DefaultNode 数据、记录对应的 originNode 数据(若存在 origin) // 、累计 IN 统计数据(若流量类型为 IN) // 省略 exit 统计逻辑... } } StatisticNode 数据结构 到这里,StatisticSlot 的作用已经比较清晰了。接下来我们需要分析它的统计数据结构。fireEntry 调用向下的节点和之前的方式一样,剩下的节点主要包括: ParamFlowSlot; SystemSlot; AuthoritySlot; FlowSlot; DegradeSlot; 其中比较常见的是流控和熔断:FlowSlot、DegradeSlot,所以下面我们着重分析 FlowSlot。 六、FlowSlot - 流控插槽 这个 Slot 主要根据预设的资源的统计信息,按照固定的次序依次生效。如果一个资源对应两条或者多条流控规则,则会根据如下次序依次检验,直到全部通过或者有一个规则生效为止。 FlowSlot 核心逻辑 @SpiOrder(-2000) public class FlowSlot extends AbstractLinkedProcessorSlot<DefaultNode> { @Override public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, boolean prioritized, Object... args) throws Throwable { // 执行流控检查 checkFlow(resourceWrapper, context, node, count, prioritized); // 继续执行后续 Slot fireEntry(context, resourceWrapper, node, count, prioritized, args); } // 省略其他方法... } checkFlow 方法详解 /** * 执行流控检查 * * @param ruleProvider 规则提供者函数 * @param resource 资源包装器 * @param context 上下文 * @param node 节点 * @param count 请求数量 * @param prioritized 是否优先 * @throws BlockException 如果被限流则抛出异常 */ public void checkFlow(Function<String, Collection<FlowRule>> ruleProvider, ResourceWrapper resource, Context context, DefaultNode node, int count, boolean prioritized) throws BlockException { // 判断规则和资源不能为空 if (ruleProvider == null ** resource == null) { return; } // 获取指定资源的所有流控规则 Collection<FlowRule> rules = ruleProvider.apply(resource.getName()); // 逐个应用流控规则。若无法通过则抛出异常,后续规则不再应用 if (rules != null) { for (FlowRule rule : rules) { if (!canPassCheck(rule, context, node, count, prioritized)) { // FlowException 继承 BlockException throw new FlowException(rule.getLimitApp(), rule); } } } } 通过这里我们就可以得知,流控规则是通过 FlowRule 来完成的,数据来源是我们使用的流控控制台,也可以通过代码进行设置。 FlowRule 流控规则 每条流控规则主要由三个要素构成: grade(阈值类型):按 QPS(每秒请求数)还是线程数进行限流; strategy(调用关系策略):基于调用关系的流控策略; controlBehavior(流控效果):当 QPS 超过阈值时的流量整形行为。 public class FlowRule extends AbstractRule { public FlowRule() { super(); // 来源默认 Default setLimitApp(RuleConstant.LIMIT_APP_DEFAULT); } public FlowRule(String resourceName) { super(); // 资源名称 setResource(resourceName); setLimitApp(RuleConstant.LIMIT_APP_DEFAULT); } /** * 流控的阈值类型 * 0: 线程数 * 1: QPS */ private int grade = RuleConstant.FLOW_GRADE_QPS; /** * 流控阈值 */ private double count; /** * 基于调用链的流控策略 * STRATEGY_DIRECT: 直接流控(按来源) * STRATEGY_RELATE: 关联流控(关联资源) * STRATEGY_CHAIN: 链路流控(按入口资源) */ private int strategy = RuleConstant.STRATEGY_DIRECT; /** * 关联流控模式下的关联资源 */ private String refResource; /** * 流控效果(流量整形行为) * 0: 默认(直接拒绝) * 1: 预热(Warm Up) * 2: 排队等待(Rate Limiter) * 3: 预热 + 排队等待(目前控制台没有) */ private int controlBehavior = RuleConstant.CONTROL_BEHAVIOR_DEFAULT; /** * 预热时长(秒) */ private int warmUpPeriodSec = 10; /** * 排队等待的最大超时时间(毫秒) */ private int maxQueueingTimeMs = 500; /** * 是否为集群模式 */ private boolean clusterMode; /** * 集群模式配置 */ private ClusterFlowConfig clusterConfig; /** * 流量整形控制器 */ private TrafficShapingController controller; // 省略 getter/setter 方法... } 七、滑动窗口算法 不管流控规则采用何种流控算法,在底层都需要有支持指标统计的数据结构作为支撑。在 Sentinel 中,用于支撑基于 QPS 等限流的数据结构是 StatisticNode。 StatisticNode 数据结构 public class StatisticNode implements Node { /** * 保存最近 1 秒内的统计数据 * 每个桶(bucket)500ms,共 2 个桶 */ private transient volatile Metric rollingCounterInSecond = new ArrayMetric(SampleCountProperty.SAMPLE_COUNT, IntervalProperty.INTERVAL); /** * 保存最近 60 秒的统计数据 * windowLengthInMs 被特意设置为 1000 毫秒,即每个桶代表 1 秒 * 共 60 个桶,这样可以获得每秒精确的统计信息 */ private transient Metric rollingCounterInMinute = new ArrayMetric(60, 60 * 1000, false); // 省略其他字段和方法... } ArrayMetric 核心实现 ArrayMetric 是 Sentinel 中数据采集的核心,内部使用了 BucketLeapArray,即滑动窗口的思想进行数据的采集。 public class ArrayMetric implements Metric { /** * 滑动窗口数组 */ private final LeapArray<MetricBucket> data; public ArrayMetric(int sampleCount, int intervalInMs) { this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs); } public ArrayMetric(int sampleCount, int intervalInMs, boolean enableOccupy) { if (enableOccupy) { // 可抢占的滑动窗口,支持借用未来窗口的配额 this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs); } else { // 普通滑动窗口 this.data = new BucketLeapArray(sampleCount, intervalInMs); } } } 这里有两种实现: BucketLeapArray:普通滑动窗口,每个时间桶仅记录固定时间窗口内的指标数据; OccupiableBucketLeapArray:扩展实现,支持"抢占"未来时间窗口的令牌或容量,在流量突发时允许借用后续窗口的配额,实现更平滑的限流效果。 BucketLeapArray - 滑动窗口实现 LeapArray 核心属性 LeapArray 是滑动窗口的基础类,其核心属性如下: /** * 窗口大小(长度),单位:毫秒 * 例如:1000ms */ private int windowLengthInMs; /** * 样本数(桶的数量) * 例如:5(表示 5 个桶,每个 1000ms,总共 5 秒) */ private int sampleCount; /** * 采集周期(总时间窗口长度),单位:毫秒 * 例如:5 * 1000ms(5 秒) */ private int intervalInMs; /** * 窗口数组,array 长度就是样本数 sampleCount */ protected final AtomicReferenceArray<WindowWrap<T>> array; /** * 更新窗口数据的锁,保证数据的正确性 */ private final ReentrantLock updateLock; WindowWrap 窗口包装器 每个窗口包装器包含三个属性: public class WindowWrap<T> { /** * 窗口大小(长度),单位:毫秒 * 与 LeapArray 中的 windowLengthInMs 一致 */ private final long windowLengthInMs; /** * 窗口开始时间戳 * 它的值是 windowLengthInMs 的整数倍 */ private long windowStart; /** * 窗口数据(泛型 T) * Sentinel 目前只有 MetricBucket 类型,存储统计数据 */ private T value; } MetricBucket 指标桶 public class MetricBucket { /** * 计数器数组 * 长度是需要统计的事件种类数,目前是 6 个 * LongAdder 是线程安全的计数器,性能优于 AtomicLong */ private final LongAdder[] counters; // 省略其他字段和方法... } 滑动窗口工作原理 LeapArray 统计数据的基本思路: 创建一个长度为 n 的数组,数组元素就是窗口; 每个窗口包装了 1 个指标桶,桶中存放了该窗口时间范围内对应的请求统计数据; 可以想象成一个环形数组在时间轴上向右滚动; 请求到达时,会命中数组中的一个窗口,该请求的数据就会存到命中的这个窗口包含的指标桶中; 当数组转满一圈时,会回到数组的开头; 此时下标为 0 的元素需要重复使用,它里面的窗口数据过期了,需要重置,然后再使用。 获取当前窗口 LeapArray 获取当前时间窗口的方法: /** * 获取当前时间戳对应的窗口 * * @return 当前时间的窗口 */ public WindowWrap<T> currentWindow() { return currentWindow(TimeUtil.currentTimeMillis()); } /** * 获取指定时间戳对应的窗口(核心方法) * * @param timeMillis 时间戳(毫秒) * @return 对应的窗口 */ public WindowWrap<T> currentWindow(long timeMillis) { if (timeMillis < 0) { return null; } // 计算数组下标 int idx = calculateTimeIdx(timeMillis); // 计算当前请求对应的窗口开始时间 long windowStart = calculateWindowStart(timeMillis); // 无限循环,确保能够获取到窗口 while (true) { // 取窗口 WindowWrap<T> old = array.get(idx); if (old == null) { // 第一次使用,创建新窗口 WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis)); // CAS 操作,确保只初始化一次 if (array.compareAndSet(idx, null, window)) { // 成功更新,返回创建的窗口 return window; } else { // CAS 失败,让出时间片,等待其他线程完成初始化 Thread.yield(); } } else if (windowStart == old.windowStart()) { // 命中:取出的窗口的开始时间和本次请求计算出的窗口开始时间一致 return old; } else if (windowStart > old.windowStart()) { // 窗口过期:本次请求计算出的窗口开始时间大于取出的窗口 // 说明取出的窗口过期了,需要重置 if (updateLock.tryLock()) { try { // 成功获取锁,更新窗口开始时间,计数器重置 return resetWindowTo(old, windowStart); } finally { updateLock.unlock(); } } else { // 获取锁失败,让出时间片,等待其他线程更新 Thread.yield(); } } else if (windowStart < old.windowStart()) { // 异常情况:机器时钟回拨等 // 正常情况不会进入该分支 return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis)); } } } 数据存储 在获取到窗口之后,就可以存储数据了。ArrayMetric 实现了 Metric 中存取数据的接口方法。 示例:存储 RT(响应时间) /** * 添加响应时间数据 * * @param rt 响应时间(毫秒) */ public void addRT(long rt) { // 获取当前时间窗口,data 为 BucketLeapArray WindowWrap<MetricBucket> wrap = data.currentWindow(); // 计数 wrap.value().addRT(rt); } /** * MetricBucket 的 addRT 方法 * * @param rt 响应时间 */ public void addRT(long rt) { // 记录 RT 时间对 rt 值 add(MetricEvent.RT, rt); // 记录最小响应时间(非线程安全,但没关系) if (rt < minRt) { minRt = rt; } } /** * 通用的计数方法 * * @param event 事件类型 * @param n 增加的数量 * @return 当前桶 */ public MetricBucket add(MetricEvent event, long n) { counters[event.ordinal()].add(n); return this; } 数据读取 示例:读取 RT(响应时间) /** * 获取总响应时间 * * @return 总响应时间 */ public long rt() { // 触发当前窗口更新(处理过期窗口) data.currentWindow(); long rt = 0; // 取出所有的 bucket List<MetricBucket> list = data.values(); for (MetricBucket window : list) { rt += window.rt(); // 求和 } return rt; } /** * 获取所有有效的窗口 * * @return 有效窗口列表 */ public List<T> values() { return values(TimeUtil.currentTimeMillis()); } /** * 获取指定时间之前的所有有效窗口 * * @param timeMillis 时间戳 * @return 有效窗口列表 */ public List<T> values(long timeMillis) { if (timeMillis < 0) { return new ArrayList<T>(); // 正常情况不会到这里 } int size = array.length(); List<T> result = new ArrayList<T>(size); for (int i = 0; i < size; i++) { WindowWrap<T> windowWrap = array.get(i); // 过滤掉没有初始化过的窗口和过期的窗口 if (windowWrap == null ** isWindowDeprecated(timeMillis, windowWrap)) { continue; } result.add(windowWrap.value()); } return result; } /** * 判断窗口是否过期 * * @param time 给定时间(通常是当前时间) * @param windowWrap 窗口包装器 * @return 如果过期返回 true */ public boolean isWindowDeprecated(long time, WindowWrap<T> windowWrap) { // 给定时间与窗口开始时间超过了一个采集周期 return time - windowWrap.windowStart() > intervalInMs; } OccupiableBucketLeapArray - 可抢占窗口 为什么需要 OccupiableBucketLeapArray? 假设一个资源的访问 QPS 稳定是 10,请求是均匀分布的: 在时间 0.0-1.0 秒区间中,通过了 10 个请求; 在 1.1 秒的时候,观察到的 QPS 可能只有 5,因为此时第一个时间窗口被重置了,只有第二个时间窗口有值; 当在秒级统计的情形下,用 BucketLeapArray 会有 0~50%的数据误这时就要用 OccupiableBucketLeapArray 来解决这个问题。 OccupiableBucketLeapArray 实现 从上面我们可以看到在秒级统计 rollingCounterInSecond 中,初始化实例时有两种构造参数: public class OccupiableBucketLeapArray extends LeapArray<MetricBucket> { /** * 借用未来窗口的数组 */ private final FutureBucketLeapArray borrowArray; public OccupiableBucketLeapArray(int sampleCount, int intervalInMs) { super(sampleCount, intervalInMs); // 创建借用窗口数组 this.borrowArray = new FutureBucketLeapArray(sampleCount, intervalInMs); } /** * 创建新的空桶 * 会从 borrowArray 中借用数据 */ @Override public MetricBucket newEmptyBucket(long time) { MetricBucket newBucket = new MetricBucket(); // 获取借用窗口的数据 MetricBucket borrowBucket = borrowArray.getWindowValue(time); if (borrowBucket != null) { // 将借用数据复制到新桶中 newBucket.reset(borrowBucket); } return newBucket; } /** * 重置窗口 * 会从 borrowArray 中借用 pass 数据 */ @Override protected WindowWrap<MetricBucket> resetWindowTo(WindowWrap<MetricBucket> w, long time) { // 更新开始时间并重置值 w.resetTo(time); MetricBucket borrowBucket = borrowArray.getWindowValue(time); if (borrowBucket != null) { // 重置桶值并添加借用的 pass 数据 w.value().reset(); w.value().addPass((int) borrowBucket.pass()); } else { w.value().reset(); } return w; } /** * 获取当前等待中的请求数量 */ @Override public long currentWaiting() { borrowArray.currentWindow(); long currentWaiting = 0; List<MetricBucket> list = borrowArray.values(); for (MetricBucket window : list) { currentWaiting += window.pass(); } return currentWaiting; } /** * 添加等待中的请求数量 * * @param time 时间 * @param acquireCount 获取数量 */ @Override public void addWaiting(long time, int acquireCount) { WindowWrap<MetricBucket> window = borrowArray.currentWindow(time); window.value().add(MetricEvent.PASS, acquireCount); } } 八、总结 至此,Sentinel 的基本情况都已经分析完成。以上内容主要讲解了 Sentinel 的核心处理流程,包括: 核心流程总结 规则加载: 通过 RuleLoader 将各种规则(流控、熔断、系统限流等)加载到内存缓存中。 请求拦截: 通过 SentinelServletFilter 过滤器拦截 HTTP 请求; 通过SentinelResourceAspect切面处理 @SentinelResource 注解。 责任链处理: 使用 ProcessorSlotChain 责任链模式组合多个功能插槽; 每个插槽负责特定的功能(统计、流控、熔断等)。 流控判断: FlowSlot 根据流控规则判断是否限流; 通过滑动窗口算法统计 QPS、线程数等指标。 异常处理: 被限流时抛出 BlockException; 通过 blockHandler 或 fallback 处理异常。 核心技术点 责任链模式: 通过 ProcessorSlotChain 将不同的限流功能组合在一起。 滑动窗口算法: LeapArray 实现环形滑动窗口; BucketLeapArray 普通滑动窗口; OccupiableBucketLeapArray 可抢占窗口,支持借用未来配额。 数据结构: DefaultNode:特定于上下文的统计节点; ClusterNode:不区分上下文的集群统计节点; StatisticNode:核心统计节点,包含秒级和分钟级统计。 限流算法: QPS 限流:通过滑动窗口统计 QPS; 线程数限流:通过原子计数器统计线程数; 流控效果:快速失败、预热、排队等待等; Sentinel 通过精心设计的架构,实现了高效、灵活、可扩展的流量控制能力,为微服务系统提供了强大的保护机制。 往期回顾 1.社区推荐重排技术:双阶段框架的实践与演进|得物技术 2.Flink ClickHouse Sink:生产级高可用写入方案|得物技术 3.服务拆分之旅:测试过程全揭秘|得物技术 4.大模型网关:大模型时代的智能交通枢纽|得物技术 5.从"人治"到"机治":得物离线数仓发布流水线质量门禁实践 文 /万钧 关注得物技术,每周更新技术干货 要是觉得文章对你有帮助的话,欢迎评论转发点赞~ 未经得物技术许可严禁转载,否则依法追究法律责任。

优秀的个人博客,低调大师

🔥 理解 Liquor :动态编译 Java 代码的神器

引言 Liquor 是一个开源的轻量级 Java 动态编译器(零依赖,24KB),它可以在运行时编译 Java 字符串代码片段、类、方法等。 源码地址:https://gitee.com/noear/liquor 编译特性: 可以单个类编译 可以多个类同时编译 可以增量编译 Liquor 的基本使用 需求:输入一个类定义的 java 字符串(内容逻辑为输出 Hello World ),然后使用 Liquor 去动态编译并执行。 首先,需要在项目中添加 Liquor 依赖,以 maven 为例: <dependency> <groupId>org.noear</groupId> <artifactId>liquor</artifactId> <version>1.1.1</version> </dependency> 接着可以写 Liquor 相关的代码了: public class DemoApp { public static void main(String[] args) throws Exception{ String className = "HelloWorld"; String classCode = "public class HelloWorld { " + " public static void helloWorld() { " + " System.out.println(\"Hello, world!\"); " + " } " + "}"; DynamicCompiler compiler = new DynamicCompiler(); //添加源码(可多个)并 构建 compiler.addSource(className, classCode).build(); Class<?> clazz = compiler.getClassLoader().loadClass(className); clazz.getMethod("helloWorld").invoke(null); } }

优秀的个人博客,低调大师

深入理解 MySQL 中的 SQL_MODE

SQL_MODE 是 MySQL 数据库中的一个系统变量,用于控制 MySQL 如何处理 SQL 语句和数据校验。它可以看作是一组约束和规范,确保数据的准确性、完整性和一致性。例如,可以控制如何处理无效日期、是否允许插入不完整的记录、是否区分大小写等。 > 作者:许祥,爱可生 MySQL DBA 团队成员,负责处理客户 MySQL 及我司自研 DMP 平台日常运维中的问题。 > >爱可生开源社区出品,原创内容未经授权不得随意使用,转载请联系小编并注明来源。 > >本文约 2600 字,预计阅读需要 8 分钟。 什么是 SQL_MODE? SQL_MODE 是 MySQL 数据库中的一个系统变量,用于控制 MySQL 如何处理 SQL 语句和数据校验。它可以看作是一组约束和规范,确保数据的准确性、完整性和一致性。例如,可以控制如何处理无效日期、是否允许插入不完整的记录、是否区分大小写等。 在 MySQL 中,通过设置 SQL_MODE 可以解决下面几类问题: 完成不同严格程度的数据校验,有效地保障数据准确性。 保证大多数 SQL 符合标准的 SQL 语法,这样应用在不同数据库之间进行迁移时,则不需要对业务 SQL 进行较大的修改。 在不同数据库之间进行数据迁移之前,通过设置 SQL_MODE 可以使 MySQL 上的数据更方便地迁移到目标数据库中。 通过阅读本文,你将收获以下知识点: MySQL 不同版本的默认 SQL_MODE 值 常用的 SQL_MODE 设置及其含义 SQL_MODE 在数据迁移中的关键作用 SQL_MODE 有哪些值? MySQL 5.7 的默认值和描述 ONLY_FULL_GROUP_BY:在 GROUP BY 子句中没有出现的列,若出现在 SELECT 列表、HAVING 条件、ORDER BY 条件中时会被拒绝。 STRICT_TRANS_TABLES:非法日期,超过字段长度的值插入时,直接报错,拒绝执行。例如,如果向一个整数列插入超出范围的值,将触发错误。 NO_ZERO_DATE:针对日期 '0000-00-00',执行逻辑如下: disable:可以正常插入,没有警告。 enable:可以正常插入,有警告。 如果 SQL_MODE 中包含 STRICT TRANS TABLES,则日期被拒绝写入,但可以通过加 IGNORE 关键字写入 '0000-00-00',有警告。 NO_ZERO_IN_DATE:日期中针对月份和日期部分,如果为0,比如 '2024-00-00',有不同的执行逻辑: disable:可以正常插入,实际插入值还是 '2024-00-00' 没有警告。 enable:可以正常插入,有警告。 如果 SQL_MODE 中包含STRICT TRANS TABLES 则日期被拒绝写入,但可以通过加 IGNORE 关键字写入 '0000-00-00'。 ERROR_FOR_DIVISION_BY_ZERO:除数为 0(包括 MOD(N,0)),执行逻辑如下: disable:插入 NULL,没有警告。 enable:插入 NULL,有警告。 如果 SQL_MODE 中包含 STRICTTRANSTABLES,则数据被拒绝写入,但可以通过加 IGNORE 关键字写入 NULL,有警告。 NO_AUTO_CREATE_USER:防止使用不带密码子句的 GRANT 语句来创建一个用户。 NO_ENGINE_SUBSTITUTION:执行 CREATE TABLE 或者 ALTER TABLE 语句时,如果指定的存储引擎不可用,MySQL 会抛出错误,而不是使用默认存储引擎。 disable:CREATE TABLE 会自动替换后执行,ALTER TABLE 不会执行,两个命令都有警告。 enable:两个命令直接报错。 MySQL 8.0 的默认值和描述 STRICT_TRANS_TABLES:同 MySQL 5.7,确保插入或更新的数据严格符合表的定义。 NO_ZERO_DATE:同 MySQL 5.7,禁止日期字段为 '0000-00-00',要求输入有效的日期。 NO_ZERO_IN_DATE:同 MySQL 5.7,禁止日期或日期时间字段中的月份或日部分为零。 ERROR_FOR_DIVISION_BY_ZERO:同 MySQL 5.7,当除以零时,MySQL 将抛出错误,而不是返回 NULL。 NO_AUTO_CREATE_USER:同 MySQL 5.7,禁止通过 GRANT 语句自动创建用户。 NO_ENGINE_SUBSTITUTION:同 MySQL 5.7,确保指定的存储引擎必须存在,不会自动替换。 查询 SQL_MODE -- MySQL 5.7 mysql&gt; show variables like 'sql_mode'; +---------------+-------------------------------------------------------------------------------------------------------------------------------------------+ | Variable_name | Value | +---------------+-------------------------------------------------------------------------------------------------------------------------------------------+ | sql_mode | ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION | +---------------+-------------------------------------------------------------------------------------------------------------------------------------------+ 1 row in set (0.00 sec) -- MySQL 8.0 mysql&gt; show variables like 'sql_mode'; +---------------+-----------------------------------------------------------------------------------------------------------------------+ | Variable_name | Value | +---------------+-----------------------------------------------------------------------------------------------------------------------+ | sql_mode | ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION | +---------------+-----------------------------------------------------------------------------------------------------------------------+ 1 row in set (0.00 sec) SQL_MODE 常见设置模式 SQL_MODE 的常见设置模式是一组可以选择的选项,这些选项可以根据需要组合使用,以改变数据库的行为。以下是一些常见的模式及其含义: ANSI 模式:宽松模式,对插入数据进行校验,如果不符合定义类型或长度,对数据类型调整或截断保存,报 WARNING 警告。 STRICT_TRANS_TABLES 模式:严格模式,进行数据的严格校验,错误数据不能插入,报 ERROR 错误。只对支持事务的表有效。 STRICT_ALL_TABLES 模式:严格模式,进行数据的严格校验,错误数据不能插入,报 ERROR 错误。对所有表都有效。 TRADITIONAL 模式:严格模式,当向 MySQL 数据库插入数据时,进行数据的严格校验,保证错误数据不能插入,报 ERROR 错误。用于事务时,会进行事务的回滚。 在下面的例子中,观察一下非法日期“2007-04-31”(因为 4 月没有 31 日)在不同 SQL_MODE 下能否正确插入。 mysql&gt; select @@session.sql_mode; +-------------------------------------------------------------------------------------------------------------------------------------------+ | @@session.sql_mode | +-------------------------------------------------------------------------------------------------------------------------------------------+ | ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION | +-------------------------------------------------------------------------------------------------------------------------------------------+ 1 row in set (0.00 sec) mysql&gt; set session sql_mode='ANSI'; Query OK, 0 rows affected, 1 warning (0.00 sec) mysql&gt; select @@session.sql_mode; +--------------------------------------------------------------------------------+ | @@session.sql_mode | +--------------------------------------------------------------------------------+ | REAL_AS_FLOAT,PIPES_AS_CONCAT,ANSI_QUOTES,IGNORE_SPACE,ONLY_FULL_GROUP_BY,ANSI | +--------------------------------------------------------------------------------+ 1 row in set (0.00 sec) mysql&gt; create table t_sql_mode_ansi(d datetime); Query OK, 0 rows affected (0.01 sec) mysql&gt; insert into t_sql_mode_ansi values('2007-04-31'); Query OK, 1 row affected, 1 warning (0.00 sec) mysql&gt; select * from t_sql_mode_ansi; +---------------------+ | d | +---------------------+ | 0000-00-00 00:00:00 | +---------------------+ 1 row in set (0.00 sec) mysql&gt; set session sql_mode='TRADITIONAL'; Query OK, 0 rows affected, 1 warning (0.00 sec) mysql&gt; select @@session.sql_mode; +------------------------------------------------------------------------------------------------------------------------------------------------------+ | @@session.sql_mode | +------------------------------------------------------------------------------------------------------------------------------------------------------+ | STRICT_TRANS_TABLES,STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,TRADITIONAL,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION | +------------------------------------------------------------------------------------------------------------------------------------------------------+ 1 row in set (0.00 sec) mysql&gt; insert into t_sql_mode_ansi values('2007-04-31'); ERROR 1292 (22007): Incorrect datetime value: '2007-04-31' for column 'd' at row 1 很显然,在 ANSI 模式下,非法日期可以插人,但是插入值却变为“0000-00-00 00:00:00”并且系统给出了 WARNING;而在 TRADITIONAL 模式下,会直接提示日期非法,拒绝插入。STRICT_TRANS_TABLES 模式和 STRICT_ALL_TABLES 模式同样如此。 SQL_MODE 在迁移中的用法 当从 MySQL 迁移到其他异构数据库时,SQL_MODE 的配置和调整在数据迁移过程中起着关键作用。 由于不同数据库系统在 SQL 标准、数据类型处理、错误处理等方面存在差异,正确使用 SQL_MODE 可以帮助确保迁移过程中数据的完整性和一致性。 SQL_MODE 是 MySQL 的一个配置选项,控制着 SQL 语句的解析和执行方式。 例如,SQL_MODE 可以影响 NULL 值的处理、日期格式的验证、GROUP BY 的严格性等。当迁移数据到其他数据库时,目标数据库可能不支持或以不同方式处理这些规则,因此在迁移前需要仔细配置和测试 SQL_MODE。 1. SQL_MODE 在迁移前的调整 在将 MySQL 数据库迁移到其他数据库之前,可以考虑以下步骤来调整 SQL_MODE,以减少迁移时可能出现的兼容性问题: 禁用严格模式:在迁移前,禁用 MySQL 中的严格模式(如 STRICT_TRANS_TABLES)可以帮助识别哪些数据在当前配置下可能会在目标数据库中引发问题。通过禁用严格模式,可以提前发现并处理不兼容的数据。 SET GLOBAL SQL_MODE =''; 禁用 ONLY_FULL_GROUP_BY:MySQL 的 ONLY_FULL_GROUP_BY 模式要求所有非聚合列必须在 GROUP BY 子句中,这在其他数据库中可能不是必需的。禁用该模式可以确保 SQL 查询在目标数据库中能正确执行。 启用宽松模式:通过设置较为宽松的 SQL_MODE(如禁用 NO_ZERO_DATE、NO_ZERO_IN_DATE),可以让 MySQL 接受一些可能在目标数据库中允许的数据格式,并确保这些数据能被顺利迁移。 2. 在迁移过程中处理 SQL_MODE 相关问题 日期和时间处理:一些数据库系统对日期和时间有更严格的要求。例如,0000-00-00 这样的日期在 MySQL 中可能是合法的,但在其他数据库中会引发错误。在迁移前,需要确保数据格式符合目标数据库的要求,或者通过 SQL 脚本清理这些数据。 空字符串与 NULL 的处理:在 MySQL 中,空字符串和 NULL 可能在一些情况下被视为相等,而在其他数据库中并非如此。迁移前,应该明确这些字段的逻辑,并在必要时进行转换。 标识符大小写:MySQL 对标识符的大小写敏感性可能与目标数据库不同。在迁移前,使用 SQL_MODE 的 ANSI_QUOTES 选项可以确保标识符的引用方式符合 SQL 标准,并减少在目标数据库中的兼容性问题。 3. 迁移后的兼容性测试 完成数据迁移后,需要在目标数据库中进行全面的兼容性测试,以确保迁移后的数据和应用程序能正常运行。重点测试的领域包括: 查询结果的准确性:检查涉及 GROUP BY、聚合函数、日期处理等 SQL 查询是否在目标数据库中返回预期结果 数据完整性:确保迁移后的数据没有丢失、截断或被错误转换。 性能:有些 SQL_MODE 设置可能影响查询性能,迁移后需要在目标数据库中优化相关查询。 4. 小结 在从 MySQL 迁移到其他数据库时,合理调整 SQL_MODE 可以显著减少迁移过程中的兼容性问题。通过禁用严格模式、调整日期和空值处理、确保标识符的一致性,以及进行全面的测试,可以确保迁移后的数据和应用程序在新的数据库环境中稳定运行。 5. 迁移过程中需要注意 在数据迁移过程中,可以设置 SQL_MODE 为 NO_TABLE_OPTIONS 模式。这样将去掉 SHOW CREATE TABLE 中的 ENGINE 关键字,获得通用的建表脚本。 测试示例如下: mysql&gt; show create table test\G *************************** 1. row *************************** Table: test Create Table: CREATE TABLE `test` ( `ename` varchar(10) COLLATE utf8mb4_bin DEFAULT NULL, `age` int(11) DEFAULT NULL, `sal` decimal(10,2) DEFAULT NULL, `hiredate` date DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin 1 row in set (0.00 sec) mysql&gt; mysql&gt; set session sql_mode='NO_TABLE_OPTIONS'; Query OK, 0 rows affected, 2 warnings (0.00 sec) mysql&gt; mysql&gt; show create table test\G *************************** 1. row *************************** Table: test Create Table: CREATE TABLE `test` ( `ename` varchar(10) COLLATE utf8mb4_bin DEFAULT NULL, `age` int(11) DEFAULT NULL, `sal` decimal(10,2) DEFAULT NULL, `hiredate` date DEFAULT NULL ) 1 row in set (0.00 sec) 总结 SQL_MODE 的“严格模式”为 MySQL 提供了很好的数据校验功能,保证了数据的准确性,TRADITIONAL 和 STRICT_TRANS_TABLES 是常用的两种严格模式,要注意两者的区别。 SQL_MODE 的多种模式可以灵活组合,组合后的模式可以更好地满足应用程序的需求。尤其在数据迁移中,SQL_MODE 的使用更为重要。 更多技术文章,请访问:https://opensource.actionsky.com/ 关于 SQLE SQLE 是一款全方位的 SQL 质量管理平台,覆盖开发至生产环境的 SQL 审核和管理。支持主流的开源、商业、国产数据库,为开发和运维提供流程自动化能力,提升上线效率,提高数据质量。 ✨ Github:https://github.com/actiontech/sqle 📚 文档:https://actiontech.github.io/sqle-docs/ 💻 官网:https://opensource.actionsky.com/sqle/ 👥 微信群:请添加小助手加入 ActionOpenSource 🔗 商业支持:https://www.actionsky.com/sqle

优秀的个人博客,低调大师

深入理解树状数组 | 京东物流技术团队

树状数组 树状数组(BIT, Binary Indexed Tree)是简洁优美的数据结构,它能在很少的代码量下支持单点修改和区间查询,我们先以a[] {1, 2, 3, 4, 5, 6}数组为例建立树状数组看一下树状数组的样子: 可以发现:不是所有节点都是连接在一起的,c[1], c[2], c[3], c[4] 和 c[5], c[6] 分别构成了两棵树;奇数索引位置的节点只管辖一个数组元素(我们例子中以 1 为起始索引)。那么这个树状数组是怎么计算和推导出来的呢? 管辖的区间 树状数组的每个元素会管辖多少个数组元素?也就是说每个元素的区间长度是多少?我们从上图中已经知道了奇数的树状数组元素只管辖一个元素,区间为 c[x] = [x, x],那么我们只需再研究下偶数元素管辖的区间长度即可。 c[y] 所管辖的区间长度为 2k,其中 k 为 y 的 2 进制表示中最低位 1 后面所有 0 的数量;c[y] 所管辖的区间为:[y - 2k+ 1, y] 我们以 c[4] 为例,它管辖多少个元素呢?4 的 2 进制表示为 0100,最低位 1 后面 0 的数量为 2,即 k = 2,那么 2k= 22= 4,所以它管辖的区间长度为 4,也就是 4 个数组元素,区间为 [4 - 4 + 1, 4] = [1, 4]。 父节点是谁? 现在我们知道每个元素所管辖的区间范围了,那么我们怎么才能知道它的父节点是谁呢?就比如说我们现在得到了 c[1] 元素,我们想知道它的父节点,要怎么计算呢? c[x] 的父节点为 c[x + lowbit(x)] 怎么回事?其中的**lowbit(x)**是什么东西?其实它的值和 2k一致,其中 k 为 x 的 2 进制表示中最低位 1 后面所有 0 的数量,熟悉不熟悉?这个 lowbit(x) 和我们上文中计算该元素所管辖区间长度的值一致!这不就简单了! lowbit(x) 的计算方法:lowbit(x) = x & -x 我们以计算 c[2] 为例,lowbit(2) = 2 & -2,其中 2 的 2 进制表示为 0010,-2 的 2 进行表示为 1110,它的计算方法为将 2 的所有非符号位二进制全部取反后再加 1,即 1101 + 1 = 1110,执行 & 运算后结果为 0010,十进制表示为 2,与 21值一致。lowbit 的计算用代码表示为: int lowbit(int x) { return x & -x; } 我们以 c[1] 节点为例计算下它的父节点是谁,lowbit(1) = 1 & -1 = 0001 & 1111 = 0001 = 1,那么它的父节点为 c[1 + 1] = c[2],与图上表示的一致。 现在我们已经知道如何通过计算来创建树状数组了, 接下来我们要看下它的应用。 区间查询 区间查询我们先讨论计算前 N 项和的方法,比如我们现在要查询前 6 项和,我们来看下它查询的过程: 从 c[6] 开始找子节点,有 c[6] 管辖的区间为 [5, 6],那么再往下找需要找 c[4],它的区间为 [1, 4],计算这两个节点的和即可。 那么从 c[6] 跳到 c[4] 是如何计算出来的呢?我们可以通过 c[6] 区间的下界减 1 来得到,转换成公式表示即为 x - lowbit(x) = 6 - 2 = 4,当它跳到 c[4] 时发现已经满足求和条件,不再向下跳而结束查找,而且我们可以通过计算 4 - lowbit(4) = 4 - 4 = 0 ,可以发现当 x - lowbit(x) = 0 时为结束查找的条件。我们用代码来表示为: int query(int x) { int res = 0; for (int i = x; i > 0; i -= lowbit(i)) { res += c[i]; } return res; } 那么我们计算区间 [3, 6] 的和该如何计算呢?我们从图中可以发现,先计算出[1, 6] 和 [1, 2] 的和,再使用前者减去后者即为所得,用代码表示为: int query(int left, int right) { return query(right) - query(left - 1); } 单点修改 如果我们要修改 a[x] 的值,我们仅需要修改所有管辖了 a[x] 的 c[y] 即可,而 a[x] 可能会被多个 c[y] 管辖,这些所有的 c[y] 节点该如何确定呢?我们可以回头再去看看前面的树状数组配图,比如我们要修改 a[1] 的值,那么我们需要修改 c[1], c[2] 和 c[4] ,能不能发现它是在不断的跳父节点修改?所以,如果我们要修改数组中某个元素的值,树状数组的更新则是不断地更新父节点值。好,我们直接上代码吧: // 将 index 索引处的值更新为 num void update(int index, int num) { a[index] = num; add(index, num - a[index]); } // 更新 c[index] 的值,变化差值为 val void add(int index, int val) { for (int i = index; i <= c.length; i += lowbit(i)) { c[i] += val; } } 建树 好了,区间查询和单点修改我们都讲完了,但是从头到尾我们还没说过树状数组是怎么建立的呢。我们可以想一下,c 数组初始化时每个索引处的值都为 0,建树仅需要将 a 数组中所有值都在树状数组中执行单点修改即可: public BinaryIndexedTree(int[] a) { this.a = a; this.c = new int[a.length + 1]; for (int i = 0; i < a.length; i++) { add(i + 1, a[i]); } } 到这里我们基本上已经将树状数组讲解完毕了,它的全量代码如下: public class BinaryIndexedTree { int[] a; int[] c; public BinaryIndexedTree(int[] a) { this.a = a; this.c = new int[a.length + 1]; for (int i = 0; i < a.length; i++) { add(i + 1, a[i]); } } // 将 index 索引处的值更新为 num void update(int index, int num) { a[index] = num; add(index, num - a[index]); } // 更新 c[index] 的值,变化差值为 val void add(int index, int val) { for (int i = index; i < c.length; i += lowbit(i)) { c[i] += val; } } int query(int left, int right) { return query(right) - query(left - 1); } // 查询前缀和的方法 int query(int x) { int res = 0; for (int i = x; i > 0; i -= lowbit(i)) { res += c[i]; } return res; } int lowbit(int x) { return x & -x; } } 巨人的肩膀 树状数组(简单介绍) 负数的二进制表示方法(正数:原码、负数:补码) 树状数组 算法学习笔记(2) : 树状数组 维基百科 - 树状数组 关于各类「区间和」问题如何选择解决方案(含模板) 算法学习笔记(19): 离散化 作者:京东物流 王奕龙 来源:京东云开发者社区 自猿其说Tech 转载请注明来源

优秀的个人博客,低调大师

Lakehouse系列 | StarRocks支持Apache Hudi原理解析

作者:王日宇,StarRocks Active Contributor,阿里云高级研发工程师 近年来,随着大数据分析技术的进步,大量业务场景对数据仓库的实时性提出了更高的要求,Lakehouse 架构逐渐被各大公司熟悉和接受,Apache Hudi(以下简称 Hudi)、Apache Iceberg(以下简称 Iceberg)、Delta Lake 都被看成是下一代数据湖的解决方案,并被称为数据湖技术三剑客。 StarRocks 作为当前最流行的 OLAP 分析引擎之一,阿里云EMR OLAP 团队与 StarRocks 社区在 2021 年就开始合作,共同设计并实现了 StarRocks Connector 框架,用于统一外部数据源的接入分析,并且基于 Connector 框架完成了对 Hudi、Iceberg、Delta Lake 等主流数据湖表格式的接入。 本文围绕以 Hudi 为中心的数据湖生态,详细介绍 StarRocks 是如何实现快速分析 Hudi 数据湖格式,并给出完整的使用案例。 #01 StarRocks 支持 Hudi 的现状 — Hudi 提供了两种类型的表: Copy On Write 表(简称 COW ) Merge On Read 表(简称 MOR ) StarRocks 早在 2.2 版本就正式发布了对 Hudi COW 表的 Snapshot 查询分析支持。对于 MOR 表的 Snapshot/ Read Optimized 查询支持功能,目前已在主分支可用,预计会在 2.5 版本正式对外发布。届时,StarRocks 将会完成对 Hudi 数据湖格式的全面支持。 最新发布的 StarRocks 2.4 版本使用全新 Connector 框架对 Apache Hive(以下简称 Hive)/Iceberg/Hudi 等表格式的读取特性进行了重构和改造,使用户无需再手动逐个创建外表,直接简单配置 Hudi Catalog ,便可查询到数据库下所有 Hudi 表格式数据。 #02 StarRocks 支持 Hudi 的技术解析 — 在讲解 StarRocks 支持 Hudi 技术解析前,我们先简单了解一些 Hudi 表的基本知识。 1、Hudi数据湖格式简介 Hudi 将数据仓库和数据库的核心功能直接引入数据湖,并提供了表、事务、高效的更新/删除、高级索引、流式摄取服务、小文件管理、压缩优化和读写并发隔离等技术,同时将数据保持为开源文件格式(Parquet,ORC,Avro)。 2、Hudi表类型 Copy On Write 表:简称 COW,这类 Hudi 表使用列存储格式(例如 Parquet)存储数据,如果有数据写入,则会对整个 Parquet 文件进行复制。COW 表具备列存储读取效率高的优点,适用于对读取效率要求高的场景。 Merge On Read 表:简称 MOR,这类 Hudi 表使用列存储格式(例如 Parquet)和行存储格式(Avro)共同存储数据。当数据更新时,将更新写入到行文件中,然后根据作业配置,以同步或异步方式将两种格式的数据文件进行合并并生成新的列存储文件(Compaction)。MOR 表具备行存储写入效率高的优点,适用于对写入效率要求高的场景。 Hudi 对于两种类型的表,提供了不同的查询类型: Snapshot Queries:快照查询,查询数据的最新快照,即表的最新数据 Incremental Queries:增量查询,可以查询指定时间范围内新增或修改的数据 Read Optimized Queries:读取优化查询,该查询为 MOR 表特有,仅查询 Parquet 文件中的数据,即牺牲一部分数据时效性来换取最佳的读取性能 3、Hudi数据文件的组织方式和概念 Hudi 将单个目录下的数据划分为多个 File group,每个 File group下包含多个 File Slice,File Slice 是 Hudi 文件组织系统里的最小管理单位,它由 0 个或 1 个 base 数据文件(列存储)和 0 个或多个增量 delta log 文件(行存储)组成。 对于 COW 表,则 File Slice 不包含 delta log 文件,只有 1 个 base 数据文件 4、StarRocks架构简介 StarRocks 整体架构分为两大部分:FE 和 BE,FE 使用 Java 语言,BE 使用 C++ 语言。 下面我们分别介绍 FE 和 BE 在外部表读取时对应的主要功能。 ​ FE 的主要作用有: 1. 缓存外部表元数据。其中,元数据信息分为两类: 数据表的基本元信息,如表结构 Schema、存储位置、分区信息、不同类别的统计信息等; 数据表下的数据文件信息,如某个分区下的文件列表、大小、压缩格式等。 2. 查询规划。包括解析 SQL 文本,生成逻辑执行计划和物理执行计划,对执行计划进行优化,如分区/列裁剪,Join Reorder 等。 3. 查询调度。使用上述的外部表元数据,根据数据的分布情况分配可用的 BE 节点资源等信息,规划每个 BE 需要执行的具体计算任务,并使用算法保证数据本地性的同时,尽可能让每个 BE 节点并行地读取大致相同的数据量以提高执行效率。 BE 主要作用有: 1. 执行 FE 分配的计算任务。如 Scan、Join、Shuffle、Aggregate 等。 2. 向 FE 汇报执行状态,传输执行结果。 5、Hudi的读取支持介绍 Hudi 的格式支持开发工作主要对 FE/BE 在外表上的对应功能来针对性实现和优化。 FE 端改造: 1. 在外部表的元信息方面,增加存储 Hudi 特有的元信息: 表类型,用来存储 Hudi 表类型。取值为 COW 或 MOR; Input Format,用来区分 MOR 的查询类型。Hudi 的 MOR 表在写入端(如 Spark)建表时,会额外生成 <table name>_rt 和 <table name>_ro 两张表,分别对应同一张 MOR 表的 Snapshot 查询表和 Read Optimized 查询表。Snapshot 查询表的 Input Format 信息取值为 org.apache.hudi.hadoop.realtime.HoodieParquetRealtimeInputFormat,Read Optimized 查询的 Input Format 信息取值为 org.apache.hudi.hadoop.HoodieParquetInputFormat; 数据文件元信息方面,增加 Optional 的 Delta Logs 文件列表字段,用来存储 Hudi MOR 表 Base 数据文件相对应的 Logs 更新文件信息。 2. 将 Hudi 元信息采集模块集成到全新 Catalog 框架内,只需要创建一次Hudi Catalog,后续便可以直接查询该 Catalog 下的所有 Hudi 表,不再需要以前的手工创建外部表的繁琐过程。 (https://docs.starrocks.io/zh-cn/latest/data_source/catalog/hudi_catalog) 全新 Catalog 框架结构: 3. CBO 优化器和执行计划优化: 通过 Hive Metastore 读取和缓存 Hudi 分区信息,应用到分区数据裁剪优化规则里; 通过 Hive Metastore 读取和缓存 Hudi 表的相关统计信息,应用到 Join Reorder 等 CBO 优化规则里。 4.表结构解析完成 Hudi 表字段类型映射: 通过读取 Hudi 表的 Avro Schema,在各个字段上完成 Hudi Type 到 StarRocks Type 映射,具体如下: 备注:即将发布的 StarRocks 2.5.0 版本会支持 Struct、Map 数据类型的读取,Hudi COW 表也会同步支持。 5. 平衡 IO 请求优化: StarRocks 查询传统的 Hive 表的时候,若单个数据文件的大小过大,会对这个文件进行 File Split 均匀拆分,形成多个 Scan Range。但在 Hudi 表的实现里,我们直接对 Base file 和对应 Delta Logs(即 File Slice)形成一个 Scan Range,除了有潜在的 Merge 逻辑数据问题之外,还有 IO 效率取舍,即使做了 File Split 拆分,比如拆成 3 份,其对应的 Delta Logs 就需要跟着拆分后的 File Slice 重复读取3次,这很可能会加剧存储系统的压力。在工程实现上,我们在 Scan Range 里添加了 useJNIReader 的标识字段,用来指示 BE 侧执行选择 Native Reader 还是 JNI reader,这两个 Reader 的具体用途见下文 BE 端改造部分。 6. 元数据请求优化: 通过批量以及异步的方式访问 Hive Metastore 获取 Hudi 表分区统计信息,在 FE 对其进行缓存并计算表级别统计信息用于查询优化,同时缓存 Scan 节点所要扫描的文件元数据信息来减少 Namenode 的压力并提升查询速度。用户可通过查询来触发元数据的异步加载,使得用户无需手动刷新元数据即可访问最新数据文件。 FE 和 BE 传输改造: 由于 FE 和 BE 使用 Thrift 协议传输信息,分别按照表元数据和 Scan Range 的改动,相对应添加了 Thrift 字段,确保 BE 端可以正确拿到执行计算所需要的信息。 BE 端改造: StarRocks BE 端本身自己实现了高效的 Native Reader,用来读取不同的数据格式,如 ORC/Parquet 等。Native Reader 做了很多 IO 优化的实现,如: 延迟物化:根据查询请求特征进行优化,先将参与谓词计算的列读出来,然后根据计算谓词的过滤结果,再将过滤结果下推读取其他列,并跳过被过滤的行,从而达到减少 IO 请求次数,提升查询性能。 Coalesce IO:读取数据文件时,合并小的 IO 请求(如小的列,小的 Row Group),减少 IO 次数,增加每次 IO 的请求量,提升查询性能。 - BE 端 Hudi Reader 设计方案评估过程 在实现 Hudi MOR 的支持过程中,在设计阶段我们考虑了两种可行方案: 1.使用 C++ 在 BE 全新实现一套 Hudi Reader,用来读取 Avro 格式的 Delta logs 文件和 Parquet 格式的 Base 文件,然后参考 Hudi 社区 Java 版本的 merge 逻辑完成合并,最终输出结果数据。这种方案可以带来 native 执行速度快的优势,但缺点也很明显,自行维护成本太高,且不方便及时更新社区的进展。 2. 在 BE 端使用 JNI 调用 Hudi 社区的 Java SDK,直接使用社区 SDK 读取 File Slice 的结果数据,然后传给 BE 端使用。与第一种方案相比,优缺点正好相反,但代码维护工作量会大大减少。同时,我们考虑到 Java 在大数据生态的优势地位,想统一抽象一个通用的 JNI Reader,让所有基于 Java 实现的数据源 SDK,都可以通过 JNI Reader 方便地接入到 StarRocks 里来。 因此,我们最终选择了第二套基于 JNI 方案,并把实现目标从原来的仅支持 Hudi MOR 数据格式扩展到实现通用的 JNI Connector。 - JNI Connector 设计介绍 在实现过程中,我们对 JNI Connector 做了大量的优化,同时借鉴了 Spark Photon 的内存设计和实现思路,让 C++ 和 Java 使用同一块 Native Memory 共享数据,Java 端使用 JVM 的 Native 操作类 Unsafe 直接将 Hudi Java SDK 的结果数据写入 Native Memory 里,C++ 便可以直接使用这块内存进行数据计算,从而减少数据多次内存拷贝的开销。实际上,类似这样的跨语言共享内存的编程模式在阿里云 EMR 技术产品里实践落地非常广泛,如基于开源 Apache Spark 做了深度优化的 EMR Spark,对 OSS 透明加速的 Jindo 等都有类似的设计和思想。接下来,我们将展开讲讲这块的具体设计思路。 数据列存储设计 在 BE 端,所有数据列是存储在 Column 的数据结构里,不同类型的数据列对应不同的 Column 数据结构。例如: 存储 INT 数据列使用的数据结构是 FixedLengthColumn<int32_t> 存储 VARCHAR类型数据列使用的数据结构是 BinaryColumnBase<uint32_t> 这些对应关系可以参考type_traints.h 源文件。 (https://github.com/StarRocks/starrocks/blob/main/be/src/column/type_traits.h) 实现 JNI Connector 工程上比较复杂的地方是,需要根据不同类型的数据类型安排对应的内存布局实现,且要尽量做到与 BE 端对应数据类型的内存布局相同,进而尽量减少 BE 端读取内存数据到 Column 时转化的开销。 对于 INT、LONG、DOUBLE 等定长类型的数据列,对应的 Column 数据结构是FixedLengthColumn<Cpp_type>,其底层存储使用了 vector<Cpp_type>,即:将数据列存储在一段连续的内存,对于这种类型,我们只需将 Java SDK 读出来的数据列按顺序依次放在 Native Memory 里即可。 对于 VARCHAR 这类变长类型的数据列,对应的 Column 数据结构是 BinaryColumnBase<Cpp_type>,以 BinaryColumn 为例,以其底层存储使用了 vector<uint8_t> 和 vector<uint_32>,vector<uint8_t> 即连续的字节流用来存储数据(Bytes),vector<uint_32> 即连续的 int 序列用来存储行索引(Offsets)。对于这种类型,我们需要将 Java SDK 读出来的数据转换成字节流放在 vector<uint8_t> 对应的 Native Memory 里,将行号放在 vector<uint_32> 对应的 Native Memory 里。 vector<uint8_t> 和 vector<uint_32> 读取变长数据的方法如下图所示: 例如,获取数据列的第x行,通过 Offsets 获取该行的起始索引 start=offsets(x) 和下一行的起始索引 end=offsets(x+1),然后使用这两个索引通过 Bytes 获取具体数据对应的字节流 bytes.slice(start,end),最后按照字段类型做相应的转换即可。 注:为方便表述,上面例子做了一定的简化,对于 Nullable 数据列,实际上还有FixedLengthColumn<uint8_t> 的数据结构,对应一段连续的 Bool 数据,用来表示对应行号的列数据取值是否为 Null,用来加速 Nullable 列的计算效率,减少数据列的存储空间。 内存分配和管理设计 由于 BE 端是通过 Memory Tracker 进行内存管理的,我们在实现 JNI Reader 时,由 Memory Tracker 统一进行内存的分配和销毁,避免直接使用 JVM Unsafe 分配 Native Memory,来保证内存统计的准确性,并防止出现错误的内存使用导致的各类问题。 编程框架设计 在编程框架上,我们完整定义了 JNI Connector 的 Java 接口,让开发者不需要进行复杂的 JNI 编程以及相关的内存管理,只需要继承 ConnectorScanner 抽象类,实现 open(), getNext(), close() 这三个抽象方法,然后在 BE 侧给出具体实现类的名称,便可以完整使用我们的 JNI Connector, 读取各类 Java 数据源的数据。 混合使用 Native Reader 和 JNI Reader 设计 在 Hudi Reader 的实现里,我们有一个优化方案。上文提到,我们在 Scan range 里添加了 useJNIReader 的字段。这个字段具体取值在 FE 端通过下面逻辑控制: 判断当前表的数据类型:如果是 MOR 表,查询类型为 Snapshot,且该 File Slice 包含一个或一个以上的 Delta log,useJNIReader 便置为 True,其他情况都为 False。 这样带来的效果是,对于同一个 MOR 表的 Snapshot 读取,BE 端实际上会同时混合使用两种不同的 Reader 去拿数据,使用 Native reader 读取不包含 Delta Logs 的 File Slice,使用 JNI Reader 读取包含 Delta Logs 的 File Slice,不同的 Reader 混合使用可以进一步提升读取效率,且这些加速对于用户都是透明无感知的。 6、使用案例 1. 创建 hudi catalog CREATE EXTERNAL CATALOG "hudi_catalog" PROPERTIES ( "type" = "hudi", "hive.metastore.uris"= "thrift://xxx:9083" -- hive metastore地址 ); 2. 执行查询 select * from hudi_catalog.<db_name>.<table_name>; +------+------+---------------------+------------------------+--------------------+-----------------------+------------------------------------------------------------------------+ | id | age | _hoodie_commit_time | _hoodie_partition_path | _hoodie_record_key | _hoodie_commit_seqno | _hoodie_file_name | +------+------+---------------------+------------------------+--------------------+-----------------------+------------------------------------------------------------------------+ | 3 | 33 | 20220422111823231 | age=33 | id:3 | 20220422111823231_0_3 | 29d8d367-93f9-4bd3-aae4-47852e443e36-0_0-128-114_20220422111823231.orc | +------+------+---------------------+------------------------+--------------------+-----------------------+------------------------------------------------------------------------+ 7、性能测试 测试环境: 阿里云 EMR Presto 集群,阿里云 EMR StarRocks 集群 硬件配置: Master: ecs.g7.4xlarge 16 vCPU 64 GiB Worker: ecs.g7.4xlarge 16 vCPU 64 GiB * 5 软件配置: StarRocks 版本:master 分支(Hudi MOR 读取特性会在2.5.0正式发布) StarRocks 软件配置:默认配置 Presto 版本:0.277 Presto 软件配置: query.max-memory-per-node=40GB query.max-memory=200GB COW 表 Snapshot 查询 TPCH-100 测试结果 ​ 对于 COW 表 Snapshot 查询,使用 StarRocks 相对 Presto 大约会带来5 倍的性能提升。 MOR 表 Snapshot 查询 TPCH-100 测试结果 对于 MOR 表 Snapshot 查询,如上文描述,混合使用了 Native Reader 和 JNI Reader,因此,我们在 StarRocks 上测试读取不同比例更新数据(带有 Delta Logs 的 File Slice)的执行时间,来得到不同更新比例场景下的查询效率数据。从测试结果可以看出,更新数据比例越小,StarRocks 实际执行时使用 Native Reader 的比例也就越高,执行效率也越好。 根据社区的经验,最常见的库表更新场景里,在 Hudi 表做 Compaction 操作前,带有 Delta Logs 的 File Slice 占整个表的比例大约在 20% 左右,在这种条件下,使用 StarRocks 相对 Presto 大约会带来2-3 倍的性能提升。 对于 MOR 表 Read Optimized 查询,StarRocks 内部执行原理和流程与 COW 表相同,性能提升数据也一致。 #03 未来开发方向 — 当前 Hudi Scan Range 没有对 Base File 的大小进行 File Split 拆分,是因为考虑了上文所述可能带来的 IO 损失和 Merge 逻辑问题,但实际上,COW 表和 Read Optimized 查询 MOR 表还是依旧可以采用 File Split 逻辑的,针对这些场景还是可以进一步提高 Scan 并发度,提升执行效率。 当前 JNI Connector 还不支持复杂的数据类型如 Array、Struct、Map,接下来需要针对这些类型设计对应的 Native Memory 布局,完成这些类型的支持。 当前 JNI Connector 在 Decimal、Date、Datetime 这三种数据类型的处理上,Java 端是写入原始 String 到内存,然后在 BE 端读取并转换成 StarRocks 内部的对应类型表示,这些内存转换过程会有一定的时间损耗,后续需要进行针对性的优化,尽可能消除这个转换过程。 当前 Hudi 表的部分基础元信息,如 Table location,是通过 Hive Metastore 等外部服务获取的。但在某些应用场景里,用户可能不会将 Hudi 表的元数据同步到 Hive Metastore 里,后续需要将这类 Hudi 表支持起来,在元信息获取流程上解耦外部服务,通过 Hudi Client SDK 完成相关操作。 进一步与 Apache Hudi 社区交流和合作,推动开发 C++ 版本的 Hudi SDK。 支持对 Hudi 表数据的 Incremental Query 查询类型 。 未来,阿里云 EMR OLAP 团队与 StarRocks 社区会继续紧密合作,推出功能更加完善、简单易用、极速统一 的 OLAP 数据湖分析技术和产品。 参考资料: https://hudi.apache.org/ https://help.aliyun.com/document_detail/405463.html?spm=a2c4g.11186623.0.0.36917cc3wBuyu6 关于 StarRocks 面世两年多来,StarRocks 一直专注打造世界顶级的新一代极速全场景 MPP 数据库,帮助企业建立“极速统一”的数据分析新范式,助力企业全面数字化经营。 当前已经帮助腾讯、携程、顺丰、Airbnb 、滴滴、京东、众安保险等超过 170 家大型用户构建了全新的数据分析能力,生产环境中稳定运行的 StarRocks 服务器数目达数千台。 2021 年 9 月,StarRocks 源代码开放,在 GitHub 上的星数已超过 3500 个。StarRocks 的全球社区飞速成长,至今已有超百位贡献者,社群用户突破 7000 人,吸引几十家国内外行业头部企业参与共建。

资源下载

更多资源
Mario

Mario

马里奥是站在游戏界顶峰的超人气多面角色。马里奥靠吃蘑菇成长,特征是大鼻子、头戴帽子、身穿背带裤,还留着胡子。与他的双胞胎兄弟路易基一起,长年担任任天堂的招牌角色。

Spring

Spring

Spring框架(Spring Framework)是由Rod Johnson于2002年提出的开源Java企业级应用框架,旨在通过使用JavaBean替代传统EJB实现方式降低企业级编程开发的复杂性。该框架基于简单性、可测试性和松耦合性设计理念,提供核心容器、应用上下文、数据访问集成等模块,支持整合Hibernate、Struts等第三方框架,其适用范围不仅限于服务器端开发,绝大多数Java应用均可从中受益。

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。

Sublime Text

Sublime Text

Sublime Text具有漂亮的用户界面和强大的功能,例如代码缩略图,Python的插件,代码段等。还可自定义键绑定,菜单和工具栏。Sublime Text 的主要功能包括:拼写检查,书签,完整的 Python API , Goto 功能,即时项目切换,多选择,多窗口等等。Sublime Text 是一个跨平台的编辑器,同时支持Windows、Linux、Mac OS X等操作系统。

用户登录
用户注册