MySQL Join 原理:B+树上的表连接

B+树上的表连接——彻底搞懂 Join 📌 前置知识:这篇基于前一篇 B+树索引体系的内容。默认读者已经理解聚簇索引、二级索引、回表、B+树叶子链表这几个概念。这不会是一篇"查字典"式的 SQL 语法说明,而是从 InnoDB 引擎视角解释 Join 到底在干什么。 1. Join 的本质:笛卡尔积的引擎视角 从数学上讲,Join 是两张表的 笛卡尔积 + 过滤条件: SELECT * FROM A JOIN B ON A.id = B.a_id WHERE A.age > 20; 逻辑上等价于:先穷举 A × B 的所有组合(笛卡尔积),再保留满足 A.id = B.a_id AND A.age > 20 的行。但现实中没有引擎会真去算笛卡尔积——100 万 × 100 万 = 1 万亿行,物理世界做不到。 MySQL 实际的做法是:选一张表做驱动(外层循环),另一张做被驱动(内层查找),逐行匹配。算法的核心差异在于"如何查找被驱动表中匹配的行"——这才有了 SNLJ、BNLJ、INLJ、Hash Join 四种策略。 flowchart TD DRIVER["🔁 驱动表(外层)逐行读取"] --> CHECK{"被驱动表\n有可用索引?"} CHECK -->|"有"| INLJ["Index Nested-Loop\n每行走 B+树查找"] CHECK -->|"无"| BNLJ["Block Nested-Loop\nJoin Buffer 批量匹配"] BNLJ --> HASHCHECK{"MySQL 8.0+\n且等值连接?"} HASHCHECK -->|"是"| HJ["Hash Join\n构建哈希表替代 B+树"] HASHCHECK -->|"否"| BNLJ2["仍用 BNLJ 或 SNLJ"] classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold; classDef condition fill:#2a1147,stroke:#a855f7,stroke-width:1.5px,color:#ede9fe,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb; classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold; class DRIVER startEnd class CHECK,HASHCHECK condition class INLJ,BNLJ,HJ highlight class BNLJ2 process 这四种算法,接下来逐个拆解。 ...

十二月 28, 2022 · 4 分钟 · 699 字 · yaomingye

MySQL B+树索引体系

MySQL B+树索引体系:从数据结构到查询执行 📌 前置知识:读者需了解磁盘与内存的速度差异(磁盘寻道 ~ 10ms,内存访问 ~ 100ns),以及基本的数据结构概念(链表、树、二分查找)。本文所有讨论基于 InnoDB 存储引擎。 1. 为什么是 B+树 MySQL 的数据是存在磁盘上的。磁盘 IO 的速度比内存慢约 10 万倍,所以数据库设计的第一原则是:尽量减少磁盘 IO 次数。 要理解为什么用 B+树,先看二叉搜索树(BST,Binary Search Tree)。 在 BST 中,每个节点只存一个键,每层只有两个子节点。如果数据量是 100 万行,树高就是 log₂(1000000) ≈ 20 层。执行一次查找最多需要 20 次磁盘 IO——因为每一层的节点都可能分散在不同的磁盘页上,每次读一个节点就是一次磁盘 IO。 这个代价太高了。解决的思路是:让每个节点存更多的键,增加每层的分叉数,降低树的高度。 flowchart LR root1["🌳 二叉树 ⚡20层 IO 100万数据"] --> root2["🌲 多路查找树 ⚡3 ~ 4层 IO 100万数据"] root2 --> leaf["叶子链表 范围扫描"] classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb; classDef leaf fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold; class root1,root2 startEnd class leaf leaf 从二叉树到 B+树的演进: ...

十二月 27, 2022 · 7 分钟 · 1315 字 · yaomingye

分布式事务本质——CAP、BASE 与四大方案

分布式事务本质 一、⚡ @Transactional 在生产中失效——不是代码写错了——是底层就不是一回事 先看一个场景——最经典的"下单扣库存": // 单体应用——一个 @Transactional 搞定 @Service public class OrderService { @Transactional public void createOrder(CreateOrderRequest request) { // ① 创建订单 orderMapper.insert(order); // ② 扣库存 product.setStock(product.getStock() - quantity); productMapper.updateById(product); // ③ 扣余额 account.setBalance(account.getBalance().subtract(totalAmount)); accountMapper.updateById(account); // 这三个操作在同一个数据库中——同一个事务——要么全成功——要么全回滚 } } 拆成微服务后——同样的流程——@Transactional 失效: order-service ──→ 创建订单(自己的数据库) product-service ──→ 扣库存(product 数据库) account-service ──→ 扣余额(account 数据库) 每个服务有独立的数据库——三个 @Transactional 是三个独立的事务 → 订单创建成功——库存扣减成功——但扣余额失败 → 订单已创建——库存已扣——余额没变——钱还在——但东西已经扣了 → 数据不一致——用户赚了——公司亏了 分布式事务的本质问题:多个数据库(或服务)的操作——怎么保证"要么全成功、要么全回滚"? ...

十二月 27, 2022 · 4 分钟 · 706 字 · yaomingye

DDD 本质——领域驱动设计的核心概念

搞懂 DDD 的核心概念 一、⚡ 一个下单方法 800 行——你知道拆不开是因为什么吗? 先看一段熟悉的代码——我们所有微服务的 Controller/Service 大概都长这样: @Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private UserMapper userMapper; @Autowired private ProductMapper productMapper; @Autowired private InventoryMapper inventoryMapper; public Order createOrder(CreateOrderRequest request) { // ① 查用户——有没有被封号 User user = userMapper.selectById(request.getUserId()); if (user == null || user.getStatus() == UserStatus.BANNED) { throw new BusinessException("用户不存在或已封号"); } // ② 查商品——库存够不够 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> items = new ArrayList<>(); for (CreateOrderItemRequest itemReq : request.getItems()) { Product product = productMapper.selectById(itemReq.getProductId()); if (product == null || product.getStatus() != ProductStatus.ON_SALE) { throw new BusinessException("商品 " + itemReq.getProductId() + " 不可售"); } if (product.getStock() < itemReq.getQuantity()) { throw new BusinessException("商品 " + itemReq.getProductId() + " 库存不足"); } // ③ 扣库存——直接在 Service 里 UPDATE product.setStock(product.getStock() - itemReq.getQuantity()); productMapper.updateById(product); totalAmount = totalAmount.add(product.getPrice() .multiply(BigDecimal.valueOf(itemReq.getQuantity()))); items.add(new OrderItem(itemReq.getProductId(), itemReq.getQuantity(), product.getPrice())); } // ④ 扣余额——直接操作 Account 表 Account account = accountMapper.selectByUserId(request.getUserId()); if (account.getBalance().compareTo(totalAmount) < 0) { throw new BusinessException("余额不足"); } account.setBalance(account.getBalance().subtract(totalAmount)); accountMapper.updateById(account); // ⑤ 创建订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(request.getUserId()); order.setTotalAmount(totalAmount); order.setStatus(OrderStatus.PENDING_PAY); order.setItems(items); orderMapper.insert(order); // ⑥ 发通知——MQ rocketMQTemplate.syncSend("order-created", order); return order; } } 问题不是代码长——问题是:你想加一个"首单 9 折"的功能——该加在哪? ...

十二月 21, 2022 · 12 分钟 · 2357 字 · yaomingye

Nacos 服务发现深度解析

服务发现深度解析 📖 前置阅读:本文假设读者已掌握 Nacos 的核心概念——命名空间/分组/服务/实例。如果还不熟悉,建议先阅读 Nacos 核心概念与快速上手。 一、⚡ 服务注册上去了——但为什么偶尔调不通? Nacos Dashboard 里看到服务是"健康"的——但 Feign 偶尔报 Connection Refused。排查后发现:那个实例 5 分钟前就挂了——Nacos 还没把它剔掉。 服务发现不是"注册上去了就完了"——你要理解它背后的机制:心跳怎么维护?挂了的实例多久被剔除?本地缓存的作用是什么? 二、🔄 服务注册流程全景 sequenceDiagram participant Provider as 服务提供方\nuser-service participant Nacos participant Consumer as 服务调用方\norder-service Provider->>Nacos: ① 注册:我是 user-service,地址 10.0.1.1:8081 Note over Provider,Nacos: Provider 启动时向 Nacos 注册 Provider->>Nacos: ② 心跳:我还活着(每 5s) Note over Provider,Nacos: 健康的实例定时发心跳 Consumer->>Nacos: ③ 订阅:我要找 user-service Nacos-->>Consumer: ④ 返回实例列表:[10.0.1.1:8081, 10.0.1.2:8081] Note over Nacos,Consumer: Consumer 拉一次——后续 Nacos Push 变更 Consumer->>Provider: ⑤ 直连调用——GET /api/users/1 Note over Consumer,Provider: 选一个实例——通过负载均衡 Note over Nacos: 实例不发心跳 15s → 不健康\n30s → 剔除 关键点——Nacos 不参与业务流量:服务注册/发现只在"找地址"阶段经过 Nacos。一旦 Consumer 拿到了实例列表——后续的 RPC 调用是直连 Provider——不经过 Nacos。 ...

十二月 14, 2022 · 4 分钟 · 809 字 · yaomingye

Dubbo 3.x 新特性:Triple 协议与应用级服务发现

Dubbo 3.x 新特性 📖 前置阅读:本文假设读者已掌握 Dubbo 的基本 RPC 开发、注册中心使用(Nacos)和 dubbo 协议。如果还不熟悉,建议先阅读 Dubbo 核心架构与 RPC 模型 和 注册中心:Nacos 与 Zookeeper。 一、⚡ 问题切入:dubbo 协议有什么不够用的? 第一篇说了 dubbo 协议的优点——TCP 长连接 + Hessian2 二进制序列化,性能极佳。但它的设计产生于 2011 年: dubbo 协议的局限 为什么是问题 私有二进制协议 浏览器和 curl 调不了——非 HTTP,无法穿透通用 HTTP 网关 Java 中心 协议体是 Java 特有的——Go/Node.js/Python 客户端需要单独实现协议栈 服务网格不友好 Istio/Envoy 基于 HTTP/1.1 和 HTTP/2 做流量治理——私有协议无法被 Sidecar 理解 接口级服务发现 注册中心存储的是接口粒度数据(org.example.OrderService:getOrderById)——微服务有几百个接口时,注册数据爆炸 Dubbo 3.x 的两大革新直接解决这些问题: Triple 协议——基于 HTTP/2 + Protobuf,解决私有协议问题 应用级服务发现——从接口粒度改为应用粒度,解决注册数据爆炸 二、Triple 协议 —— HTTP/2 能力 + RPC 性能 2.1 Triple 协议的本质 Triple 协议 = 将 gRPC 的传输协议(HTTP/2 + Protobuf)搬到 Dubbo 上,同时保留 Dubbo 的服务治理能力(注册中心、负载均衡、集群容错)。 ...

十一月 23, 2022 · 5 分钟 · 944 字 · yaomingye

Dubbo 注册中心:Nacos 与 Zookeeper

Dubbo 注册中心 📖 前置阅读:本文假设读者已掌握 Dubbo 的基本 RPC 开发和集群容错配置。如果还不熟悉,建议先阅读 SpringBoot Dubbo 全操作指南 和 集群容错与负载均衡。 一、⚡ 问题切入:Registry 到底是什么? 前面三篇反复提到"注册中心"——Provider 向它注册,Consumer 从它订阅。但注册中心不只是一个"存地址的地方": 注册中心的职责 具体行为 服务注册 Provider 启动时把"IP:Port + 接口名 + 元数据"写入 Registry 服务发现 Consumer 启动时从 Registry 拉取"接口名 → 地址列表"的映射 健康检查 检测 Provider 是否存活——不存活就剔除 变更推送 Provider 地址列表变化时,主动通知 Consumer 配置管理(Nacos) 动态下发配置——不需要重启应用 关键点:Registry 只在服务发现阶段起作用。Consumer 拿到 Provider 地址后——直连调用,不经过 Registry。这是和 MQ 的 Broker 最本质的区别。 二、服务注册与发现的全链路 2.1 详细流程 flowchart TD classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb; classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold; classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold; subgraph REGISTER [注册阶段] P1[Provider 启动] --> P2["向 Nacos 注册\nServiceName: order-provider\nIP: 192.168.1.10\nPort: 20880\nInterface: org.example.OrderService\nMethods: getOrderById, createOrder..."] end subgraph DISCOVER [发现阶段] C1[Consumer 启动] --> C2["向 Nacos 订阅\nInterface: org.example.OrderService"] C2 --> C3["Nacos 返回\n[192.168.1.10:20880\n 192.168.1.11:20880\n 192.168.1.12:20880]"] C3 --> C4[Consumer 存到本地缓存] C4 --> C5["选一个 Provider\n建立 TCP 长连接"] end subgraph HEARTBEAT [心跳阶段] P2 --> H1["Provider 每 5s\n向 Nacos 发心跳"] H1 --> H2{Nacos 15s\n没收到心跳?} H2 -- "是" --> H3["标记为不健康\n30s 后剔除"] H3 --> H4["推送变更通知\n给所有订阅的 Consumer"] H4 --> C6[Consumer 更新\n本地地址缓存] end class P1,C1,C5 startEnd; class P2,C2,C3,C4,C6,H1,H3,H4 process; class H2 highlight; 2.2 注册中心的存储结构 以 Nacos 为例,注册的数据长这样: ...

十一月 22, 2022 · 4 分钟 · 845 字 · yaomingye

RocketMQ 顺序消息、延迟消息与事务消息

顺序消息、延迟消息与事务消息 📖 前置阅读:本文假设读者已掌握 SpringBoot RocketMQ 的基本操作(RocketMQTemplate、@RocketMQMessageListener)。如果还不熟悉,建议先阅读 SpringBoot RocketMQ 全操作指南。 一、⚡ 问题切入:三种 RabbitMQ 做不到或做不好的事 RabbitMQ 六篇系列学完时留了几个坑——有些场景 RabbitMQ 不是不能用,而是做起来别扭: 需求 RabbitMQ 方案 痛点 订单创建→支付→发货严格按序 单队列 + 单消费者,关并发 吞吐量压到一条线;一旦重试入队顺序全乱 30 分钟后自动取消 Delayed Message 插件或 TTL+DLX 插件生产不可靠;TTL+DLX 有消息时序问题 下单 + 扣库存 + 发消息三件事原子执行 自己实现本地消息表 + 定时补偿 代码量大,维护麻烦 RocketMQ 对这三种场景都有原生支持——不是插件,不是 workaround,是设计时就考虑进去了。 二、顺序消息 —— 深度篇 2.1 上一篇回顾 + 补充 上一篇讲了基本用法:syncSendOrderly 用 orderId 哈希选 Queue,同一个 orderId 进同一个 Queue → 该 Queue 内 FIFO。消费端 consumeMode = ConsumeMode.ORDERLY。 但这只讲了正常流程。重试会破坏顺序——这是最容易踩的坑。 2.2 顺序消费的重试机制:挂起而非重入队 并发消费中,失败的消息通过 RECONSUME_LATER 进入重试 Topic,然后延迟重新投递。但顺序消费不能这么干——如果第 2 条消息失败后进了重试队列,第 3 条消息先被消费,顺序就乱了。 ...

十一月 9, 2022 · 6 分钟 · 1151 字 · yaomingye

RabbitMQ 交换机类型完全指南

四种交换机:路由机制完全解析 📖 前置阅读:本文假设读者已理解上一篇中 Exchange、Queue、Binding、RoutingKey 的概念。如果还不清楚,建议先阅读 RabbitMQ 核心概念与 AMQP 协议。 一、⚡ 问题切入:同一条消息,为什么有人收到有人收不到? 上一篇结尾发了第一条 RabbitMQ 消息——消息发出去,消费者收到了。但实际业务远比这个复杂: 订单创建后,所有下游服务(短信、邮件、风控、日志)都要收到通知 商品价格变更后,只有关注了这个商品的搜索服务需要重建索引 用户行为日志中,一部分是购买行为(需要发优惠券),一部分是浏览行为(只需要统计) 这些需求的本质是路由——同一批消息,不同消费者按不同规则接收不同子集。RabbitMQ 用 Exchange(交换机)来承担这个角色。 Exchange 有四种类型。它们唯一的不同是如何匹配 RoutingKey 和 BindingKey: flowchart TD classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold; classDef condition fill:#2a1147,stroke:#a855f7,stroke-width:1.5px,color:#ede9fe,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb; classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold; classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold; P([Producer\n消息 + RoutingKey]) --> EX{Exchange 类型?} EX -->|"Direct"| D[精确匹配\nRoutingKey == BindingKey] EX -->|"Fanout"| F[忽略 RoutingKey\n广播所有绑定队列] EX -->|"Topic"| T[通配符匹配\n* 单段 / # 多段] EX -->|"Headers"| H[消息头属性匹配\nx-match: all / any] class P startEnd; class EX condition; class D,F,T,H highlight; 去管理界面 Exchanges 页面点开一个 Exchange,看到 type 字段的值就是这四种之一。 ...

十一月 2, 2022 · 8 分钟 · 1549 字 · yaomingye

Caffeine 本地缓存核心与 SpringBoot 集成

Caffeine 核心与 SpringBoot 集成 📖 前置阅读:本文假设读者已了解 Redis 基本操作和 Spring Cache 注解(@Cacheable/@CachePut/@CacheEvict)。如果还不熟悉 Redis 系列,建议先阅读 SpringBoot Redis 全操作指南。 一、⚡ 问题切入:Redis 再快也是远程调用 回顾一下,一个典型的 Redis 缓存查询是: // Redis 缓存读 User user = (User) redisTemplate.opsForValue().get("user:1001"); if (user != null) return user; // 缓存未命中,查 MySQL user = userMapper.selectById(1001L); redisTemplate.opsForValue().set("user:1001", user, 30, TimeUnit.MINUTES); return user; Redis 延迟一般在 0.5ms ~ 2ms——相比 MySQL 的 3ms ~ 10ms 已经快很多了。但这个延迟不是免费的:每次 Redis 查询都是一次网络往返(RTT)。同机房内 RTT 大约 0.1ms,跨机房可能到 2ms 甚至更久。 在高 QPS 下,这 0.5ms × 10 万次查询 = 50 秒的累计时间,还不算序列化/反序列化的 CPU 开销。而且 Redis 不是永远不会挂——网络抖动、内存满了、主从切换,任何一个都可能让 Redis 临时不可用。 ...

十月 30, 2022 · 6 分钟 · 1073 字 · yaomingye
Cat Radio