Skip to content

Keycloak 数据库方言策略模式实战:7 种数据库分页 SQL 适配与国产化数据库集成方案

作者: 必码 | bima.cc


前言

在企业级身份与访问管理(Identity and Access Management,IAM)系统的建设过程中,数据库适配始终是绕不开的核心技术挑战。Keycloak 作为业界领先的开源 IAM 框架,凭借其完善的 SPI(Service Provider Interface)扩展机制,为开发者提供了灵活的用户存储扩展能力。然而,当企业需要将 Keycloak 对接到多种不同类型的数据库时,分页 SQL 语法差异便成为横亘在开发者面前的第一道技术门槛。

从 MySQL 的 LIMIT/OFFSET 到 Oracle 的 ROWNUM 嵌套子查询,从 SQL Server 的 ROW_NUMBER() 窗口函数到达梦、人大金仓、OceanBase、GaussDB 等国产数据库各自的分页实现,七种数据库在分页语法上的差异之大,足以让任何缺乏系统化设计的项目陷入维护困境。更为严峻的是,随着国家信创战略的深入推进,国产数据库在政府、金融、电信、能源等关键行业的渗透率持续攀升,任何面向企业级市场的 IAM 产品如果不能提供对国产数据库的原生支持,将在信创采购中处于严重劣势。

本文基于 keycloak-sandbox 项目 spi-user-storage-extension 模块的真实源码,深入剖析 DatabaseDialect 策略模式的设计与实现。文章将从数据库方言问题的本质出发,系统性地讲解策略模式在数据库适配中的应用,逐一分析七种数据库的分页 SQL 实现差异,并给出完整的国产化数据库集成方案与 HikariCP 连接池优化实践。

读者受众:

  • Keycloak SPI 扩展开发者,希望实现多数据库用户存储适配
  • 企业级 IAM 系统架构师,需要设计支持多种数据库的身份认证方案
  • 信创环境下的数据库适配工程师,面临国产数据库集成挑战
  • 对策略模式和数据库方言设计感兴趣的 Java 后端开发者

第一章 数据库方言问题的本质

1.1 SQL 方言差异的来源

SQL(Structured Query Language)作为关系型数据库的标准查询语言,由美国国家标准协会(ANSI)于 1986 年首次发布,国际标准化组织(ISO)随后于 1987 年采纳为国际标准。从 SQL-86 到 SQL:2023,SQL 标准经历了近四十年的演进,涵盖了窗口函数、公共表表达式(CTE)、JSON 支持、图形查询等丰富特性。然而,标准的广泛性并不意味着实现的统一性。

历史演进导致的碎片化

SQL 标准的每一次修订都是各方利益妥协的结果。不同的数据库厂商在标准制定过程中各有立场,导致最终标准中存在大量可选特性和多种实现路径。例如,SQL 标准允许使用 FETCH FIRST n ROWS ONLY 语法进行分页,但同时也保留了 LIMIT/OFFSET 作为替代方案,这直接导致不同厂商选择了不同的实现路径。

Oracle 数据库在长达数十年的时间里坚持使用 ROWNUM 伪列来实现分页,直到 Oracle 12c 才引入了标准化的 FETCH FIRST 语法。SQL Server 则长期依赖 TOP N 子句和 ROW_NUMBER() 窗口函数的组合。MySQL 和 PostgreSQL 选择了更为简洁的 LIMIT/OFFSET 语法。这种历史包袱使得分页成为 SQL 方言差异最为集中的领域之一。

商业竞争驱动的差异化

数据库厂商出于商业竞争的考虑,往往会有意引入一些非标准的 SQL 扩展,以形成技术壁垒和用户粘性。Oracle 的分析函数、SQL Server 的 T-SQL 扩展、MySQL 的 ON DUPLICATE KEY UPDATE 等都是典型的例子。这些扩展虽然为用户提供了更强大的功能,但也加剧了 SQL 的碎片化程度。

性能优化的必然选择

不同数据库的底层存储引擎和查询优化器架构差异巨大。Oracle 的基于成本的优化器(CBO)与 MySQL InnoDB 的查询优化器在设计理念上存在根本性差异,这种差异直接反映在最优分页策略的选择上。例如,Oracle 的 ROWNUM 分页利用了其特有的执行计划优化,而 MySQL 的 LIMIT/OFFSET 则更适合其索引扫描机制。

SQL 标准演进与方言碎片化示意:

SQL-86 (1986) ─── 基础 SELECT/FROM/WHERE

SQL-89 (1989) ─── 增加完整性约束

SQL-92 (1992) ─── JOIN、CASE、子查询 ────┬─── Oracle: 扩展 PL/SQL
    │                                      ├─── SQL Server: 扩展 T-SQL
SQL:1999 (1999) ─── 窗口函数、CTE ───────├─── MySQL: 简化实现
    │                                      └─── 国产DB: 各自演进
SQL:2003 (2003) ─── XML、序列

SQL:2008 (2008) ─── FETCH FIRST ────────┬─── Oracle: 仍用 ROWNUM
    │                                      ├─── SQL Server: 仍用 ROW_NUMBER()
SQL:2011 (2011) ─── 时态数据              ├─── MySQL: 用 LIMIT/OFFSET
    │                                      └─── 国产DB: 混合兼容
SQL:2016 (2016) ─── JSON、行模式匹配

SQL:2023 (2023) ─── 图查询、JSON表值函数

1.2 分页 SQL 的三种主流实现

在主流关系型数据库中,分页查询主要存在三种技术实现路径。理解这三种实现方式的原理和差异,是设计数据库方言抽象层的基础。

第一种:LIMIT/OFFSET 模式

这是最为简洁直观的分页方式,通过 LIMIT 指定返回行数,通过 OFFSET 指定跳过的行数。MySQL、PostgreSQL、SQLite 以及大多数国产数据库都采用这种方式。

sql
-- MySQL / PostgreSQL / 达梦 / 金仓 / OceanBase / GaussDB
SELECT username, email FROM users
WHERE username LIKE '%zhang%'
LIMIT 10 OFFSET 20;

这种方式的优点是语法简洁、易于理解和使用。但其缺点也很明显:当 OFFSET 值较大时(例如超过 10 万行),数据库仍然需要扫描并跳过前面所有的行,导致查询性能随页码增大而线性下降。在 MySQL 中,LIMIT 100000, 10 的执行计划与 LIMIT 0, 10 截然不同,前者可能需要进行全表扫描后再丢弃前 10 万行。

第二种:ROWNUM 嵌套子查询模式

Oracle 数据库在 12c 之前使用 ROWNUM 伪列来实现分页。ROWNUM 是 Oracle 在查询结果返回时动态分配的行号,从 1 开始递增。由于 ROWNUM 是在 WHERE 条件过滤之后、ORDER BY 排序之前分配的,因此必须使用嵌套子查询来确保分页的正确性。

sql
-- Oracle (11g 及之前版本)
SELECT * FROM (
    SELECT t.*, ROWNUM AS rn FROM (
        SELECT username, email FROM users
        WHERE username LIKE '%zhang%'
        ORDER BY username
    ) t WHERE ROWNUM <= 30
) WHERE rn > 20;

这种实现方式的关键在于三层嵌套结构:

  1. 最内层:执行原始查询并排序
  2. 中间层:通过 ROWNUM <= (offset + limit) 截取到目标位置
  3. 最外层:通过 rn > offset 过滤掉前面的行

需要注意的是,ROWNUM 条件不能直接使用 ROWNUM > n(其中 n > 0)来跳过行,因为 ROWNUM 是从 1 开始分配的,如果第一行不满足条件,ROWNUM 就不会递增,导致后续行永远不会被检查。这就是为什么必须使用嵌套子查询的原因。

第三种:ROW_NUMBER() 窗口函数模式

SQL Server 在 2012 版本之前使用 ROW_NUMBER() 窗口函数来实现分页。这种方式通过为结果集中的每一行分配一个连续的行号,然后通过行号范围来筛选分页数据。

sql
-- SQL Server (2008 R2 及之前版本)
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (ORDER BY username) AS row_num
    FROM users
    WHERE username LIKE '%zhang%'
) AS t
WHERE row_num > 20 AND row_num <= 30;

ROW_NUMBER() 窗口函数的优势在于它支持灵活的排序定义,可以基于任意列或表达式进行排序。但需要注意的是,OVER (ORDER BY ...) 子句中的排序列必须明确指定,否则结果顺序是不确定的。在 spi-user-storage-extension 项目的 SQLServerDialect 实现中,使用了 ORDER BY (SELECT NULL) 来避免排序依赖,这在某些场景下是合理的简化,但在生产环境中需要根据实际业务需求指定排序列。

三种分页模式对比:

┌──────────────────┬────────────────────┬─────────────────────────┬──────────────────────┐
│     特性          │   LIMIT/OFFSET     │   ROWNUM 子查询         │   ROW_NUMBER()       │
├──────────────────┼────────────────────┼─────────────────────────┼──────────────────────┤
│ 语法复杂度        │ 低(一行后缀)      │ 高(三层嵌套)           │ 中(两层嵌套)        │
│ 深度分页性能      │ 差(线性下降)       │ 中等                    │ 中等                 │
│ 排序灵活性        │ 高                  │ 中等                    │ 高                   │
│ 标准兼容性        │ 非标准但广泛支持     │ Oracle 专有             │ SQL:2003 标准        │
│ 代表数据库        │ MySQL, PG, 达梦等   │ Oracle (< 12c)          │ SQL Server           │
│ 代码可读性        │ 优秀                │ 较差                    │ 良好                 │
└──────────────────┴────────────────────┴─────────────────────────┴──────────────────────┘

1.3 国产数据库的兼容性挑战

国产数据库的崛起是近年来中国数据库市场最重要的趋势之一。在信创政策的推动下,达梦数据库(DM)、人大金仓(KingbaseES)、OceanBase、华为 GaussDB 等产品在功能完整性和性能表现上已经取得了长足进步。然而,从技术适配的角度来看,国产数据库仍然面临一些独特的兼容性挑战。

兼容性策略的分化

国产数据库在 SQL 兼容性方面采取了不同的技术路线,这直接影响了方言适配的策略选择:

  • 达梦数据库(DM):采取了高度兼容 Oracle 的策略,其 SQL 语法、PL/SQL 兼容性、存储过程语法都与 Oracle 高度相似。达梦同时支持 LIMIT/OFFSETROWNUM 两种分页方式,但在实际使用中推荐使用 LIMIT/OFFSET,因为其优化器对 LIMIT/OFFSET 的处理更为高效。

  • 人大金仓(KingbaseES):基于 PostgreSQL 内核开发,其 SQL 语法与 PostgreSQL 高度兼容。金仓支持标准的 LIMIT/OFFSET 语法,同时也提供了一些 Oracle 兼容扩展。在分页方面,金仓的 LIMIT/OFFSET 实现与 PostgreSQL 基本一致。

  • OceanBase:由蚂蚁集团开发,支持 MySQL 和 Oracle 两种兼容模式。在 MySQL 兼容模式下,OceanBase 使用 LIMIT/OFFSET 分页;在 Oracle 兼容模式下,支持 ROWNUMFETCH FIRST 语法。这种双模式设计给方言适配带来了额外的复杂度。

  • 华为 GaussDB:基于 PostgreSQL 内核开发(企业版基于自研内核),支持 LIMIT/OFFSET 语法。GaussDB 在 SQL 标准兼容性方面表现良好,同时也提供了一些 Oracle 兼容扩展包。

国产数据库兼容性路线图:

                    Oracle 兼容路线              PostgreSQL 兼容路线
                    ─────────────               ──────────────────
                    │                           │
               ┌────┴────┐                 ┌────┴────┐
               │  达梦   │                 │  金仓   │
               │  (DM)   │                 │(Kingbase)│
               └────┬────┘                 └────┬────┘
                    │                           │
               ┌────┴───────────────────────────┴────┐
               │         OceanBase                  │
               │    (MySQL模式 + Oracle模式)          │
               └────────────────┬───────────────────┘

                        ┌───────┴───────┐
                        │   GaussDB     │
                        │ (PG兼容+自研)  │
                        └───────────────┘

JDBC 驱动的获取与版本管理

国产数据库的 JDBC 驱动通常不在 Maven Central 等公共仓库中发布,这是适配工作中最常遇到的非技术性障碍。开发者需要从各厂商官网手动下载驱动 JAR 包,然后通过 mvn install:install-file 命令安装到本地 Maven 仓库,或者搭建私有 Maven 仓库进行管理。

xml
<!-- 达梦数据库驱动安装示例 -->
<!-- mvn install:install-file -Dfile=DmJdbcDriver18.jar \
     -DgroupId=com.dameng -DartifactId=DmJdbcDriver18 \
     -Dversion=8.1.2.193 -Dpackaging=jar -->
<dependency>
    <groupId>com.dameng</groupId>
    <artifactId>DmJdbcDriver18</artifactId>
    <version>8.1.2.193</version>
    <scope>provided</scope>
</dependency>

在 spi-user-storage-extension 项目的 pom.xml 中,所有数据库驱动都被注释掉并标记为 provided 作用域,这意味着驱动 JAR 包需要在运行时环境中提供。这种设计是合理的,因为 Keycloak 部署时可以将驱动放入 standalone/deployments/providers/ 目录中。

数据类型映射差异

虽然国产数据库在基本数据类型(INTEGER、VARCHAR、DATE 等)上与主流数据库保持一致,但在一些特殊类型上存在差异:

数据类型MySQLOracle达梦金仓OceanBaseGaussDB
自增主键AUTO_INCREMENTSEQUENCEIDENTITYSERIALAUTO_INCREMENTSERIAL
大文本TEXTCLOBTEXTTEXTTEXTTEXT
布尔类型TINYINT(1)NUMBER(1)BITBOOLEANTINYINT(1)BOOLEAN
时间戳DATETIMETIMESTAMPDATETIMETIMESTAMPDATETIMETIMESTAMP
UnicodeVARCHAR(utf8mb4)NVARCHAR2VARCHARVARCHARVARCHARVARCHAR

这些差异在用户存储场景中通常不会造成严重问题,但在进行跨数据库迁移或数据同步时需要特别注意。


第二章 DatabaseDialect 策略模式设计

2.1 策略模式在数据库适配中的应用

策略模式(Strategy Pattern)是 GoF 23 种设计模式中属于行为型模式的一种。其核心思想是定义一系列算法,将每一个算法封装起来,并使它们可以互相替换。策略模式让算法的变化独立于使用算法的客户。

在数据库适配场景中,策略模式的应用非常自然:每种数据库的分页 SQL 生成逻辑就是一种"算法",而使用这些算法的业务代码(如用户查询方法)就是"客户"。通过将分页逻辑封装在独立的方言类中,我们可以实现业务代码与数据库特定逻辑的完全解耦。

为什么选择策略模式而非其他模式

在设计数据库方言层时,开发者可能会考虑多种设计模式。下面对比了几种常见模式在数据库适配场景中的适用性:

  • 策略模式:将每种数据库的 SQL 生成逻辑封装为独立的策略类,通过工厂或上下文选择具体策略。优点是结构清晰、易于扩展,符合开闭原则。缺点是策略类的数量与数据库类型数量成正比。

  • 简单工厂模式:通过一个工厂类的静态方法根据数据库类型创建对应的 SQL。优点是实现简单,缺点是所有 SQL 生成逻辑集中在一个类中,违反单一职责原则。

  • 模板方法模式:在抽象基类中定义分页 SQL 生成的骨架,子类覆盖具体步骤。优点是代码复用性好,缺点是不同数据库的分页逻辑差异太大,难以提取公共模板。

  • 抽象工厂模式:为每种数据库创建一个完整的工厂,生成该数据库所需的所有 SQL 组件。优点是扩展性最强,缺点是复杂度过高,在只需要分页适配的场景中属于过度设计。

综合考虑实现复杂度和扩展需求,策略模式是数据库方言适配的最佳选择。spi-user-storage-extension 项目采用的正是策略模式与简单工厂模式的组合:方言接口定义策略契约,具体方言类实现策略逻辑,工厂类负责策略的创建和分发。

策略模式在数据库适配中的架构定位:

┌──────────────────────────────────────────────────────────────────┐
│                    Keycloak SPI Layer                            │
│                                                                  │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │           CustomUserStorageProviderFactory                  │  │
│  │  ┌─────────────┐  ┌──────────────┐  ┌──────────────────┐  │  │
│  │  │ 配置管理     │  │ 数据源管理    │  │ 方言工厂调用     │  │  │
│  │  │ dbType      │  │ HikariCP     │  │ getDialect()    │  │  │
│  │  │ connectionUrl│  │ DataSource   │  │                 │  │  │
│  │  │ userTable   │  │ Cache        │  │                 │  │  │
│  │  └─────────────┘  └──────────────┘  └──────────────────┘  │  │
│  └────────────────────────────────────────────────────────────┘  │
│                              │                                   │
│                              ▼                                   │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │           CustomUserStorageProvider                        │  │
│  │  ┌──────────────────────────────────────────────────────┐  │  │
│  │  │  searchForUserStream()                               │  │  │
│  │  │    │                                                 │  │  │
│  │  │    ├── 1. 构建基础 SQL                               │  │  │
│  │  │    ├── 2. 获取方言实例: dialect = getDialect(dbType)  │  │  │
│  │  │    ├── 3. 生成分页 SQL: dialect.getLimitOffsetSql()  │  │  │
│  │  │    └── 4. 执行查询并返回结果                          │  │  │
│  │  └──────────────────────────────────────────────────────┘  │  │
│  └────────────────────────────────────────────────────────────┘  │
│                              │                                   │
│                              ▼                                   │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │              DatabaseDialect (策略接口)                     │  │
│  │  + getDriverClassName(): String                            │  │
│  │  + getLimitOffsetSql(sql, limit, offset): String           │  │
│  └────────────────────────────────────────────────────────────┘  │
│           │          │          │          │          │          │
│     ┌─────┴───┐ ┌───┴───┐ ┌───┴───┐ ┌───┴───┐ ┌───┴───┐       │
│     │ MySQL   │ │Oracle │ │SQLSvr │ │达梦   │ │金仓   │  ...  │
│     │Dialect  │ │Dialect│ │Dialect│ │Dialect│ │Dialect│       │
│     └─────────┘ └───────┘ └───────┘ └───────┘ └───────┘       │
└──────────────────────────────────────────────────────────────────┘

2.2 DatabaseDialect 接口定义

DatabaseDialect 接口是整个方言策略体系的核心契约。在 spi-user-storage-extension 项目中,该接口被精简为两个方法,体现了"最小接口原则"的设计理念。

java
package cc.bima.keycloak.extension.storage.dialect;

/**
 * 数据库方言接口
 * 用于处理不同数据库的SQL语法差异
 *
 * @author 必码 bima.cc
 */
public interface DatabaseDialect {
    /**
     * 获取数据库驱动类名
     * @return 驱动类名
     */
    String getDriverClassName();

    /**
     * 获取带限制和偏移的SQL语句
     * @param sql 原始SQL语句
     * @param limit 限制数量
     * @param offset 偏移量
     * @return 带限制和偏移的SQL语句
     */
    String getLimitOffsetSql(String sql, int limit, int offset);
}

接口设计分析

getDriverClassName() 方法看似简单,实则承担着重要的职责。在创建 HikariCP 数据源时,必须明确指定数据库驱动类名,否则连接池无法正确加载数据库驱动。不同数据库的驱动类名差异很大:

数据库驱动类名
MySQLcom.mysql.cj.jdbc.Driver
Oracleoracle.jdbc.OracleDriver
SQL Servercom.microsoft.sqlserver.jdbc.SQLServerDriver
达梦dm.jdbc.driver.DmDriver
人大金仓com.kingbase8.Driver
OceanBasecom.oceanbase.jdbc.Driver
GaussDBcom.huawei.gaussdb.jdbc.Driver

将驱动类名封装在方言接口中,可以确保驱动类名与方言实现的一致性,避免因手动配置错误导致的问题。

getLimitOffsetSql(String sql, int limit, int offset) 方法是分页适配的核心。该方法接收三个参数:原始 SQL 语句、每页记录数(limit)和偏移量(offset),返回添加了分页语法后的 SQL 语句。这种设计将分页逻辑完全封装在方言实现中,业务代码只需调用该方法即可获得数据库特定的分页 SQL。

接口扩展性考虑

当前的 DatabaseDialect 接口只包含两个方法,这在当前需求下是足够的。但随着系统功能的扩展,可能需要添加更多的方言相关方法。以下是可能的扩展方向:

java
/**
 * 扩展版 DatabaseDialect 接口(教学示例,非项目实际代码)
 */
public interface DatabaseDialect {
    // 现有方法
    String getDriverClassName();
    String getLimitOffsetSql(String sql, int limit, int offset);

    // 可能的扩展方法
    String getCountSql(String sql);                    // 生成 COUNT 查询
    String getQuotedIdentifier(String identifier);     // 标识符引用
    String getTestConnectionSql();                     // 连接测试 SQL
    boolean supportsWindowFunctions();                 // 是否支持窗口函数
    String getCurrentTimestampSql();                   // 获取当前时间
    String getPaginationSql(String sql, int limit, int offset, String orderBy);
    // 带排序的分页
}

在实际项目中,接口的扩展应当遵循接口隔离原则(ISP),避免将过多的职责集中在一个接口中。如果方言相关的方法数量增长到一定程度,可以考虑将接口拆分为多个细粒度的接口,例如 PaginationDialectIdentifierDialectFunctionDialect 等。

2.3 DatabaseDialectFactory 工厂类

DatabaseDialectFactory 是方言策略模式的工厂组件,负责根据数据库类型创建对应的方言实例。它采用了简单工厂模式的实现方式,通过静态方法提供方言实例的创建服务。

java
package cc.bima.keycloak.extension.storage.dialect;

import cc.bima.keycloak.extension.storage.dialect.impl.*;

/**
 * 数据库方言工厂
 * 用于创建不同数据库的方言实例
 *
 * @author 必码 bima.cc
 */
public class DatabaseDialectFactory {
    /**
     * 获取数据库方言实例
     * @param dbType 数据库类型
     * @return 数据库方言实例
     */
    public static DatabaseDialect getDialect(String dbType) {
        switch (dbType) {
            case "mysql":
                return new MySQLDialect();
            case "sqlserver":
                return new SQLServerDialect();
            case "oracle":
                return new OracleDialect();
            case "dameng":
                return new DamengDialect();
            case "kingbase":
                return new KingbaseDialect();
            case "oceanbase":
                return new OceanBaseDialect();
            case "gaussdb":
                return new GaussDBDialect();
            default:
                throw new IllegalArgumentException(
                    "Unsupported database type: " + dbType);
        }
    }
}

工厂设计分析

当前实现使用 switch-case 语句进行方言分发,这是最直接也最容易理解的实现方式。对于七种数据库的规模,这种方式完全够用。但如果未来需要支持更多数据库类型,可以考虑以下优化方案:

方案一:注册表模式

通过静态注册表管理方言类型与实现类的映射关系,避免每次新增数据库都要修改工厂类。

java
/**
 * 注册表模式工厂(教学示例)
 */
public class DatabaseDialectFactory {
    private static final Map<String, Supplier<DatabaseDialect>> registry =
        new HashMap<>();

    static {
        registry.put("mysql", MySQLDialect::new);
        registry.put("sqlserver", SQLServerDialect::new);
        registry.put("oracle", OracleDialect::new);
        registry.put("dameng", DamengDialect::new);
        registry.put("kingbase", KingbaseDialect::new);
        registry.put("oceanbase", OceanBaseDialect::new);
        registry.put("gaussdb", GaussDBDialect::new);
    }

    public static void register(String dbType,
                                Supplier<DatabaseDialect> supplier) {
        registry.put(dbType, supplier);
    }

    public static DatabaseDialect getDialect(String dbType) {
        Supplier<DatabaseDialect> supplier = registry.get(dbType);
        if (supplier == null) {
            throw new IllegalArgumentException(
                "Unsupported database type: " + dbType);
        }
        return supplier.get();
    }
}

方案二:Java SPI 机制

利用 Java 的 ServiceLoader 机制实现自动发现和注册,进一步降低耦合度。

java
/**
 * 基于 SPI 的方言发现机制(教学示例)
 */
public class DatabaseDialectFactory {
    private static final Map<String, DatabaseDialect> dialects = new HashMap<>();

    static {
        ServiceLoader<DatabaseDialect> loader =
            ServiceLoader.load(DatabaseDialect.class);
        for (DatabaseDialect dialect : loader) {
            dialects.put(dialect.getType(), dialect);
        }
    }

    public static DatabaseDialect getDialect(String dbType) {
        DatabaseDialect dialect = dialects.get(dbType);
        if (dialect == null) {
            throw new IllegalArgumentException(
                "Unsupported database type: " + dbType);
        }
        return dialect;
    }
}

在上述 SPI 方案中,每个方言实现类需要实现一个额外的 getType() 方法来声明自己支持的数据库类型,并在 META-INF/services/cc.bima.keycloak.extension.storage.dialect.DatabaseDialect 文件中注册。

方案三:枚举单例模式

将方言类型定义为枚举,每个枚举值关联一个方言实例,利用枚举的天然单例特性避免重复创建。

java
/**
 * 枚举单例模式工厂(教学示例)
 */
public enum DatabaseType {
    MYSQL(new MySQLDialect()),
    SQLSERVER(new SQLServerDialect()),
    ORACLE(new OracleDialect()),
    DAMENG(new DamengDialect()),
    KINGBASE(new KingbaseDialect()),
    OCEANBASE(new OceanBaseDialect()),
    GAUSSDB(new GaussDBDialect());

    private final DatabaseDialect dialect;

    DatabaseType(DatabaseDialect dialect) {
        this.dialect = dialect;
    }

    public DatabaseDialect getDialect() {
        return dialect;
    }

    public static DatabaseType fromString(String dbType) {
        return valueOf(dbType.toUpperCase());
    }
}

三种方案的对比:

特性switch-case注册表模式Java SPI枚举单例
实现复杂度
扩展性需修改工厂注册即可自动发现需修改枚举
类型安全
运行时动态注册不支持支持支持不支持
适合当前项目可选过度设计可选

对于 spi-user-storage-extension 项目的当前规模,switch-case 方案是最务实的选择。如果未来方言类型频繁增加,可以平滑迁移到注册表模式。

2.4 配置驱动的方言选择

在 Keycloak SPI 体系中,方言的选择是通过配置驱动的。CustomUserStorageProviderFactory 在 getConfigProperties() 方法中定义了 dbType 配置项,管理员可以在 Keycloak 管理控制台中通过下拉列表选择数据库类型。

java
@Override
public List<ProviderConfigProperty> getConfigProperties() {
    return ProviderConfigurationBuilder.create()
        .property()
            .name("dbType")
            .label("Database Type")
            .helpText("Select database type")
            .type(ProviderConfigProperty.LIST_TYPE)
            .options("mysql", "sqlserver", "oracle",
                     "dameng", "kingbase", "oceanbase", "gaussdb")
            .defaultValue("mysql")
            .add()
        .property()
            .name("connectionUrl")
            .label("Connection URL")
            .helpText("Database connection URL")
            .type(ProviderConfigProperty.STRING_TYPE)
            .add()
        // ... 其他配置项
        .build();
}

配置驱动的完整流程

┌─────────────────────────────────────────────────────────────────┐
│                    配置驱动的方言选择流程                         │
│                                                                  │
│  1. 管理员在 Keycloak 控制台配置 User Federation                  │
│     ┌──────────────────────────────────────────────┐             │
│     │ Provider: bima-spi-user-storage-extension     │             │
│     │ Database Type: [mysql ▼]                     │             │
│     │ Connection URL: jdbc:mysql://localhost:3306  │             │
│     │ Username: root                               │             │
│     │ Password: ********                           │             │
│     │ User Table: users                            │             │
│     │ Username Column: username                    │             │
│     │ Password Column: password                    │             │
│     │ Email Column: email                          │             │
│     └──────────────────────────────────────────────┘             │
│                          │                                       │
│                          ▼                                       │
│  2. Keycloak 存储 ComponentModel 配置                            │
│     {                                                            │
│       "dbType": "mysql",                                        │
│       "connectionUrl": "jdbc:mysql://...",                      │
│       ...                                                        │
│     }                                                            │
│                          │                                       │
│                          ▼                                       │
│  3. CustomUserStorageProviderFactory.create() 创建 Provider       │
│                          │                                       │
│                          ▼                                       │
│  4. Provider 执行查询时调用方言工厂                               │
│     DatabaseDialect dialect =                                    │
│         DatabaseDialectFactory.getDialect("mysql");              │
│     String pagedSql = dialect.getLimitOffsetSql(sql, 10, 0);    │
│                          │                                       │
│                          ▼                                       │
│  5. 获取 MySQL 特定的分页 SQL                                    │
│     SELECT ... FROM users LIMIT 10 OFFSET 0                     │
└─────────────────────────────────────────────────────────────────┘

配置属性完整说明

CustomUserStorageProviderFactory 定义了以下配置属性:

配置项类型默认值说明
dbTypeLISTmysql数据库类型,可选值:mysql, sqlserver, oracle, dameng, kingbase, oceanbase, gaussdb
connectionUrlSTRING-数据库连接 URL
usernameSTRING-数据库用户名
passwordPASSWORD-数据库密码
userTableSTRINGusers用户表名
usernameColumnSTRINGusername用户名列名
passwordColumnSTRINGpassword密码列名
emailColumnSTRINGemail邮箱列名

这种配置化的设计使得管理员无需修改代码即可切换数据库类型,极大地提升了系统的灵活性和可运维性。在实际部署中,不同的 Keycloak Realm 可以配置不同的数据库类型,实现多数据库并行运行。


第三章 MySQL 方言实现

3.1 LIMIT/OFFSET 语法

MySQL 是全球使用最广泛的开源关系型数据库,其 LIMIT/OFFSET 分页语法以其简洁性著称。在 spi-user-storage-extension 项目中,MySQLDialect 的实现最为简洁,这直接反映了 MySQL 分页语法的简洁性。

java
package cc.bima.keycloak.extension.storage.dialect.impl;

import cc.bima.keycloak.extension.storage.dialect.DatabaseDialect;

/**
 * MySQL数据库方言实现
 * 实现 DatabaseDialect 接口,用于处理MySQL数据库的SQL语法差异。
 *
 * @author 必码 bima.cc
 */
public class MySQLDialect implements DatabaseDialect {
    @Override
    public String getDriverClassName() {
        return "com.mysql.cj.jdbc.Driver";
    }

    @Override
    public String getLimitOffsetSql(String sql, int limit, int offset) {
        return sql + " LIMIT " + limit + " OFFSET " + offset;
    }
}

分页 SQL 生成示例

假设原始 SQL 为 SELECT username, email FROM users WHERE username LIKE ? OR email LIKE ?,当 limit = 10offset = 20 时,生成的分页 SQL 为:

sql
SELECT username, email FROM users
WHERE username LIKE ? OR email LIKE ?
LIMIT 10 OFFSET 20

MySQL 分页语法的两种形式

MySQL 实际上支持两种 LIMIT 语法形式:

sql
-- 形式一:LIMIT offset, count(逗号分隔)
SELECT * FROM users LIMIT 20, 10;

-- 形式二:LIMIT count OFFSET offset(标准形式)
SELECT * FROM users LIMIT 10 OFFSET 20;

两种形式在功能上完全等价,但形式二(LIMIT count OFFSET offset)与 PostgreSQL 的语法一致,可读性也更好。spi-user-storage-extension 项目选择了形式二,这是一个正确的决定,因为它与大多数支持 LIMIT/OFFSET 的数据库保持了一致的语法风格。

MySQL 8.0 的窗口函数支持

MySQL 8.0 引入了窗口函数支持,这意味着从 MySQL 8.0 开始,开发者也可以使用 ROW_NUMBER() 来实现分页:

sql
-- MySQL 8.0+ 窗口函数分页(替代方案)
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS row_num
    FROM users
    WHERE username LIKE '%zhang%'
) AS t
WHERE row_num > 20 AND row_num <= 30;

然而,在大多数场景下,LIMIT/OFFSET 仍然是 MySQL 分页的首选方案,因为其语法更简洁,且 MySQL 优化器对 LIMIT/OFFSET 的处理经过了长期优化。

3.2 索引优化建议

MySQL 的 LIMIT/OFFSET 分页在深度分页场景下存在性能问题。当 OFFSET 值较大时,MySQL 需要扫描并跳过前面所有的行,导致查询时间随页码增大而线性增长。

问题复现

sql
-- 假设 users 表有 100 万条记录
-- 第一页:快速(使用索引定位)
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 0;
-- 执行时间:~1ms

-- 第 5 万页:缓慢(需要扫描前 50 万行)
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 500000;
-- 执行时间:~500ms

-- 第 10 万页:非常缓慢
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 1000000;
-- 执行时间:~1000ms

优化方案一:基于游标的分页(推荐)

sql
-- 使用上一页最后一条记录的 id 作为游标
SELECT * FROM users
WHERE id > 500000  -- 上一页最后一条记录的 id
ORDER BY id
LIMIT 10;

这种方案将时间复杂度从 O(offset + limit) 降低到 O(limit),无论翻到第几页,查询时间都保持恒定。但它的局限性在于:只能用于有序遍历(不能跳页),且需要有一个唯一且有序的列作为游标。

优化方案二:延迟关联

sql
-- 先通过覆盖索引获取目标行的 id,再关联获取完整数据
SELECT u.* FROM users u
INNER JOIN (
    SELECT id FROM users
    ORDER BY id
    LIMIT 10 OFFSET 500000
) AS tmp ON u.id = tmp.id;

这种方案利用了覆盖索引(Covering Index)的特性,子查询只需要扫描索引而不需要回表,大幅减少了 I/O 开销。

优化方案三:FORCE INDEX 提示

sql
-- 强制使用特定索引
SELECT * FROM users FORCE INDEX (idx_username)
WHERE username LIKE 'zhang%'
ORDER BY username
LIMIT 10 OFFSET 20;

在 Keycloak 用户搜索场景中,username 列通常建有索引,使用 FORCE INDEX 可以确保优化器选择正确的索引。

3.3 字符集与排序规则

MySQL 的字符集和排序规则配置对用户认证系统有直接影响。在 IAM 系统中,用户名和密码的比较必须遵循正确的排序规则,否则可能导致认证失败或安全漏洞。

推荐配置

sql
-- 创建数据库时指定字符集
CREATE DATABASE keycloak_users
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;

-- 创建用户表
CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(100) NOT NULL,
    password VARCHAR(255) NOT NULL,
    email VARCHAR(255),
    UNIQUE KEY idx_username (username),
    UNIQUE KEY idx_email (email)
) ENGINE=InnoDB
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

排序规则对认证的影响

sql
-- utf8mb4_unicode_ci:不区分大小写和重音
-- 'ZhangSan' = 'zhangsan' = 'ZHANGSAN'(在比较时)

-- utf8mb4_bin:区分大小写(二进制比较)
-- 'ZhangSan' != 'zhangsan' != 'ZHANGSAN'

-- 对于 IAM 系统,通常推荐使用 utf8mb4_unicode_ci
-- 以确保用户名比较不区分大小写

JDBC URL 中的字符集参数

properties
# MySQL JDBC URL 字符集配置
jdbc:mysql://localhost:3306/keycloak_users?\
  useUnicode=true&\
  characterEncoding=utf8mb4&\
  connectionCollation=utf8mb4_unicode_ci&\
  serverTimezone=Asia/Shanghai

在 CustomUserStorageProviderFactory 创建 HikariCP 数据源时,JDBC URL 中的字符集参数会直接影响连接级别的字符集设置。确保 JDBC URL 中正确配置了字符集参数,可以避免在数据读写过程中出现乱码问题。


第四章 Oracle 方言实现

4.1 ROWNUM 嵌套子查询

Oracle 数据库在 12c 版本之前不支持 LIMIT/OFFSET 语法,分页查询必须通过 ROWNUM 伪列和嵌套子查询来实现。这是 Oracle 方言实现中最为复杂的部分,也是最容易出错的环节。

java
package cc.bima.keycloak.extension.storage.dialect.impl;

import cc.bima.keycloak.extension.storage.dialect.DatabaseDialect;

/**
 * Oracle数据库方言实现
 * 实现 DatabaseDialect 接口,用于处理 Oracle 数据库的SQL语法差异。
 *
 * @author 必码 bima.cc
 */
public class OracleDialect implements DatabaseDialect {
    @Override
    public String getDriverClassName() {
        return "oracle.jdbc.OracleDriver";
    }

    @Override
    public String getLimitOffsetSql(String sql, int limit, int offset) {
        return "SELECT * FROM (SELECT t.*, ROWNUM AS rn FROM ("
            + sql
            + ") t WHERE ROWNUM <= " + (offset + limit)
            + ") WHERE rn > " + offset;
    }
}

分页 SQL 生成示例

假设原始 SQL 为 SELECT username, email FROM users WHERE username LIKE ? OR email LIKE ?,当 limit = 10offset = 20 时,生成的分页 SQL 为:

sql
SELECT * FROM (
    SELECT t.*, ROWNUM AS rn FROM (
        SELECT username, email FROM users
        WHERE username LIKE ? OR email LIKE ?
    ) t WHERE ROWNUM <= 30
) WHERE rn > 20

三层嵌套结构的执行原理

理解 Oracle ROWNUM 分页的关键在于理解其三层嵌套结构的工作原理:

执行顺序解析(由内到外):

第 1 层(最内层):原始查询
┌─────────────────────────────────────────────────────┐
│ SELECT username, email FROM users                    │
│ WHERE username LIKE ? OR email LIKE ?                │
└─────────────────────────────────────────────────────┘

    ▼  执行原始查询,返回所有匹配的行

第 2 层(中间层):ROWNUM 过滤
┌─────────────────────────────────────────────────────┐
│ SELECT t.*, ROWNUM AS rn FROM (...) t               │
│ WHERE ROWNUM <= 30                                   │
└─────────────────────────────────────────────────────┘

    │  Oracle 为结果集中的每一行分配 ROWNUM(从 1 开始)
    │  只保留 ROWNUM <= 30 的行(即前 30 行)
    │  关键优化:Oracle 的 CBO 知道只需要前 30 行,
    │  不会扫描整个结果集



第 3 层(最外层):偏移过滤
┌─────────────────────────────────────────────────────┐
│ SELECT * FROM (...) WHERE rn > 20                   │
└─────────────────────────────────────────────────────┘

    │  过滤掉 ROWNUM <= 20 的行,保留第 21-30 行
    │  最终结果:10 条记录(第 3 页,每页 10 条)

ROWNUM 的关键特性

ROWNUM 是 Oracle 的一个伪列(Pseudocolumn),具有以下关键特性:

  1. ROWNUM 在行被选中时分配:当查询结果中的一行满足 WHERE 条件时,Oracle 为其分配一个 ROWNUM 值。如果该行不满足 WHERE 条件,ROWNUM 不会递增。

  2. ROWNUM 从 1 开始:第一行满足条件的记录 ROWNUM 为 1,第二行为 2,以此类推。

  3. ROWNUM > n 永远返回空结果(当 n >= 1 时):这是因为第一行的 ROWNUM 为 1,不满足 > 1 的条件,所以 ROWNUM 不会递增到 2,第二行永远不会被检查。

sql
-- 错误写法:永远返回空结果
SELECT * FROM users WHERE ROWNUM > 10;

-- 正确写法:使用嵌套子查询
SELECT * FROM (
    SELECT t.*, ROWNUM AS rn FROM users t WHERE ROWNUM <= 20
) WHERE rn > 10;
  1. ROWNUM 的分配在 ORDER BY 之前:如果需要按特定顺序分页,必须在子查询中先排序,再应用 ROWNUM。
sql
-- 错误写法:ROWNUM 在排序前分配,结果顺序不确定
SELECT * FROM (
    SELECT t.*, ROWNUM AS rn FROM users t WHERE ROWNUM <= 10
) ORDER BY username;

-- 正确写法:先排序,再分页
SELECT * FROM (
    SELECT t.*, ROWNUM AS rn FROM (
        SELECT * FROM users ORDER BY username
    ) t WHERE ROWNUM <= 10
) WHERE rn > 0;

4.2 12c FETCH FIRST 语法

Oracle 12c 引入了标准化的分页语法,大幅简化了分页查询的编写。如果确定目标环境使用 Oracle 12c 或更高版本,可以考虑使用新的语法。

sql
-- Oracle 12c+ 标准分页语法
SELECT username, email FROM users
WHERE username LIKE '%zhang%'
ORDER BY username
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;

这种语法与 SQL:2008 标准完全一致,可读性和可维护性都远优于 ROWNUM 嵌套子查询。如果需要兼容 Oracle 11g 和 12c,可以在方言实现中根据数据库版本动态选择分页策略:

java
/**
 * 增强版 Oracle 方言(教学示例,支持版本自适应)
 */
public class EnhancedOracleDialect implements DatabaseDialect {
    private final int majorVersion;
    private final int minorVersion;

    public EnhancedOracleDialect(int majorVersion, int minorVersion) {
        this.majorVersion = majorVersion;
        this.minorVersion = minorVersion;
    }

    @Override
    public String getLimitOffsetSql(String sql, int limit, int offset) {
        if (majorVersion >= 12) {
            // Oracle 12c+ 使用标准语法
            return sql + " OFFSET " + offset
                + " ROWS FETCH NEXT " + limit + " ROWS ONLY";
        } else {
            // Oracle 11g 及之前使用 ROWNUM
            return "SELECT * FROM (SELECT t.*, ROWNUM AS rn FROM ("
                + sql + ") t WHERE ROWNUM <= " + (offset + limit)
                + ") WHERE rn > " + offset;
        }
    }
}

4.3 Oracle 特有的 SQL 陷阱

在 Oracle 数据库上进行用户存储适配时,开发者需要注意以下常见的 SQL 陷阱:

陷阱一:字符串比较的大小写敏感性

Oracle 的字符串比较默认是区分大小写的,这与 MySQL 的默认行为不同。在 IAM 系统中,用户名通常需要不区分大小写进行比较。

sql
-- Oracle 默认行为:区分大小写
SELECT * FROM users WHERE username = 'ZhangSan';
-- 不会匹配 'zhangsan'

-- 解决方案一:使用 UPPER() 函数
SELECT * FROM users WHERE UPPER(username) = UPPER('ZhangSan');
-- 注意:使用函数会导致索引失效

-- 解决方案二:创建函数索引
CREATE INDEX idx_username_upper ON users (UPPER(username));

-- 解决方案三:使用 NLSSORT 设置会话级排序规则
ALTER SESSION SET NLS_SORT=BINARY_CI;
ALTER SESSION SET NLS_COMP=LINGUISTIC;

陷阱二:空字符串与 NULL 等价

Oracle 将空字符串('')视为 NULL,这与 MySQL 和 PostgreSQL 的行为不同。在用户存储场景中,如果 email 列为空字符串,Oracle 会将其存储为 NULL。

sql
-- MySQL:'' 和 NULL 是不同的
INSERT INTO users (username, email) VALUES ('test', '');
-- email 列存储为空字符串

-- Oracle:'' 等同于 NULL
INSERT INTO users (username, email) VALUES ('test', '');
-- email 列存储为 NULL

-- 查询时的差异
-- MySQL:WHERE email = '' 可以匹配空字符串
-- Oracle:WHERE email = '' 等同于 WHERE email = NULL,永远不匹配
-- Oracle 正确写法:WHERE email IS NULL

陷阱三:表名和列名的长度限制

Oracle 的标识符长度限制为 30 个字符(Oracle 12.2 之前),这在某些自动生成的表名或列名场景中可能导致问题。

sql
-- Oracle 12.2 之前:标识符最长 30 字符
CREATE TABLE very_long_user_table_name_that_exceeds_thirty_characters (
    id NUMBER PRIMARY KEY
);
-- 报错:ORA-00972: identifier is too long

-- Oracle 12.2+:标识符最长 128 字符
CREATE TABLE very_long_user_table_name_that_exceeds_thirty_characters (
    id NUMBER PRIMARY KEY
);
-- 成功

陷阱四:日期类型的差异

Oracle 的 DATE 类型包含日期和时间信息(精确到秒),而 TIMESTAMP 类型精确到纳秒。这与 MySQL 的 DATE(仅日期)和 DATETIME(日期+时间)不同。

sql
-- Oracle DATE 类型包含时间
SELECT TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS') FROM DUAL;
-- 结果:2025-01-15 14:30:25

-- Oracle TIMESTAMP 类型更精确
SELECT TO_CHAR(SYSTIMESTAMP, 'YYYY-MM-DD HH24:MI:SS.FF6') FROM DUAL;
-- 结果:2025-01-15 14:30:25.123456

第五章 SQL Server 方言实现

5.1 ROW_NUMBER() 窗口函数

SQL Server 在 2012 版本之前不支持 OFFSET/FETCH 语法,分页查询需要通过 ROW_NUMBER() 窗口函数来实现。spi-user-storage-extension 项目采用了这种兼容性最好的方案。

java
package cc.bima.keycloak.extension.storage.dialect.impl;

import cc.bima.keycloak.extension.storage.dialect.DatabaseDialect;

/**
 * SQL Server数据库方言实现
 * 实现 DatabaseDialect 接口,用于处理 SQL Server 数据库的SQL语法差异。
 *
 * @author 必码 bima.cc
 */
public class SQLServerDialect implements DatabaseDialect {
    @Override
    public String getDriverClassName() {
        return "com.microsoft.sqlserver.jdbc.SQLServerDriver";
    }

    @Override
    public String getLimitOffsetSql(String sql, int limit, int offset) {
        return "SELECT * FROM (SELECT *, ROW_NUMBER() OVER "
            + "(ORDER BY (SELECT NULL)) AS row_num FROM ("
            + sql
            + ") AS t) AS t WHERE row_num > " + offset
            + " AND row_num <= " + (offset + limit);
    }
}

分页 SQL 生成示例

假设原始 SQL 为 SELECT username, email FROM users WHERE username LIKE ? OR email LIKE ?,当 limit = 10offset = 20 时,生成的分页 SQL 为:

sql
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS row_num
    FROM (
        SELECT username, email FROM users
        WHERE username LIKE ? OR email LIKE ?
    ) AS t
) AS t
WHERE row_num > 20 AND row_num <= 30

ROW_NUMBER() 工作原理

ROW_NUMBER() 是 SQL:2003 标准定义的窗口函数之一,它为结果集中的每一行分配一个唯一的连续整数,从 1 开始。OVER (ORDER BY ...) 子句定义了行号的分配顺序。

ROW_NUMBER() 执行过程示意:

原始数据:
┌──────────┬─────────────────┐
│ username │ email           │
├──────────┼─────────────────┤
│ zhangsan │ zhang@mail.com  │
│ lisi     │ li@mail.com     │
│ wangwu   │ wang@mail.com   │
│ zhaoliu  │ zhao@mail.com   │
└──────────┴─────────────────┘

添加 ROW_NUMBER() 后:
┌──────┬──────────┬─────────────────┐
│ rn   │ username │ email           │
├──────┼──────────┼─────────────────┤
│  1   │ zhangsan │ zhang@mail.com  │
│  2   │ lisi     │ li@mail.com     │
│  3   │ wangwu   │ wang@mail.com   │
│  4   │ zhaoliu  │ zhao@mail.com  │
└──────┴──────────┴─────────────────┘

分页过滤 (offset=1, limit=2):
┌──────┬──────────┬─────────────────┐
│ rn   │ username │ email           │
├──────┼──────────┼─────────────────┤
│  2   │ lisi     │ li@mail.com     │
│  3   │ wangwu   │ wang@mail.com   │
└──────┴──────────┴─────────────────┘

关于 ORDER BY (SELECT NULL) 的说明

在 SQLServerDialect 的实现中,ORDER BY (SELECT NULL) 是一个值得关注的细节。ORDER BY (SELECT NULL) 的作用是为 ROW_NUMBER() 提供一个占位排序条件,但实际上不指定任何确定的排序顺序。这意味着行号的分配顺序取决于 SQL Server 的内部执行计划,可能因查询优化器的选择而不同。

在生产环境中,如果分页结果需要稳定的排序,应当在 ORDER BY 子句中指定明确的排序列。例如:

sql
-- 生产环境推荐写法(带明确排序)
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (ORDER BY username) AS row_num
    FROM (
        SELECT username, email FROM users
        WHERE username LIKE ? OR email LIKE ?
    ) AS t
) AS t
WHERE row_num > 20 AND row_num <= 30;

如果需要支持动态排序,可以在方言接口中增加排序参数,或者在业务层构建包含 ORDER BY 的原始 SQL。

5.2 OFFSET FETCH 语法

SQL Server 2012 引入了 OFFSET/FETCH 语法,与 SQL:2008 标准保持一致。如果确定目标环境使用 SQL Server 2012 或更高版本,可以使用这种更简洁的语法。

sql
-- SQL Server 2012+ OFFSET FETCH 语法
SELECT username, email FROM users
WHERE username LIKE '%zhang%'
ORDER BY username
OFFSET 20 ROWS
FETCH NEXT 10 ROWS ONLY;

与 ROW_NUMBER() 方案的对比

sql
-- 方案一:ROW_NUMBER()(兼容 SQL Server 2005+)
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (ORDER BY username) AS row_num
    FROM users WHERE username LIKE '%zhang%'
) AS t WHERE row_num > 20 AND row_num <= 30;

-- 方案二:OFFSET FETCH(需要 SQL Server 2012+)
SELECT username, email FROM users
WHERE username LIKE '%zhang%'
ORDER BY username
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
特性ROW_NUMBER()OFFSET FETCH
最低版本要求SQL Server 2005SQL Server 2012
语法复杂度中(嵌套子查询)低(后缀子句)
执行计划效率良好优秀(优化器原生支持)
排序要求必须指定 ORDER BY必须指定 ORDER BY
代码可读性中等优秀

如果需要同时兼容 SQL Server 2005/2008 和 2012+,可以在方言实现中增加版本检测逻辑,类似于前面提到的增强版 Oracle 方言。

5.3 TOP N 替代方案

在 SQL Server 中,TOP N 子句是另一种限制结果行数的方式。虽然它不能直接实现 OFFSET 功能,但在某些简单场景下可以作为替代方案。

sql
-- TOP N:只返回前 N 行(不支持 OFFSET)
SELECT TOP 10 username, email FROM users
WHERE username LIKE '%zhang%'
ORDER BY username;

-- TOP N 结合 NOT IN:实现简单的分页(不推荐,性能差)
SELECT TOP 10 username, email FROM users
WHERE username LIKE '%zhang%'
AND id NOT IN (
    SELECT TOP 20 id FROM users
    WHERE username LIKE '%zhang%'
    ORDER BY id
)
ORDER BY username;

TOP N 方案的主要局限性在于不支持 OFFSET,且 NOT IN 子查询在大数据量下性能极差。在实际项目中,不推荐使用 TOP N 来实现分页,除非数据量非常小(例如少于 1000 行)且只需要获取第一页数据。

SQL Server 方言实现总结

SQL Server 分页方案演进:

SQL Server 2000 ─── TOP N(无 OFFSET 支持)

SQL Server 2005 ─── ROW_NUMBER() 窗口函数(推荐兼容方案)
    │                  ├── 优点:功能完整,支持 OFFSET
    │                  └── 缺点:语法较复杂,嵌套子查询

SQL Server 2012 ─── OFFSET FETCH(推荐现代方案)
    │                  ├── 优点:语法简洁,标准兼容
    │                  └── 缺点:需要 2012+ 版本

SQL Server 2022 ─── 增强 OFFSET FETCH
                       └── 支持更灵活的分页选项

第六章 国产数据库方言适配

6.1 达梦数据库适配

达梦数据库(DM)是国产数据库中市场份额最高的产品之一,由达梦数据库有限公司开发。达梦在 SQL 语法层面高度兼容 Oracle,同时也在 MySQL 兼容性方面做了大量工作。在分页语法上,达梦支持 LIMIT/OFFSET 语法,这使得其方言实现与 MySQL 几乎完全一致。

java
package cc.bima.keycloak.extension.storage.dialect.impl;

import cc.bima.keycloak.extension.storage.dialect.DatabaseDialect;

/**
 * 达梦数据库方言实现
 * 实现 DatabaseDialect 接口,用于处理达梦数据库的SQL语法差异。
 *
 * @author 必码 bima.cc
 */
public class DamengDialect implements DatabaseDialect {
    @Override
    public String getDriverClassName() {
        return "dm.jdbc.driver.DmDriver";
    }

    @Override
    public String getLimitOffsetSql(String sql, int limit, int offset) {
        return sql + " LIMIT " + limit + " OFFSET " + offset;
    }
}

达梦数据库连接配置

properties
# 达梦数据库 JDBC URL 格式
jdbc:dm://localhost:5236?keycloak_users

# 带完整参数的连接 URL
jdbc:dm://localhost:5236?keycloak_users&\
  charset=utf8&\
  schema=KEYCLOAK

达梦数据库的 Oracle 兼容模式

达梦数据库提供了 Oracle 兼容模式,可以通过初始化参数或会话参数进行设置:

sql
-- 设置数据库级别的兼容模式
ALTER SYSTEM SET 'COMPATIBLE_MODE' = 2;  -- 1=DM兼容, 2=Oracle兼容, 3=MySQL兼容

-- 设置会话级别的兼容模式
ALTER SESSION SET 'COMPATIBLE_MODE' = 2;

在 Oracle 兼容模式下,达梦支持 ROWNUM 伪列、FETCH FIRST 语法以及大部分 Oracle 的 PL/SQL 语法。这意味着如果企业正在从 Oracle 迁移到达梦,现有的 Oracle SQL 代码几乎不需要修改即可运行。

达梦数据库的注意事项

  1. 驱动版本兼容性:达梦的 JDBC 驱动版本需要与数据库服务器版本严格匹配。DmJdbcDriver18 适用于 DM 8.x 系列,不兼容 DM 7.x。

  2. 大小写敏感性:达梦默认将标识符转为大写存储,这与 Oracle 的行为一致。如果需要保留原始大小写,需要使用双引号引用标识符。

  3. 数据类型映射:达梦的 CLOB 类型与 Oracle 的 CLOB 类型在 JDBC 层面的处理方式略有不同,需要注意字符编码问题。

6.2 人大金仓适配

人大金仓(KingbaseES)是由北京人大金仓信息技术股份有限公司开发的国产数据库,基于 PostgreSQL 内核。金仓在 SQL 语法上与 PostgreSQL 高度兼容,支持标准的 LIMIT/OFFSET 分页语法。

java
package cc.bima.keycloak.extension.storage.dialect.impl;

import cc.bima.keycloak.extension.storage.dialect.DatabaseDialect;

/**
 * 金仓数据库方言实现
 * 实现 DatabaseDialect 接口,用于处理金仓数据库的SQL语法差异。
 *
 * @author 必码 bima.cc
 */
public class KingbaseDialect implements DatabaseDialect {
    @Override
    public String getDriverClassName() {
        return "com.kingbase8.Driver";
    }

    @Override
    public String getLimitOffsetSql(String sql, int limit, int offset) {
        return sql + " LIMIT " + limit + " OFFSET " + offset;
    }
}

人大金仓数据库连接配置

properties
# 人大金仓 JDBC URL 格式
jdbc:kingbase8://localhost:54321/keycloak_users

# 带完整参数的连接 URL
jdbc:kingbase8://localhost:54321/keycloak_users?\
  currentSchema=public&\
  stringtype=unspecified

金仓数据库的兼容性特性

人大金仓提供了三种兼容模式:

sql
-- 查看当前兼容模式
SHOW compatible_mode;

-- 设置兼容模式
-- 0: 金仓兼容模式(默认)
-- 1: Oracle 兼容模式
-- 2: MySQL 兼容模式
-- 3: SQL Server 兼容模式
SET compatible_mode = 1;

在 Oracle 兼容模式下,金仓支持 ROWNUMDUAL 表、NVL 函数等 Oracle 特有语法。在 MySQL 兼容模式下,金仓支持 LIMIT/OFFSETIFNULL 函数等 MySQL 语法。

金仓数据库的注意事项

  1. 驱动类名:金仓的 JDBC 驱动类名为 com.kingbase8.Driver,注意其中的数字 "8" 代表 KingbaseES V8 版本系列。

  2. Schema 概念:金仓沿用了 PostgreSQL 的 Schema 概念,默认 Schema 为 public。在连接 URL 中可以通过 currentSchema 参数指定默认 Schema。

  3. 序列(SEQUENCE):金仓使用 CREATE SEQUENCENEXTVAL() 函数来实现自增主键,与 PostgreSQL 一致。

sql
-- 金仓自增主键示例
CREATE SEQUENCE users_id_seq START WITH 1 INCREMENT BY 1;

CREATE TABLE users (
    id BIGINT PRIMARY KEY DEFAULT NEXTVAL('users_id_seq'),
    username VARCHAR(100) NOT NULL,
    email VARCHAR(255)
);

6.3 OceanBase 适配

OceanBase 是由蚂蚁集团开发的分布式关系型数据库,支持 MySQL 和 Oracle 两种兼容模式。在 MySQL 兼容模式下,OceanBase 使用 LIMIT/OFFSET 分页语法。

java
package cc.bima.keycloak.extension.storage.dialect.impl;

import cc.bima.keycloak.extension.storage.dialect.DatabaseDialect;

/**
 * OceanBase数据库方言实现
 * 实现 DatabaseDialect 接口,用于处理 OceanBase 数据库的SQL语法差异。
 *
 * @author 必码 bima.cc
 */
public class OceanBaseDialect implements DatabaseDialect {
    @Override
    public String getDriverClassName() {
        return "com.oceanbase.jdbc.Driver";
    }

    @Override
    public String getLimitOffsetSql(String sql, int limit, int offset) {
        return sql + " LIMIT " + limit + " OFFSET " + offset;
    }
}

OceanBase 数据库连接配置

properties
# OceanBase MySQL 模式 JDBC URL
jdbc:oceanbase://localhost:2883/keycloak_users?\
  useUnicode=true&\
  characterEncoding=utf8mb4

# OceanBase Oracle 模式 JDBC URL(使用 Oracle 驱动)
jdbc:oracle:thin:@//localhost:2883/keycloak_users

OceanBase 的双模式架构

OceanBase 的独特之处在于其双模式架构:

OceanBase 双模式架构:

┌──────────────────────────────────────────────────┐
│                  OceanBase 集群                   │
│                                                    │
│  ┌─────────────────────┐ ┌──────────────────────┐ │
│  │   MySQL 租户         │ │   Oracle 租户         │ │
│  │   (兼容模式)         │ │   (兼容模式)          │ │
│  │                      │ │                       │ │
│  │  - LIMIT/OFFSET      │ │  - ROWNUM             │ │
│  │  - AUTO_INCREMENT    │ │  - SEQUENCE           │ │
│  │  - MySQL 数据类型    │ │  - Oracle 数据类型    │ │
│  │  - MySQL 函数        │ │  - Oracle 函数        │ │
│  └─────────────────────┘ └──────────────────────┘ │
│                                                    │
│  ┌──────────────────────────────────────────────┐ │
│  │           OceanBase 存储引擎                   │ │
│  │  - 分布式事务 (Paxos 协议)                     │ │
│  │  - 自动分区                                   │ │
│  │  - 高可用 (多副本)                            │ │
│  └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘

在 MySQL 兼容模式下,OceanBase 的分页语法与 MySQL 完全一致,使用 LIMIT/OFFSET。在 Oracle 兼容模式下,OceanBase 支持 ROWNUMFETCH FIRST 语法。spi-user-storage-extension 项目中的 OceanBaseDialect 使用 LIMIT/OFFSET 语法,适用于 MySQL 兼容模式。

OceanBase 的注意事项

  1. 租户隔离:OceanBase 采用多租户架构,每个租户有独立的数据库实例。连接时需要指定租户信息。

  2. 驱动选择:MySQL 模式使用 OceanBase 自有的 JDBC 驱动(com.oceanbase.jdbc.Driver),Oracle 模式可以使用 Oracle 的 JDBC 驱动。

  3. 分布式事务:OceanBase 的分布式事务基于 Paxos 协议实现,在高并发写入场景下可能需要调整事务隔离级别。

6.4 华为高斯DB适配

华为高斯数据库(GaussDB)是华为公司开发的国产数据库产品,企业版基于自研内核,同时兼容 PostgreSQL 和 Oracle 的 SQL 语法。在分页语法上,GaussDB 支持 LIMIT/OFFSET 语法。

java
package cc.bima.keycloak.extension.storage.dialect.impl;

import cc.bima.keycloak.extension.storage.dialect.DatabaseDialect;

/**
 * 高斯数据库方言实现
 * 实现 DatabaseDialect 接口,用于处理高斯数据库的SQL语法差异。
 *
 * @author 必码 bima.cc
 */
public class GaussDBDialect implements DatabaseDialect {
    @Override
    public String getDriverClassName() {
        return "com.huawei.gaussdb.jdbc.Driver";
    }

    @Override
    public String getLimitOffsetSql(String sql, int limit, int offset) {
        return sql + " LIMIT " + limit + " OFFSET " + offset;
    }
}

GaussDB 数据库连接配置

properties
# GaussDB JDBC URL 格式
jdbc:gaussdb://localhost:8000/keycloak_users

# 带完整参数的连接 URL
jdbc:gaussdb://localhost:8000/keycloak_users?\
  currentSchema=public&\
  stringtype=unspecified&\
  sslmode=prefer

GaussDB 的兼容性特性

GaussDB 提供了丰富的兼容性选项:

sql
-- 设置兼容性参数
SET behavior_compat_options = 'enable_rownum';
-- 启用 ROWNUM 支持(Oracle 兼容)

SET behavior_compat_options = 'enable_concat_to_multi_values';
-- 启用 CONCAT 转多值支持

GaussDB 在默认配置下使用 PostgreSQL 兼容模式,支持 LIMIT/OFFSET、窗口函数、CTE 等标准 SQL 特性。通过 behavior_compat_options 参数可以开启 Oracle 兼容特性。

GaussDB 的注意事项

  1. 驱动获取:GaussDB 的 JDBC 驱动需要从华为官方渠道获取,不在 Maven Central 上发布。

  2. SSL/TLS 配置:GaussDB 默认启用 SSL 加密连接,在 JDBC URL 中需要配置 sslmode 参数。如果不需要 SSL,可以设置为 disable

  3. 连接池兼容性:GaussDB 与 HikariCP 的兼容性良好,但在高并发场景下需要注意连接超时和心跳检测的配置。

6.5 国产数据库兼容性总结

七种数据库分页 SQL 对比

下表总结了七种数据库在分页 SQL 实现上的完整对比:

数据库分页语法方言实现驱动类名兼容模式分页复杂度
MySQLLIMIT n OFFSET m直接后缀com.mysql.cj.jdbc.Driver-
OracleROWNUM 嵌套子查询三层嵌套oracle.jdbc.OracleDriver-
SQL ServerROW_NUMBER() 窗口函数两层嵌套com.microsoft.sqlserver.jdbc.SQLServerDriver-
达梦LIMIT n OFFSET m直接后缀dm.jdbc.driver.DmDriverOracle/MySQL/DM
人大金仓LIMIT n OFFSET m直接后缀com.kingbase8.DriverOracle/MySQL/PG
OceanBaseLIMIT n OFFSET m直接后缀com.oceanbase.jdbc.DriverMySQL/Oracle
GaussDBLIMIT n OFFSET m直接后缀com.huawei.gaussdb.jdbc.DriverPG/Oracle

同一查询在七种数据库上的分页 SQL 对比

假设原始查询为 SELECT username, email FROM users WHERE username LIKE '%zhang%' OR email LIKE '%zhang%',分页参数为 limit=10, offset=20

sql
-- MySQL
SELECT username, email FROM users
WHERE username LIKE '%zhang%' OR email LIKE '%zhang%'
LIMIT 10 OFFSET 20;

-- Oracle (ROWNUM)
SELECT * FROM (
    SELECT t.*, ROWNUM AS rn FROM (
        SELECT username, email FROM users
        WHERE username LIKE '%zhang%' OR email LIKE '%zhang%'
    ) t WHERE ROWNUM <= 30
) WHERE rn > 20;

-- SQL Server (ROW_NUMBER)
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS row_num FROM (
        SELECT username, email FROM users
        WHERE username LIKE '%zhang%' OR email LIKE '%zhang%'
    ) AS t
) AS t WHERE row_num > 20 AND row_num <= 30;

-- 达梦
SELECT username, email FROM users
WHERE username LIKE '%zhang%' OR email LIKE '%zhang%'
LIMIT 10 OFFSET 20;

-- 人大金仓
SELECT username, email FROM users
WHERE username LIKE '%zhang%' OR email LIKE '%zhang%'
LIMIT 10 OFFSET 20;

-- OceanBase
SELECT username, email FROM users
WHERE username LIKE '%zhang%' OR email LIKE '%zhang%'
LIMIT 10 OFFSET 20;

-- GaussDB
SELECT username, email FROM users
WHERE username LIKE '%zhang%' OR email LIKE '%zhang%'
LIMIT 10 OFFSET 20;

方言类继承关系图

                    <<interface>>
                 DatabaseDialect
                 ─────────────────
                 + getDriverClassName(): String
                 + getLimitOffsetSql(sql, limit, offset): String

                            │ implements
          ┌────────┬───────┼───────┬────────┬────────┐
          │        │       │       │        │        │
    ┌─────┴───┐┌──┴────┐┌─┴────┐┌─┴────┐┌──┴────┐┌─┴────┐┌──────┴───┐
    │ MySQL   ││Oracle ││SQLSvr││达梦  ││金仓   ││OceanB││GaussDB   │
    │Dialect  ││Dialect││Dialect││Dialect││Dialect││Dialect││Dialect  │
    ├─────────┤├───────┤├──────┤├──────┤├──────┤├──────┤├──────────┤
    │LIMIT    ││ROWNUM ││ROW   ││LIMIT ││LIMIT  ││LIMIT ││LIMIT     │
    │OFFSET   ││嵌套   ││NUMBER││OFFSET││OFFSET ││OFFSET││OFFSET    │
    │直接后缀 ││子查询 ││窗口  ││直接  ││直接   ││直接  ││直接后缀  │
    │         ││       ││函数  ││后缀  ││后缀   ││后缀  ││          │
    └─────────┘└───────┘└──────┘└──────┘└──────┘└──────┘└──────────┘

    分页策略分类:
    ┌─────────────────────────────────────────────────┐
    │  LIMIT/OFFSET 组(5种)                          │
    │  MySQL, 达梦, 金仓, OceanBase, GaussDB           │
    │  特点:语法简洁,直接在 SQL 末尾追加              │
    ├─────────────────────────────────────────────────┤
    │  ROWNUM 组(1种)                                │
    │  Oracle                                         │
    │  特点:三层嵌套子查询,语法复杂                   │
    ├─────────────────────────────────────────────────┤
    │  ROW_NUMBER() 组(1种)                           │
    │  SQL Server                                     │
    │  特点:两层嵌套,使用窗口函数                     │
    └─────────────────────────────────────────────────┘

国产数据库适配的最佳实践

基于实际项目经验,国产数据库适配需要注意以下最佳实践:

  1. 建立测试矩阵:为每种国产数据库建立独立的测试环境,编写覆盖核心 SQL 语法的测试用例集。测试用例应包括分页查询、字符串函数、日期函数、数据类型映射等关键场景。

  2. 版本锁定:国产数据库的版本迭代较快,不同版本之间可能存在不兼容的语法变更。建议在项目中锁定数据库版本,并在升级前进行充分的兼容性测试。

  3. 驱动管理:建立私有 Maven 仓库统一管理国产数据库的 JDBC 驱动,避免因驱动版本不一致导致的问题。

  4. 性能基线测试:在国产数据库上进行性能基线测试,建立查询响应时间、连接池效率等关键指标的基线数据,用于后续的性能对比和优化。

  5. 厂商技术支持:在适配过程中遇到问题时,及时联系数据库厂商的技术支持团队。国产数据库厂商通常对信创项目提供优先的技术支持。


第七章 HikariCP 连接池与多数据源管理

7.1 HikariCP 核心配置

HikariCP 是目前 Java 生态中性能最高的 JDBC 连接池,其设计目标是提供极致的性能和简洁的配置。在 spi-user-storage-extension 项目中,HikariCP 被用于管理到外部用户数据库的连接。

CustomUserStorageProviderFactory 中的 HikariCP 配置如下:

java
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);
config.setDriverClassName(dialect.getDriverClassName());
config.setMaximumPoolSize(10);      // 最大连接数
config.setMinimumIdle(5);           // 最小空闲连接数
config.setIdleTimeout(30000);       // 空闲超时:30秒
config.setMaxLifetime(1800000);     // 最大生命周期:30分钟
config.setConnectionTimeout(30000); // 连接超时:30秒

HikariDataSource ds = new HikariDataSource(config);

配置参数详解

参数说明
maximumPoolSize10连接池中允许的最大连接数。当所有连接都在使用时,新的请求将等待。
minimumIdle5连接池中保持的最小空闲连接数。低于此值时,HikariCP 会尝试创建新连接。
idleTimeout30000 (30s)空闲连接的超时时间。超过此时间的空闲连接将被关闭(前提是连接数大于 minimumIdle)。
maxLifetime1800000 (30min)连接的最大生命周期。超过此时间的连接将被关闭并替换为新连接。
connectionTimeout30000 (30s)从连接池获取连接的最大等待时间。超时后将抛出 SQLException。

HikariCP 性能优势

HikariCP 之所以被称为"最快的连接池",主要得益于以下技术优化:

  1. 字节码级优化:HikariCP 使用 Java 字节码操作(javassist)对 ConnectionStatementResultSet 等接口进行了代理优化,减少了方法调用的开销。

  2. 无锁化设计:HikariCP 使用 ConcurrentBag 替代传统的阻塞队列来管理空闲连接,大幅减少了线程争用。

  3. 自定义 FastList:HikariCP 使用自定义的 FastList 替代 ArrayList 来存储打开的 Statement,减少了数组越界检查的开销。

  4. 精简的代码量:HikariCP 的核心代码量远小于其他连接池(如 DBCP2、C3P0),更少的代码意味着更少的分支预测失败和更好的 CPU 缓存命中率。

HikariCP 架构示意:

┌─────────────────────────────────────────────────────────┐
│                    HikariDataSource                      │
│                                                          │
│  ┌────────────────────────────────────────────────────┐  │
│  │              ConcurrentBag<ProxyConnection>         │  │
│  │  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐    │  │
│  │  │Conn-1│ │Conn-2│ │Conn-3│ │Conn-4│ │Conn-5│    │  │
│  │  │(idle)│ │(idle)│ │(busy)│ │(busy)│ │(idle)│    │  │
│  │  └──────┘ └──────┘ └──────┘ └──────┘ └──────┘    │  │
│  └────────────────────────────────────────────────────┘  │
│                                                          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  │
│  │ HouseKeeper  │  │ ConcurrentBag│  │  PoolEntry   │  │
│  │ (后台线程)    │  │ (无锁连接池)  │  │ (连接包装器)  │  │
│  └──────────────┘  └──────────────┘  └──────────────┘  │
│                                                          │
│  配置参数:                                               │
│  maximumPoolSize = 10                                    │
│  minimumIdle = 5                                         │
│  idleTimeout = 30000ms                                   │
│  maxLifetime = 1800000ms                                 │
│  connectionTimeout = 30000ms                             │
└─────────────────────────────────────────────────────────┘

7.2 ConcurrentHashMap 数据源缓存

在 Keycloak SPI 体系中,一个 UserStorageProviderFactory 实例可能被多个 Realm 共享,而每个 Realm 可能配置不同的外部数据库连接。为了高效管理多个数据源,CustomUserStorageProviderFactory 使用 ConcurrentHashMap 来缓存数据源实例。

java
public class CustomUserStorageProviderFactory
        implements UserStorageProviderFactory<CustomUserStorageProvider> {

    // 共享数据源映射,key为连接URL,value为数据源
    private final Map<String, HikariDataSource> dataSourceMap =
        new ConcurrentHashMap<>();

    public HikariDataSource getDataSource(ComponentModel model) {
        String connectionUrl = model.getConfig().getFirst("connectionUrl");
        if (connectionUrl == null) {
            throw new IllegalArgumentException(
                "Missing connectionUrl configuration");
        }

        // 从缓存中获取数据源,如果不存在则创建
        return dataSourceMap.computeIfAbsent(connectionUrl, url -> {
            String dbType = model.getConfig().getFirst("dbType");
            String username = model.getConfig().getFirst("username");
            String password = model.getConfig().getFirst("password");

            // 验证必要的配置参数
            if (dbType == null || username == null || password == null) {
                throw new IllegalArgumentException(
                    "Missing required configuration parameters");
            }

            DatabaseDialect dialect = DatabaseDialectFactory.getDialect(dbType);

            HikariConfig config = new HikariConfig();
            config.setJdbcUrl(url);
            config.setUsername(username);
            config.setPassword(password);
            config.setDriverClassName(dialect.getDriverClassName());
            config.setMaximumPoolSize(10);
            config.setMinimumIdle(5);
            config.setIdleTimeout(30000);
            config.setMaxLifetime(1800000);
            config.setConnectionTimeout(30000);

            return new HikariDataSource(config);
        });
    }
}

ConcurrentHashMap 的选择理由

选择 ConcurrentHashMap 作为数据源缓存容器有以下技术考量:

  1. 线程安全:Keycloak 是一个高并发的 IAM 服务器,多个线程可能同时请求创建或获取数据源。ConcurrentHashMap 提供了高效的并发读写能力,无需额外的同步措施。

  2. 原子操作computeIfAbsent 方法保证了"检查-创建"操作的原子性,避免了重复创建数据源的问题。即使多个线程同时为同一个 connectionUrl 调用 computeIfAbsent,也只有一个线程会执行创建逻辑。

  3. 内存可见性ConcurrentHashMap 保证了 put 操作的内存可见性,确保一个线程创建的数据源对其他线程立即可见。

数据源缓存的生命周期管理

数据源生命周期管理:

┌────────────────────────────────────────────────────────────┐
│                 CustomUserStorageProviderFactory            │
│                                                              │
│  init() ──────────────────────────────────────────────────  │
│    │                                                        │
│    ▼                                                        │
│  ┌────────────────────────────────────────────────────┐    │
│  │  dataSourceMap (ConcurrentHashMap)                  │    │
│  │                                                      │    │
│  │  "jdbc:mysql://db1" ────→ HikariDataSource-1        │    │
│  │  "jdbc:oracle://db2" ───→ HikariDataSource-2        │    │
│  │  "jdbc:dm://db3" ───────→ HikariDataSource-3        │    │
│  │                                                      │    │
│  │  computeIfAbsent(): 原子性创建或获取                  │    │
│  └────────────────────────────────────────────────────┘    │
│                                                              │
│  close() ────────────────────────────────────────────────  │
│    │                                                        │
│    ├── 遍历 dataSourceMap.values()                         │
│    ├── 逐个调用 dataSource.close()                          │
│    └── dataSourceMap.clear()                                │
└────────────────────────────────────────────────────────────┘

close() 方法的实现

java
@Override
public void close() {
    // 关闭所有数据源
    for (HikariDataSource dataSource : dataSourceMap.values()) {
        try {
            dataSource.close();
            logger.info("DataSource closed successfully");
        } catch (Exception e) {
            logger.error("Error closing DataSource: {}", e.getMessage(), e);
        }
    }
    dataSourceMap.clear();
}

close() 方法在 Keycloak 服务器关闭时被调用,负责释放所有数据源资源。该方法遍历数据源映射中的所有 HikariDataSource 实例,逐个关闭,并清空映射表。注意,每个数据源的关闭操作都被独立的 try-catch 包裹,确保一个数据源关闭失败不会影响其他数据源的关闭。

7.3 连接池参数调优

HikariCP 的默认配置已经经过了大量优化,但在实际生产环境中,仍然需要根据业务场景进行参数调优。以下是基于 Keycloak 用户存储场景的调优建议。

核心参数调优公式

HikariCP 官方推荐的最大连接数计算公式为:

connections = (core_count * 2) + effective_spindle_count

其中:

  • core_count:CPU 核心数
  • effective_spindle_count:有效磁盘数(对于 SSD,通常为 1)

例如,在一个 8 核 CPU 的服务器上:

connections = (8 * 2) + 1 = 17

但这个公式适用于 CPU 密集型场景。 在 Keycloak 用户存储场景中,数据库操作通常是 I/O 密集型的(等待网络和磁盘),因此可以适当增加连接数。

场景化调优建议

场景maximumPoolSizeminimumIdleconnectionTimeout说明
开发测试5210000 (10s)减少资源占用
中小规模生产10-205-1030000 (30s)项目默认配置
大规模生产20-5010-2015000 (15s)高并发场景
信创环境10-155-830000 (30s)国产数据库网络延迟可能较大

信创环境下的特殊调优

在信创环境中,由于网络架构和硬件性能的差异,连接池参数需要进行特殊调整:

java
/**
 * 信创环境连接池配置(教学示例)
 */
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);
config.setDriverClassName(dialect.getDriverClassName());

// 信创环境调优参数
config.setMaximumPoolSize(15);          // 适当降低最大连接数
config.setMinimumIdle(5);               // 保持最小空闲连接
config.setIdleTimeout(60000);           // 增加空闲超时(60秒)
config.setMaxLifetime(1800000);         // 保持最大生命周期(30分钟)
config.setConnectionTimeout(30000);     // 增加连接超时(30秒)
config.setValidationTimeout(5000);      // 验证超时(5秒)
config.setLeakDetectionThreshold(60000);// 泄漏检测(60秒)

// 连接测试
config.setConnectionTestQuery("SELECT 1"); // 兼容性好的测试查询

// 网络优化
config.setSocketTimeout(30000);         // Socket 超时(30秒)

关键调优参数说明

  1. connectionTimeout(连接超时):从连接池获取连接的最大等待时间。在信创环境中,网络延迟可能较大,建议设置为 30 秒。如果设置为过短,可能导致在高负载时频繁抛出 SQLTransientConnectionException

  2. idleTimeout(空闲超时):空闲连接在池中的最大存活时间。设置为 60 秒可以在连接空闲时及时释放资源,同时避免频繁创建和销毁连接的开销。

  3. leakDetectionThreshold(泄漏检测阈值):当连接被借出超过此时间未归还时,HikariCP 会记录一条警告日志。设置为 60 秒可以帮助发现连接泄漏问题。

  4. maxLifetime(最大生命周期):连接的最大存活时间。设置为 30 分钟可以确保定期刷新连接,避免因数据库端关闭连接而导致的"连接已关闭"错误。

7.4 连接泄漏检测

连接泄漏是数据库应用中最常见的问题之一。当应用程序从连接池获取连接后,由于异常或编程错误未能正确归还连接,就会导致连接泄漏。如果不及时发现和处理,连接泄漏最终会耗尽连接池中的所有连接,导致新的数据库请求无法执行。

HikariCP 的泄漏检测机制

HikariCP 内置了连接泄漏检测功能,通过 leakDetectionThreshold 参数配置:

java
// 启用连接泄漏检测
config.setLeakDetectionThreshold(60000); // 60秒

当连接被借出超过 60 秒未归还时,HikariCP 会记录一条包含完整堆栈跟踪的警告日志:

Connection leak detection triggered for connection xxx,
stack trace follows
java.lang.Exception: Apparent connection leak detected
    at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:...)
    at cc.bima.keycloak.extension.storage.CustomUserStorageProvider
        .getConnection(CustomUserStorageProvider.java:428)
    at cc.bima.keycloak.extension.storage.CustomUserStorageProvider
        .getUserByUsername(CustomUserStorageProvider.java:105)
    ...

CustomUserStorageProvider 中的连接管理

spi-user-storage-extension 项目通过 Java 7 的 try-with-resources 语法来确保连接的正确释放:

java
@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
    // ...
    try (Connection connection = getConnection()) {
        String sql = "SELECT * FROM " + userTable
            + " WHERE " + usernameColumn + " = ?";
        try (PreparedStatement stmt = connection.prepareStatement(sql)) {
            stmt.setString(1, username);
            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    // 处理结果...
                }
            }
        }
    } catch (SQLException e) {
        logger.error("Error getting user by username: {}", username, e);
    }
    return null;
}

try-with-resources 的三层嵌套

代码中使用了三层嵌套的 try-with-resources,分别管理 ConnectionPreparedStatementResultSet 三个资源。这种写法确保了无论查询是否成功、是否抛出异常,所有 JDBC 资源都会被正确关闭。

资源关闭的顺序与声明的顺序相反(后声明的先关闭):

  1. 首先关闭 ResultSet
  2. 然后关闭 PreparedStatement
  3. 最后关闭 Connection(归还到连接池)

连接泄漏的常见原因和预防措施

原因描述预防措施
异常未处理catch 块中未关闭连接使用 try-with-resources
手动管理连接在 finally 块中手动关闭,逻辑错误使用 try-with-resources
长事务业务逻辑执行时间过长设置合理的 leakDetectionThreshold
线程死锁持有连接的线程被死锁使用连接池的 JMX 监控
连接未归还忘记调用 close()代码审查 + 静态分析工具

生产环境监控建议

在生产环境中,建议通过以下方式监控连接池状态:

  1. JMX 监控:HikariCP 注册了 JMX MBean,可以通过 JConsole 或 VisualVM 监控连接池的各项指标。

  2. 日志监控:配置 HikariCP 的日志级别为 DEBUG,可以输出连接获取、归还、创建、关闭等详细日志。

java
// 启用 HikariCP 详细日志
config.setMetricRegistry(yourMetricRegistry); // 可选:集成 Micrometer
  1. 健康检查:通过 Keycloak 的健康检查端点暴露连接池状态,集成到运维监控系统中。
连接池健康检查指标:

┌──────────────────────────────────────────────────────────┐
│              HikariCP 连接池监控面板                       │
│                                                           │
│  活跃连接数:    ████████░░  8/10                         │
│  空闲连接数:    ██░░░░░░░░  2/10                         │
│  等待线程数:    ░░░░░░░░░░  0                            │
│  总连接数:      ██████████  10/10                        │
│                                                           │
│  连接获取时间:  avg=2ms, p99=15ms, max=120ms             │
│  连接使用时间:  avg=50ms, p99=200ms, max=5000ms          │
│  连接创建数:    total=150, active=10, idle=0             │
│  连接关闭数:    total=140, byAge=100, byIdle=40          │
│                                                           │
│  告警规则:                                               │
│  ├── 活跃连接数 > 80% 最大连接数 → WARNING                │
│  ├── 等待线程数 > 0 → WARNING                            │
│  ├── 连接获取时间 p99 > 100ms → WARNING                  │
│  └── 连接泄漏检测触发 → CRITICAL                          │
└──────────────────────────────────────────────────────────┘

总结与展望

本文基于 keycloak-sandbox 项目 spi-user-storage-extension 模块的真实源码,系统性地讲解了 Keycloak SPI 用户存储扩展中数据库方言策略模式的设计与实现。通过对七种数据库分页 SQL 的深入分析,我们展示了策略模式在数据库适配领域的强大应用能力。

核心技术要点回顾

  1. DatabaseDialect 策略接口:通过精简的接口设计(getDriverClassName + getLimitOffsetSql),实现了数据库方言的统一抽象。这种最小接口原则的设计既满足了当前的分页需求,又为未来的扩展预留了空间。

  2. 七种数据库分页适配:MySQL、达梦、金仓、OceanBase、GaussDB 采用 LIMIT/OFFSET 直接后缀方式;Oracle 采用 ROWNUM 三层嵌套子查询方式;SQL Server 采用 ROW_NUMBER() 两层嵌套窗口函数方式。三种分页策略的差异反映了不同数据库的历史演进和设计哲学。

  3. 配置驱动的方言选择:通过 Keycloak 管理控制台的下拉列表选择数据库类型,管理员无需修改代码即可切换数据库。这种设计极大地提升了系统的灵活性和可运维性。

  4. HikariCP 连接池管理:基于 ConcurrentHashMap 的数据源缓存机制,支持多 Realm 多数据库并行运行。连接池参数经过合理配置,兼顾了性能和资源利用效率。

  5. 国产数据库信创适配:达梦、人大金仓、OceanBase、GaussDB 四种国产数据库的方言实现,覆盖了当前信创市场的主流产品。通过分析各数据库的兼容性策略和注意事项,为信创环境下的数据库适配提供了实践指导。

架构设计的价值

本文所展示的 DatabaseDialect 策略模式不仅仅是一个技术实现方案,更是一种架构思维的体现。在面对"如何让系统适配多种外部依赖"这一类问题时,策略模式提供了一种优雅的解决思路:

  • 通过接口抽象隔离变化:将变化的 SQL 语法封装在方言接口背后,使业务代码免受数据库差异的影响。
  • 通过工厂模式管理创建:将方言实例的创建逻辑集中在工厂类中,实现方言类型的统一管理。
  • 通过配置驱动实现灵活:将数据库类型的选择权交给运维人员,通过配置而非代码来控制系统行为。

这种架构思维不仅适用于数据库方言适配,也可以推广到其他需要适配多种外部依赖的场景,例如消息队列适配、缓存适配、文件存储适配等。

未来展望

随着技术的持续演进,Keycloak 数据库方言适配方案还有以下值得探索的方向:

  1. 动态方言发现:基于 Java SPI 机制实现方言的自动发现和注册,进一步降低新数据库适配的侵入性。

  2. SQL 注入防护增强:当前的实现通过 PreparedStatement 参数化查询防止 SQL 注入,但表名和列名仍然通过字符串拼接构建。未来可以考虑增加标识符白名单校验机制。

  3. 读写分离支持:在方言层增加读写分离路由能力,支持主库写入、从库读取的部署架构。

  4. 分库分表适配:对于数据量特别大的场景,在方言层集成分库分表路由逻辑,支持水平拆分的用户数据存储。

  5. 国产数据库深度适配:随着国产数据库的持续发展,方言层需要持续跟进各数据库的新版本特性,提供更深层次的适配支持。

  6. 性能基准测试体系:建立覆盖七种数据库的性能基准测试体系,为连接池参数调优和方言实现优化提供数据支撑。


版权声明: 本文为必码(bima.cc)原创技术文章,仅供学习交流。

本文内容基于实际项目源码解析整理,代码示例均为教学简化版本,仅供学习参考。

如需获取完整项目代码或技术支持,请访问 bima.cc