PostgreSQL JSONB 在 Spring Boot 中的读写边界:从 JDBC 类型映射到 GIN 索引失效

PostgreSQL JSONB 在 Spring Boot 中的读写边界:从 JDBC 类型映射到 GIN 索引失效

0. 从一个线上现象说起

先看一个实际场景。某订单系统把"商品扩展属性"塞进了一张 orders 表的 extras jsonb 字段,理由是"字段随时会变,不想天天加列"。开发同学在 Java 里用 MyBatis/JdbcTemplate 直接拼字符串写入,查询时写了一个看起来没问题的 SQL:

sql 复制代码
SELECT id, extras FROM orders WHERE extras @> '{"channel": "app"}';

本地几万行数据跑得飞快,上线三个月后表涨到 800 万行,接口 P99 从 30ms 涨到 2.4s。EXPLAIN 一看,走的是 Seq Scan,明明建了 GIN 索引却完全没用上。

更棘手的是另一类问题:写入偶尔抛 column "extras" is of type jsonb but expression is of type character varying;并发更新时两个线程都读到了旧值,后写的把先写的覆盖掉。

这三个现象看似无关,其实都落在同一条链路上:Java 类型 → PgJDBC 绑定参数 → PostgreSQL 类型系统 → 表达式与操作符 → 索引匹配 → 事务与锁。任何一环对不上,就会在表现层炸成"索引失效"或"更新丢失"。

本文的目标不是让你记住某个 API,而是先建立这条链路的整体框架,再逐段拆开看边界在哪里。

1. 一句话模型与全局链路

可以先把它理解成一句话:JSONB 列是一段带类型标记的二进制文档,Java 侧必须带着"我是 jsonb"这个身份信息穿过 PgJDBC,PostgreSQL 才会用 jsonb 的操作符和索引去处理它。

把整体拆成三部分:

  1. Java 侧:你的对象(POJO / Map / String)以及它被序列化和绑定到 PreparedStatement 的方式。
  2. 驱动侧(PgJDBC) :决定用什么 Oid(类型标识)和什么 setXXX 方法把值发给服务端,并接收返回值。
  3. PostgreSQL 侧:类型系统、操作符、表达式求值、规划器选择索引、执行器加锁读写。

一次典型的读写请求会按下面顺序流转:

text 复制代码
[Java 调用 Mapper/Repository]
        |
        v
[构建 POJO / Map / JSON 字符串]
        |
        v
[Spring 事务开启 -> 获取 Connection]
        |
        v
[PgJDBC PreparedStatement 绑定参数]
   setObject(i, value, Types.OTHER) 或 setString / PGobject
        |
        v
[发送 Parse/Bind/Execute 到 PostgreSQL]
        |
        v
[PostgreSQL 类型解析: 得到 jsonb 值]
        |
        v
[表达式求值: @> / -> / jsonb_path_query]
        |
        v
[规划器匹配操作符族与 GIN 索引]
        |
        v
[执行器: 读快照 / 行锁 / 返回结果集]
        |
        v
[PgJDBC 读取结果 -> Java 类型还原]

这张图后面每一章都会用到:先确定值是什么类型,再确定用什么操作符,最后才谈索引能不能用。

2. Spring Boot 中 JSONB 的四种写入姿势及其代价

2.1 先看差异:字符串直拼 vs 参数绑定

最省事的写法是字符串拼接:

java 复制代码
String sql = "INSERT INTO orders(id, extras) VALUES (" + id + ", '" + json + "')";
jdbcTemplate.execute(sql);

看起来能跑,但问题有三层:一是引号和反斜杠没有转义,JSON 里一旦含单引号就语法错误;二是无法复用执行计划,每次 SQL 文本都不同;三是容易被注入。这条路径在工程上应当直接排除。

正确做法是使用参数绑定。但"绑定参数"本身也有多种写法,差别在于 PgJDBC 能否推断出类型是 jsonb。

写法 关键调用 服务端收到的类型 是否推荐 常见问题
字符串参数 setString(1, json) 大概率是 varchar 否 报类型不匹配错误
显式 OTHER setObject(1, json, Types.OTHER) 可被推断为 jsonb 是 需确保值是合法 JSON
PGobject new PGobject(); setType("jsonb") 明确 jsonb 是 代码稍啰嗦
自定义 TypeHandler MyBatis 中封装上述逻辑 明确 jsonb 是(团队级) 需统一约定

2.2 为什么 setString 会报错

PostgreSQL 是强类型系统。当 SQL 里写 INSERT INTO orders(extras) VALUES (?),驱动发送的参数如果被标记为 varchar,服务端在准备语句阶段就需要一个 varchar → jsonb 的隐式转换。这个转换并不存在(jsonb 只接受 text/json 到 jsonb 的显式转换路径,而且是否成功还取决于上下文),于是报出:

text 复制代码
ERROR: column "extras" is of type jsonb but expression is of type character varying
HINT: You will need to rewrite or cast the expression.

解决方案有两种:SQL 里写 ?::jsonb 显式转换,或者在 Java 侧把参数标记为 Types.OTHER。前者要求每个写入点都记得写转换,后者更集中。

这里最容易误解的是:"JSON 字符串能插进去"不等于"类型正确"。 有时候你本地碰巧成功,是因为 SQL 文本里拼了一个字面量 '{"a":1}',PostgreSQL 会把它当未知类型再推断成 jsonb;而换成绑定参数后推断链断了,就报错。

2.3 读取方向同样有边界

读出时,PgJDBC 会把 jsonb 的二进制值还原成字符串(因为 jsonb 没有对应的标准 JDBC 类型)。你用 getString("extras") 拿到的是规范化后的文本,比如键顺序被重排、空白被去掉。如果业务代码假设"我写进去什么,读出来就是什么",就会踩坑。

3. PgJDBC 绑定参数:类型 Oid 从哪里来

3.1 绑定链路拆解

PreparedStatement 绑定参数到服务端,实际经历的是扩展查询协议的三步:Parse(带参数类型 Oid)、Bind(带参数值)、Execute。关键在 Parse 阶段,驱动要告诉服务端每个占位符的类型。

PgJDBC 的类型推断规则大致是:

  1. 如果调用 setObject(i, value, Types.OTHER),驱动会使用未知类型或让服务端推断。
  2. 如果调用 setString,类型是 varchar。
  3. 如果传入 PGobject,类型就是 PGobject.getType() 返回的字符串(如 jsonb)。
  4. 如果传入 java.util.UUID、LocalDateTime 等,驱动会映射到对应 Oid。

所以真正决定成败的不是"你有没有用 PreparedStatement",而是你有没有让驱动把类型标成 jsonb。

3.2 一个最小的可运行写入示例

目标:用三种写法分别写入同一个 JSON,观察哪种成功、哪种报错。

前置环境 :PostgreSQL 14+,本地可连;Maven 项目引入 org.postgresql:postgresql(建议 42.6 以上)。

sql 复制代码
CREATE TABLE orders (
    id bigserial PRIMARY KEY,
    channel text NOT NULL,
    extras jsonb NOT NULL DEFAULT '{}'::jsonb,
    created_at timestamptz NOT NULL DEFAULT now()
);
java 复制代码
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Types;
import org.postgresql.util.PGobject;

public class JsonbWriteDemo {
    private static final String URL = "jdbc:postgresql://localhost:5432/demo";

    public static void main(String[] args) throws Exception {
        String json = "{\"coupon\":\"NEW2024\",\"amount\":12.5}";
        try (Connection conn = DriverManager.getConnection(URL, "demo", "demo")) {
            writeWithSetString(conn, json);       // 预期失败
            writeWithTypesOther(conn, json);      // 预期成功
            writeWithPGobject(conn, json);        // 预期成功
        }
    }

    static void writeWithSetString(Connection conn, String json) {
        String sql = "INSERT INTO orders(channel, extras) VALUES (?, ?)";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, "app");
            ps.setString(2, json);
            ps.executeUpdate();
            System.out.println("setString: OK");
        } catch (SQLException e) {
            System.out.println("setString: FAIL -> " + e.getMessage());
        }
    }

    static void writeWithTypesOther(Connection conn, String json) throws SQLException {
        String sql = "INSERT INTO orders(channel, extras) VALUES (?, ?)";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, "app");
            ps.setObject(2, json, Types.OTHER);
            ps.executeUpdate();
            System.out.println("setObject OTHER: OK");
        }
    }

    static void writeWithPGobject(Connection conn, String json) throws SQLException {
        PGobject pg = new PGobject();
        pg.setType("jsonb");
        pg.setValue(json);
        String sql = "INSERT INTO orders(channel, extras) VALUES (?, ?)";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, "web");
            ps.setObject(2, pg);
            ps.executeUpdate();
            System.out.println("PGobject: OK");
        }
    }
}

预期输出:第一行打印失败并带类型不匹配信息,后两行打印 OK。

关键点 :setObject(i, value, Types.OTHER) 之所以能成功,是因为它让服务端在 Parse 阶段把参数类型留空,PostgreSQL 再根据目标列 jsonb 反推类型。这是最省事的写法,但代价是每次执行都可能带来一次额外的类型推断;如果同一条 SQL 被反复调用且类型推断结果稳定,影响可忽略。

容易改错的地方 :Types.OTHER 传的对象如果不是合法 JSON 文本,会在服务端解析时抛 invalid input syntax for type json。另外注意,channel 列写的是 "app",被当成 varchar 是正确的,不需要特殊处理。

4. GIN 索引:为什么建了却不生效

4.1 GIN 索引能加速什么

JSONB 的查询大多不是"等值",而是"包含""路径存在""键存在"。这类查询不能靠 B-Tree 的排序特性加速。GIN(Generalized Inverted Index,通用倒排索引)的做法是把文档拆成一组键(键、键值对、路径等),为每个键记录包含它的行。查询时先定位键,再取行集合做交集。

常用的操作符类有三个:

操作符类 支持的查询 适用场景
jsonb_ops(默认) @>、?、`? 、?&`
jsonb_path_ops 仅 @> 只需包含查询,索引更小更快
jsonb_ops + 表达式索引 特定路径 只查固定路径,可大幅减小索引体积

4.2 创建索引的两种写法

sql 复制代码
-- 默认操作符类,支持键存在查询
CREATE INDEX idx_orders_extras_gin ON orders USING gin (extras);

-- 只做包含查询,索引更小
CREATE INDEX idx_orders_extras_pathops ON orders USING gin (extras jsonb_path_ops);

注意:jsonb_path_ops 索引不支持 ?、?|、?& 这三个"键存在"操作符。如果你既想查 extras ? 'coupon',又想用 path_ops 索引,索引是匹配不上的。

4.3 索引失效的三种典型原因

第一种是操作符不匹配 。比如使用了 ->> 取值再和字符串比较:

sql 复制代码
-- 不会用 GIN 索引
SELECT * FROM orders WHERE extras ->> 'channel' = 'app';

-- 会用 GIN 索引
SELECT * FROM orders WHERE extras @> '{"channel": "app"}';

->> 返回的是 text,GIN 索引建立在 jsonb 上,操作符族对不上,规划器只能顺序扫描。

第二种是表达式与索引定义类型不一致 。比如你为 extras -> 'channel' 建了表达式索引,但查询时写 extras ->> 'channel',一个返回 jsonb 一个返回 text,同样匹配不上。

第三种是统计信息与代价估算 。即使操作符匹配,如果规划器估算匹配行数过多(例如 @> '{}' 几乎匹配所有行),它会认为顺序扫描更便宜而放弃索引。这类情况要靠 ANALYZE、调整统计目标或改写查询解决。

4.4 用执行计划验证索引是否命中

目标 :构造 5 万行数据,对比 ->> 与 @> 两种写法的执行计划。

前置环境:承接第 3 节的表结构。

sql 复制代码
-- 造数据
INSERT INTO orders(channel, extras)
SELECT
    (ARRAY['app','web','mini'])[1 + (i % 3)],
    jsonb_build_object('channel', (ARRAY['app','web','mini'])[1 + (i % 3)],
                       'amount', i % 1000,
                       'coupon', CASE WHEN i % 7 = 0 THEN 'NEW2024' ELSE NULL END)
FROM generate_series(1, 50000) AS i;

CREATE INDEX idx_orders_extras_gin ON orders USING gin (extras);
ANALYZE orders;

-- 写法 A:->> 比较,预期顺序扫描
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM orders WHERE extras ->> 'channel' = 'app';

-- 写法 B:@> 包含,预期 Bitmap Index Scan
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM orders WHERE extras @> '{"channel": "app"}';

预期结果 :写法 A 出现 Seq Scan on orders,耗时随行数线性增长;写法 B 出现 Bitmap Index Scan on idx_orders_extras_gin 加 Bitmap Heap Scan,扫描行数大幅下降。

适用场景 :任何"在 JSON 里按某个字段筛选"的查询,优先改写成 @> 形式。

边界 :@> 匹配的是文档包含关系,'{"channel":"app"}' 要求被查询文档恰好含这个键值对。如果 JSON 里嵌套层级很深,写法要相应嵌套;如果只是想判断键是否存在,用 ? 操作符,并确认索引是 jsonb_ops。

5. jsonb_path_query:更灵活但也更容易失效的路径查询

5.1 它解决什么问题

当你要查的条件是"数组里任意一个元素的某个字段大于某值"时,@> 就不够用了,因为它表达的是固定文档包含。jsonb_path_query 使用 SQL/JSON 路径表达式,可以按路径遍历并返回匹配的子文档。

sql 复制代码
SELECT id, jsonb_path_query(extras, '$.items[*] ? (@.qty > 5)') AS hit
FROM orders
WHERE jsonb_path_exists(extras, '$.items[*] ? (@.qty > 5)');

5.2 为什么它通常用不上 GIN 索引

GIN 的 jsonb_ops 索引为每个键和键值对建立了倒排项,但它无法直接回答"某个嵌套路径上存在满足不等式条件的元素"这种问题。jsonb_path_exists 是函数调用,规划器把它当成一个黑盒表达式来求值,只有在你为这个具体表达式建了表达式索引时才可能用到索引。

这意味着一个常见误区:以为建了 GIN 索引,所有 JSON 查询都会快。 实际上 GIN 只对 @>、?、?|、?& 这类操作符有直接支持。

5.3 三种可行的优化路线

路线 做法 优点 缺点
表达式索引 为固定路径建 (extras -> 'channel') 索引 命中率高 只能覆盖固定查询
冗余列 把热字段抽成独立列并建普通索引 计划稳定、易维护 违背"无模式"初衷
预计算键 用 jsonb_path_ops 或生成列 兼顾灵活与性能 需要写迁移脚本

工程上最稳的做法是"3 + 1":把 3 个以内最高频的查询字段抽成独立列,剩下的继续用 GIN 加速 @> 查询,路径查询只用于低频分析场景。

6. 含更新锁的并发读写:JSONB 不是并发安全的挡箭牌

6.1 一个读改写场景

假设有两个线程同时处理同一订单,逻辑是"读出 extras,加上一个优惠标签,写回":

text 复制代码
线程 A: SELECT extras FROM orders WHERE id = 1;   -- {"channel":"app"}
线程 B: SELECT extras FROM orders WHERE id = 1;   -- {"channel":"app"}
线程 A: UPDATE orders SET extras = '{"channel":"app","tagA":true}' WHERE id = 1;
线程 B: UPDATE orders SET extras = '{"channel":"app","tagB":true}' WHERE id = 1;

结果 tagA 被覆盖丢失。这不是 JSONB 的缺陷,而是"读-改-写"没有加锁的经典并发问题,只是 JSONB 让它更隐蔽,因为字段"看起来"是独立可加的。

6.2 两种 PostgreSQL 原生解法

解法一:行级悲观锁。 在事务里用 SELECT ... FOR UPDATE 锁住目标行,再做后续更新。

sql 复制代码
BEGIN;
SELECT extras FROM orders WHERE id = 1 FOR UPDATE;
-- 应用层合并新字段
UPDATE orders SET extras = $1::jsonb WHERE id = 1;
COMMIT;

解法二:服务端原子合并。 直接用 || 操作符合并 JSONB,避免读改写:

sql 复制代码
UPDATE orders SET extras = extras || '{"tagA": true}'::jsonb WHERE id = 1;

这个写法的更新完全发生在服务端,单条语句即原子,不需要先读后写。注意 || 是浅合并:同键覆盖,不递归合并嵌套对象。

6.3 并发时间线对比

text 复制代码
方案一(FOR UPDATE):
  A: BEGIN -> SELECT FOR UPDATE (持锁)
  B: BEGIN -> SELECT FOR UPDATE (阻塞等待)
  A: UPDATE -> COMMIT (释放锁)
  B: 获得锁 -> 读到 A 提交后的新值 -> UPDATE -> COMMIT
  结果: 两个 tag 都在

方案二(|| 原子合并,无显式锁):
  A: UPDATE extras = extras || tagA
  B: UPDATE extras = extras || tagB
  两行更新在同一行上加行锁,串行执行,各自基于最新值
  结果: 两个 tag 都在

6.4 一个含锁的读写完整示例

目标 :用 Spring 的 TransactionTemplate 演示 SELECT ... FOR UPDATE 保护读改写。

前置环境 :PostgreSQL 14+,Spring Boot 3.x,依赖 spring-boot-starter-jdbc 与 postgresql 驱动。

java 复制代码
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
import org.postgresql.util.PGobject;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.support.TransactionTemplate;

public class OrderExtrasService {
    private final JdbcTemplate jdbc;
    private final TransactionTemplate tx;

    public OrderExtrasService(JdbcTemplate jdbc, TransactionTemplate tx) {
        this.jdbc = jdbc;
        this.tx = tx;
    }

    public void addTag(Long orderId, String tag) {
        tx.execute(status -> {
            String extras = jdbc.query(
                "SELECT extras FROM orders WHERE id = ? FOR UPDATE",
                rs -> rs.next() ? rs.getString(1) : null,
                orderId);
            if (extras == null) {
                throw new IllegalStateException("order not found: " + orderId);
            }
            String merged = mergeTag(extras, tag);
            PGobject pg = new PGobject();
            try {
                pg.setType("jsonb");
                pg.setValue(merged);
            } catch (SQLException e) {
                throw new IllegalStateException("invalid json", e);
            }
            jdbc.update("UPDATE orders SET extras = ? WHERE id = ?", pg, orderId);
            return null;
        });
    }

    private String mergeTag(String extras, String tag) {
        // 简化实现:真实项目建议用 Jackson 解析再合并
        String trimmed = extras.trim();
        if (trimmed.equals("{}")) {
            return "{\"tags\":[\"" + tag + "\"]}";
        }
        return trimmed.substring(0, trimmed.length() - 1) + ",\"tag_" + tag + "\":true}";
    }
}

关键步骤 :FOR UPDATE 让事务持有目标行的排他锁,第二个线程会阻塞在 SELECT 上,直到第一个事务提交,从而读到最新值。

适用场景 :合并逻辑复杂、无法用 || 表达时,例如需要判断数组去重、需要按业务规则裁剪。

边界与常见错误:

  1. 锁的持有时间等于事务长度,事务里不要做远程调用,否则会拖长阻塞。
  2. 如果忘了 FOR UPDATE,两个线程的读改写仍会互相覆盖。
  3. mergeTag 里的字符串拼接是简化写法,遇到已有 tags 数组时会出错,生产代码必须用 JSON 库解析合并。

7. 三种方案怎么选:决策表

场景 推荐做法 不推荐 原因
写入 JSON PGobject 或 setObject(Types.OTHER) 字符串拼接 类型明确、避免注入
按固定键值筛选 @> + GIN jsonb_path_ops ->> 比较 操作符匹配索引
判断键存在 ? / `? + GINjsonb_ops` jsonb_path_ops
数组内条件查询 抽列或表达式索引 直接 jsonb_path_exists 全表扫 函数调用不走 GIN
并发加字段 `extras '{}'::jsonb`
复杂合并逻辑 SELECT FOR UPDATE + 应用层合并 乐观重试无版本号 保证读到最新

补充一条判断原则:如果一个 JSONB 字段的某个键出现在 80% 以上的查询条件里,它就不该继续待在 JSONB 里。 把它抽成独立列,收益通常远超"减少迁移"的便利。

8. 常见误区盘点

误区一:只要建了 GIN 索引,JSON 查询就会走索引。 错。索引只对匹配的操作符和操作符类生效,->>、jsonb_path_exists 这类表达式默认用不上。

误区二:setString 和 setObject 效果一样。 错。前者把类型标成 varchar,服务端可能拒绝写入 jsonb 列。

误区三:JSONB 写进去什么读出来就是什么。 错。jsonb 会去重键、规范化数字、重排键顺序,读取文本不代表原始输入。

误区四:|| 会递归合并嵌套对象。 错。它是浅合并,嵌套层中同键会被整体替换,而不是深入合并。

误区五:FOR UPDATE 加在任意查询上都行。 它只锁实际扫描到的行;如果查询走了索引,锁范围由索引决定,锁的行数可能与直觉不同。

9. 生产实践建议

  1. 类型统一 :在项目内统一使用自定义 TypeHandler 或 PGobject 封装 JSONB 读写,禁止裸字符串拼接。
  2. 索引选型 :只需要 @> 就用 jsonb_path_ops,需要键存在查询就用 jsonb_ops;两者可按需并存,但要评估写放大代价。
  3. 控制字段大小:单个 JSONB 文档建议不超过几十 KB,超过 2KB 左右再考虑 TOAST 策略对查询的影响。
  4. 统计信息 :JSONB 列的数据分布变化快,定期 ANALYZE,必要时调高 default_statistics_target 或在列级设置统计目标。
  5. 并发写:优先用服务端原子操作;确实需要读改写时,必须显式加锁或引入版本号。
  6. 监控 :关注 pg_stat_user_indexes 中 GIN 索引的 idx_scan,长期为 0 说明索引未被使用,需要复核查询写法。

10. 排障清单

当业务反馈"JSON 查询慢"或"写不进去"时,按以下顺序排查:

  1. EXPLAIN (ANALYZE, BUFFERS) 查看是否 Seq Scan,确认实际扫描行数。
  2. 检查 SQL 中使用的操作符是 @>、? 还是 ->>,对照索引操作符类。
  3. 用 \d+ orders 查看索引定义,确认操作符类是 jsonb_ops 还是 jsonb_path_ops。
  4. 确认索引列与查询表达式类型一致(jsonb 对 jsonb,而非 text)。
  5. 报类型错误时,检查 Java 侧用的是 setString 还是 setObject(Types.OTHER) / PGobject。
  6. 并发覆盖时,确认事务内是否有 FOR UPDATE,或是否用了 || 原子合并。
  7. 索引存在但不被选用时,执行 ANALYZE 后重看计划,评估代价估算是否合理。

11. 面试与复盘问题

  1. jsonb 和 json 在存储和索引上有何本质区别?为什么 GIN 索引只对 jsonb 有意义?
  2. PgJDBC 中 setObject(i, value, Types.OTHER) 为什么能让 jsonb 列写入成功?它的推断发生在哪个协议阶段?
  3. jsonb_ops 与 jsonb_path_ops 分别支持哪些操作符?选错会出现什么现象?
  4. extras ->> 'k' = 'v' 与 extras @> '{"k":"v"}' 在执行计划层面差在哪里?
  5. SELECT ... FOR UPDATE 在 JSONB 读改写场景中锁住了什么?如果查询走了索引,锁的范围如何确定?
  6. 为什么 jsonb_path_exists 通常用不上 GIN 索引?有哪些替代方案?

12. 总结

回到最开始那个线上现象。它的根因不是"JSONB 性能差",而是链路中的三处断裂:写入时类型没标对,查询时操作符没匹配索引,并发时没有锁。把这三件事摆到同一条链路上看,问题就变得可定位、可验证、可修复。

记住一句话就够用:先让值带着 jsonb 的类型身份进入数据库,再用 GIN 认识的操作符去查它,最后用锁或原子合并保护它的更新。 这三步做对,JSONB 就是好用的半结构化存储;做错任何一步,它就会变成线上 P99 的隐形推手。

13. 参考资料

  1. PostgreSQL 官方文档:JSON Types(https://www.postgresql.org/docs/current/datatype-json.html)
  2. PostgreSQL 官方文档:JSON Functions and Operators(https://www.postgresql.org/docs/current/functions-json.html)
  3. PostgreSQL 官方文档:GIN Indexes(https://www.postgresql.org/docs/current/gin.html)
  4. PostgreSQL 官方文档:Explicit Locking / SELECT FOR UPDATE(https://www.postgresql.org/docs/current/explicit-locking.html)
  5. PgJDBC 官方文档:Using the Driver / Custom Type Mapping(https://jdbc.postgresql.org/documentation/)
  6. Spring Framework 文档:JDBC Data Access(https://docs.spring.io/spring-framework/reference/data-access/jdbc.html)
  7. Markus Winand, SQL Performance Explained(关于索引与操作符匹配的章节)
相关推荐
IT研究室2 小时前
最新计算机毕业设计选题推荐-基于spring boot的美食配送平台-网站-文档指导-Java-springboot
java·spring boot·课程设计
小小龙学IT3 小时前
PostgreSQL 服务端机制与 SQL 实战深度解析:从 MVCC 到查询优化器
数据库·sql·postgresql
省钱兄--zs3 小时前
无人自助拼豆系统架构设计与功能实现落地部署全流程实战指南
java·数据仓库·spring boot·系统架构·需求分析
IvorySQL4 小时前
PostgreSQL 日报| pg_vectorsearch 开源向量搜索发布(10 月 14 日)
数据库·人工智能·postgresql·开源·区块链
vx_bisheyuange4 小时前
基于SpringBoot的农产品电商平台
spring boot·毕业设计
xian_wwq5 小时前
【学习笔记】pgvector 入门:用 PostgreSQL 直接实现向量检索-13
笔记·学习·postgresql
霸道流氓气质5 小时前
MinIO 入门与实战:S3 兼容对象存储、Spring Boot 集成与 AI 应用文件管理
人工智能·spring boot·后端
威哥爱编程5 小时前
【AI全栈12-07】Spring Boot 用 Agent 自动完成多步运营任务
java·spring boot·后端
RuoyiOffice6 小时前
SpringBoot3+Vue3 员工全生命周期:入转调离如何衔接账号、权限、资产与交接
spring boot·vue3·权限管理·员工管理·hrm·ruoyi office·人事变更