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 查询有三种做法:
- 自己写词法分析器→ 2000 行起跳,bug 无数
- 用正则抠字段→ 能跑但
SELECT * FROM t WHERE name='O''Brien'会崩 - 用现成的解析库→ JSQLParser
JSQLParser 把 SQL 文本解析成结构化语法树------字段列表、表名、WHERE 条件的逻辑关系------全都是结构化的对象,不用自己去抠字符串。4.9 版本稳定多年,450KB,一个依赖搞定。
这不是偷懒。这是工程上正确的事------用成熟的轮子,把精力留给有价值的部分。
三、执行计划缓存:省掉 99% 的重复解析
同一模板的 SQL 会反复出现。WHERE AAC001 = 12345 和 WHERE 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+ 树不支持模糊匹配。但它支持两个能力:
- 沿着链表从某个位置开始扫(
searchmore找到第一个 ≥ 该值的位置) - 沿着链表扫到某个位置停下(再自己判断超出上界即退出)
所以 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 篇,含桥接篇)