CacheSQL(三):双 HTTP 引擎与 SQL 查询——接口抽象的价值

CacheSQL(三):双 HTTP 引擎与 SQL 查询------接口抽象的价值


CacheSQL 有两种用法。一种是 Java API 内嵌调用,另一台机器上的 Python、Node、shell 脚本 HTTP 调用。

两种场景对应两套引擎:JDK 内置和 Undertow NIO。如果一开始没做好抽象,这两套引擎会写成两套重复代码。好在一开始就定义了一个接口。


一、HttpServerEngine:一个接口,两个实现

java 复制代码
public interface HttpServerEngine {
    void start(int port, int threads);
    void stop();
    void registerRoute(String path, RouteHandler handler);

    interface RouteHandler {
        void handle(Request req, Response resp) throws Exception;
    }
}

就三个方法:启动、停止、注册路由。具体的 HTTP 实现(JDK / Undertow)各写各的,业务代码只依赖接口。

一键切换:

properties 复制代码
# 开发/测试:零外部依赖
server.http.engine=jdk

# 生产:换 Undertow,吞吐量提升 50-60%
server.http.engine=undertow

切换了之后一行代码不用改。启动时 HttpCacheServer 读配置、反射加载引擎、绑定端口。

为什么保留两套引擎

JDK 引擎:com.sun.net.httpserver.HttpServer,Java 标准库自带,零依赖。适合开发环境、内嵌部署。缺点是 BIO------每连接一个线程,高并发天花板低。

Undertow 引擎:NIO 多路复用,Worker 线程和 IO 线程分离。8 线程读取 QPS 比 JDK 引擎高了 60%,写入高了 50%。代价是多一个 1.2MB 的依赖包。

两套引擎不是"一套高级一套低级",是不同场景下的最优选择。JAR 包 80KB 的内嵌调用,挂 Undertow 太浪费。HTTP 服务对外暴露,必须上 NIO。


二、为什么 JSQLParser:不是造轮子,是站在轮子上

SQL 查询有三种做法:

  1. 自己写词法分析器→ 2000 行起跳,bug 无数
  2. 用正则抠字段→ 能跑但 SELECT * FROM t WHERE name='O''Brien' 会崩
  3. 用现成的解析库→ JSQLParser

JSQLParser 把 SQL 文本解析成结构化语法树------字段列表、表名、WHERE 条件的逻辑关系------全都是结构化的对象,不用自己去抠字符串。4.9 版本稳定多年,450KB,一个依赖搞定。

这不是偷懒。这是工程上正确的事------用成熟的轮子,把精力留给有价值的部分。


三、执行计划缓存:省掉 99% 的重复解析

同一模板的 SQL 会反复出现。WHERE AAC001 = 12345WHERE AAC001 = 67890 的结构一模一样,只是值不同。

如果在缓存时保存完整的 WHERE 条件(包含值 12345),每次查询都匹配不上,缓存完全失效。解决方案是模板化 ------用正则把 SQL 中的字符串和数字替换成 ?

复制代码
原始:SELECT * FROM KCA2 WHERE AAC001 = 12345 AND AAC003 = '张三'
模板:SELECT * FROM KCA2 WHERE AAC001 = ?     AND AAC003 = ?

以"模板"作为缓存 key,同一个 key 永远命中同一个执行计划。第二次查询直接走缓存,省掉解析过程。只有第一次 SQL 模板才是真正的 JSQLParser 解析开销。

取出缓存后,把具体参数(12345张三)注入到执行计划的 PlanCondition 里,执行查询。具体参数从原始 SQL 中用正则提取:

java 复制代码
private static final Pattern VALUE_PATTERN =
    Pattern.compile("('[^']*'|\\b\\d+(\\.\\d+)?\\b)");

缓存的一个细节问题

解析后的 PlanCondition 存入缓存,多线程并发查询都需要读同一个条件和修改它的值------如果不隔离,线程 A 可能在线程 B 把值改成 67890 之后才开始用 12345 查询,但读到的已经是 67890

不是 synchronized 的问题------synchronized 保证不崩,不保证不串。解法是深拷贝

java 复制代码
// 每次查询前深拷贝 PlanCondition
PlanCondition[] copyConditions() {
    PlanCondition[] copy = new PlanCondition[conditions.length];
    for (int i = 0; i < conditions.length; i++) {
        copy[i] = new PlanCondition(
            conditions[i].op, conditions[i].column, conditions[i].value);
        copy[i].value2 = conditions[i].value2;
    }
    return copy;
}

语义上是一个新对象------值互不干扰。多线程同时执行,每个线程各自填充自身拷贝的值,不串读。

另一个细节:缓存无上限

planCache 是一个 ConcurrentHashMap,没有配置容量上限。理论上,如果 SQL 模板不断增多,缓存会无限膨胀。

目前用了简单的兜底:超过 1024 个模板后直接 planCache.clear()------粗暴但有效。CacheSQL 的使用场景是有缓的(几十条业务 SQL),模板数不会无限增长。但对比 OpLog 的环形缓冲区,这里的处理明显粗糙------承认了"当前场景够了,后面再说"。


四、索引优先级:等值优先

多条件查询中最常见的是 WHERE AAC001 = 12345 AND AAC003 = '张三'------两个条件都要满足,两个字段都有索引。

但 B+ 树一次只能选择一个索引执行------不能"先查 AAC001 的 B+ 树,又查 AAC003 的 B+ 树,然后取交集"。因为没有下标层面的集合操作 API------两个 B+ 树分别返回对象列表,在 Java 里"取交集"只能遍历,O(N)。

所以只能选一个索引。优选等值:

java 复制代码
private static PlanCondition findBestIndex(Table table, PlanCondition[] conditions) {
    PlanCondition best = null;
    int bestPriority = -1;
    for (PlanCondition c : conditions) {
        if (table.getIndex().containsKey(c.column)) {
            int pri = c.op == Op.EQ ? 10 : 5;  // 等值 = 10,范围 = 5
            if (pri > bestPriority) {
                bestPriority = pri;
                best = c;
            }
        }
    }
    return best;
}

选择最优索引后,其余条件在内存中逐行过滤------只对索引查出的候选行做。如果索引过滤效果高(大部分行被过滤掉),内存过滤就不是瓶颈。

范围查询上还有个细节优化:WHERE age >= 20 AND age <= 30------两个条件在同一列,都是范围查询。如果分别调两次 B+ 树再取交集,耗时不翻倍吗?所以检测到 >= + <= 组合时直接合并为一次 getMoreAndLessThen(lower, upper) 调用:

java 复制代码
private static PlanCondition findRangePartner(PlanCondition[] conditions, PlanCondition indexed) {
    for (PlanCondition c : conditions) {
        if (c.column.equals(indexed.column)) {
            if ((indexed.op == GE || indexed.op == GT) && (c.op == LE || c.op == LT)) return c;
            if ((indexed.op == LE || indexed.op == LT) && (c.op == GE || c.op == GT)) return c;
        }
    }
    return null;  // 没有范围搭档,单次调用一种边界方法
}

五、LIKE 前缀查询:转化为范围查询

LIKE '张%' 怎么走 B+ 树?B+ 树不支持模糊匹配。但它支持两个能力:

  1. 沿着链表从某个位置开始扫(searchmore 找到第一个 ≥ 该值的位置)
  2. 沿着链表扫到某个位置停下(再自己判断超出上界即退出)

所以 LIKE '张%' 转换为"范围查询"------下界是 ,上界是 的字典序后一位(即 起始的 Unicode 码点):

java 复制代码
private static String prefixUpperBound(String prefix) {
    char last = prefix.charAt(prefix.length() - 1);
    return prefix.substring(0, prefix.length() - 1) + (char)(last + 1);
}

"张%" → 范围 [张, 矛)。B+ 树的 searchmore 找到从哪开始,沿链表扫到第一个不在该范围内的 key 就停。不是 LIKE 真正变成了 BETWEEN------是 B+ 树的物理结构天然支持这种"扫链表到截止点"的模式,而 LIKE 'prefix%' 恰恰能用这个模式高效执行。

为什么只支持 prefix% 而不支持 %middle%?因为 %middle% 没有下界------"第一个 ≥ ''"就是所有行。没有任何过滤效果,退化成全表扫描。不是做不了,是 B+ 树做这件事没有意义。


六、总结

三个技术决策的共同点:接口抽象把复杂度隔离在单一模块里。

  • HttpServerEngine → 两套 HTTP 引擎切换不碰业务代码
  • PlanCondition 深拷贝 → 并发安全不侵入查询逻辑
  • rangePartner 合并 → 范围查询优化不干扰其他条件处理

不是哪个算法最复杂,是哪个设计让系统在后续扩展中不积累耦合。


下一篇:[CacheSQL(四):CacheSQLClient------用一张路由表实现水平扩展]


系列:CacheSQL 工程化交付实录(共 5 篇,含桥接篇)

相关推荐
lKWO OMET1 小时前
mysql之字符串函数
android·数据库·mysql
手握风云-2 小时前
Spring AI:让大模型住进 Spring 生态(三)
java·后端·spring
咸鱼2.03 小时前
【java入门到放弃】Dubbo
java·开发语言·dubbo
JAVA面经实录91710 小时前
Java企业级工程化·终极完整版背诵手册(无遗漏、全覆盖、面试+落地通用)
java·开发语言·面试
Flying pigs~~11 小时前
RAG智慧问答项目
数据库·人工智能·缓存·微调·知识库·rag
misL NITL11 小时前
mysql之如何获知版本
数据库·mysql
许彰午11 小时前
CacheSQL(二):主从复制——OpLog 环形缓冲区与故障自动恢复
java·数据库·缓存
2401_8323655212 小时前
JavaScript中rest参数(...args)取代arguments的优势
jvm·数据库·python
Bat U12 小时前
JavaEE|多线程初阶(七)
java·开发语言