Db 与 PostgreSQL 业务实践
本章介绍 java-db 的静态 Db、JSONB 和事务用法,以及与 tio-boot-admin 的集成方式。使用 java-db 库时,单数据源业务直接使用 nexus.io.db.activerecord.Db,无需在 Store/Service 中持有或注入 DbPro。这是 java-db 的使用方式,不依赖于是否使用 tio-boot-admin。
初始化与数据源
使用静态 Db 前,先配置数据源并启动 ActiveRecordPlugin,建立默认配置。独立使用 java-db 时由应用完成该初始化,参见 独立使用 ActiveRecord。
使用 tio-boot-admin 时,new TioAdminDbConfiguration().config() 已完成数据源和 ActiveRecord 的初始化,可直接复用。初始化后,两种接入方式都按以下形式调用:
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
Row user = Db.findFirst("select id,nickname from app_user where tenant_id=? and id=? and deleted=0", tenantId, userId);
if (user == null) {
// 按业务契约返回未找到,不要立即 user.get(...)
}
无需再次创建连接池、自己保存 ThreadLocal Connection,或为使用 Db 配置 Model 表映射。Db 不自动添加租户、逻辑删除、权限条件,这些条件必须体现在 SQL 中。
tio-boot-admin 支持可选配置 jdbc.connectionInitSql,在连接池建立每个物理连接时执行。业务需要固定数据库会话时区时可配置:
jdbc.connectionInitSql=set time zone 'Asia/Shanghai'
它用于会话级连接设置,不应放建表、迁移或数据初始化 SQL;数据库初始化仍由用户手动执行。
查询、更新与参数
List<Row> rows = Db.find("select id,title from app_resource where tenant_id=? and status=? order by id desc limit ?", tenantId, "published", 20);
Long total = Db.queryLong("select count(*) from app_resource where tenant_id=?", tenantId);
int changed = Db.update("update app_resource set title=? where tenant_id=? and id=?", title, tenantId, resourceId);
Db.update(sql, args)、Db.updateBySql(sql, args) 都是静态入口。仅在明确需要访问命名数据源时,使用 Db.use("reporting"),前提是已注册该名称的配置;单数据源业务不必保存 DbPro 实例。不要把接受表名和 Row 的重载与接受 SQL 的重载混淆。单列查询可用 queryLong/queryStr 等;多列查询优先 find/findFirst 返回 Row。
? 只绑定值,不能绑定表名、列名、排序方向。动态标识符必须来自固定白名单,不能拼接客户端原文。update 的返回值是受影响行数,关键状态迁移需检查是否为预期值。
JSONB:显式声明需要解析的列
普通 Db.find 不保证将 JSONB 自动转换成 Map/List,默认转换会把相关值转为 JSON 字符串。需要结构化数据时指定列名:
String[] jsonFields = { "form_data", "region_codes" };
List<Row> resources = Db.findWithJsonField(
"select id,form_data,region_codes from app_resource where tenant_id=?", jsonFields, tenantId);
// 列名与结果集标签一致,参数仍使用占位符绑定。
指定名称必须与结果列名/SQL 别名一致。JSON 对象解析后可按 Map 读取,数组按 List 读取;不要因为普通文本以 { 开头就尝试解析所有字符串。
写入 JSONB 可使用 PostgreSQL 驱动提供的显式类型值:
org.postgresql.util.PGobject json = nexus.io.kit.PgObjectUtils.jsonb(
com.alibaba.fastjson2.JSON.toJSONString(formData));
Db.update("update app_resource set form_data=? where tenant_id=? and id=?", json, tenantId, resourceId);
PgObjectUtils.jsonb(String) 接收已序列化的 JSON 文本;显式序列化可以自行决定是否保留对象中的 null 字段。JSON 列名、类型和业务 schema 仍需验证,不能把任意请求字段作为数据库列写入。
事务与并发
boolean committed = Db.tx(java.sql.Connection.TRANSACTION_READ_COMMITTED, () -> {
Row account = Db.findFirst("select balance from app_wallet where tenant_id=? and user_id=? for update", tenantId, userId);
if (account == null || account.getLong("balance") < amount) return false;
int changed = Db.update("update app_wallet set balance=balance-? where tenant_id=? and user_id=?", amount, tenantId, userId);
if (changed != 1) return false;
Db.update("insert into app_ledger(id,tenant_id,user_id,amount) values (?,?,?,?)", ledgerId, tenantId, userId, -amount);
return true;
});
if (!committed) {
// 已回滚,返回业务失败,不要继续发放权益。
}
- 同一线程、同一配置的 Db/DbPro 调用共享事务连接。默认配置事务里误用另一命名数据源,不会自动纳入同一个事务。
- 返回 true 提交;返回 false 回滚;异常也回滚。Db 可能将异常包装为
ActiveRecordException,业务错误转换应检查 cause 链,同时保留未知异常的诊断信息。 - 嵌套事务共享外层连接,不是独立提交或独立 savepoint;不要吞掉失败后继续声称操作成功。
- 切换线程不会传播事务。不要在持锁事务中等待短信、支付、AI 等外部网络调用。
- 唯一约束和幂等键负责跨请求重复提交控制;
SELECT ... FOR UPDATE必须在事务内才对后续更新有意义。 - PostgreSQL
23505可按业务转换为 409,但不能将所有数据库异常都当作“重复操作”。
独立数据库测试
使用静态 Db 的集成测试应在独立测试 JVM 中,将默认配置连接到随机 schema;生产业务直接使用管理员框架已初始化的默认配置。不要替换运行中服务的默认配置:
if (nexus.io.db.activerecord.DbKit.getConfig() != null) {
throw new IllegalStateException("Run in an isolated test JVM with no existing Db configuration");
}
ActiveRecordPlugin plugin = new ActiveRecordPlugin(isolatedDataSource);
plugin.setDialect(new nexus.io.db.activerecord.dialect.PostgreSqlDialect());
plugin.start();
try {
if (!testSchema.equals(Db.queryStr("select current_schema()"))) {
throw new IllegalStateException("Unexpected database schema");
}
// 在测试专用 schema 验证提交、回滚、JSONB、唯一约束和并发行为。
} finally {
plugin.stop();
}
ActiveRecordPlugin 位于 nexus.io.db.activerecord。测试建表脚本只能在已检查名称的随机 schema 执行;结束时关闭该配置,并只清理本轮创建的 schema。完整隔离流程见 PostgreSQL 集成测试。
Db 的默认配置是进程级状态,这类测试不应在同一 JVM 中并行切换默认数据源。数据库 schema 仍使用每轮唯一名称,通过隔离 DataSource 的 search_path 配置,不能只依赖配置名称隔离数据。
返回业务结果的事务
需要在提交后返回订单 ID、Row 或业务对象时,直接使用 Db.txResult,无需在业务层维护 AtomicReference 或另一套事务连接:
Long id = Db.txResult(java.sql.Connection.TRANSACTION_READ_COMMITTED, () -> {
Db.update("insert into app_order(id,tenant_id) values (?,?)", orderId, tenantId);
return Db.queryLong("select id from app_order where tenant_id=? and id=?", tenantId, orderId);
});
也可用 Db.txResult(callback):开启事务时使用配置的默认隔离级别,嵌套调用时沿用外层连接的隔离级别。请在最外层确定隔离级别,避免 PostgreSQL 已执行 SQL 后再尝试提高隔离级别。回调类型为 nexus.io.db.activerecord.tx.TransactionCallback<T>,允许抛出受检异常。
- 正常返回值表示提交,null 和 Boolean.FALSE 都可以作为业务结果返回;这里的 false 不表示回滚。
- 需要回滚时抛异常;如果需要用 boolean 控制提交/回滚,继续使用
Db.tx。 - 异常沿用 ActiveRecord 的包装与回滚语义,业务错误转换应检查 cause 链。
- 嵌套调用共享事务连接,内层返回不代表事务已经独立提交。嵌套
Db.tx返回 false 导致整体回滚时,txResult会抛异常,不会返回看似成功的结果。 - 命名数据源通过
Db.use(name).txResult(...)调用;其中的查询和写入也必须使用同一个命名数据源。
查询第一条 JSON 记录
Row resource = Db.findFirstWithJsonField(
"select id,form_data from app_resource where tenant_id=? and id=? limit 1",
new String[] { "form_data" }, tenantId, resourceId);
没有记录时返回 null,指定的 JSON 结果列被解析为对象或数组。DbPro 同样提供这个入口,原 findFirstJsonField 保持可用。查询可加 limit 1,方法本身不会改写 SQL 或自动限制返回行数。
PostgreSQL 参数边界
- LocalDate、LocalDateTime 已由 PostgreSqlDialect 绑定,无需在业务层转成 java.sql.Date/Timestamp。
- int[]、long[]、short[]、float[]、double[]、boolean[] 及对应包装数组按 PostgreSQL 数组绑定;空基本类型数组同样支持。byte[] 保持二进制参数语义。
- 非空字符串 List 保持 text[] 绑定语义,其他 List 按 JSONB 处理;空 List 按空 JSON 数组处理。需要空 text[] 时传 String[],需要 JSON 字符串数组时显式使用 PgObjectUtils.jsonb,不依赖内容猜测类型。
业务 Store 是否需要保留
java-db 已负责 SQL 执行、参数绑定和事务管理,不需要业务项目重新实现这些能力。可以直接在业务代码中使用静态 Db。
如果 Store 集中维护租户条件、逻辑删除、业务 JSON 列清单、找不到记录时的业务响应、SQLState 到业务错误的映射,可以保留为薄业务辅助层。这些规则属于应用,不能搬进通用 Db:例如米旺的 tenant_id=0、用户状态检查和 HTTP 404/409 都不应成为 java-db 的默认行为。
从业务 Store 提取 Kv 数据访问能力
通用能力使用下面的新方法名,避免将业务项目的 one、need、user 等简写作为框架 API。除参数转换外,各方法在 DbPro 中也提供对应入口,命名数据源可通过 Db.use(name) 使用。
| 原 Store 职责 | 框架入口 | 约定 |
|---|---|---|
| 将查询结果转换为 Kv 列表 | Db.findMaps(sql, jsonFields, paras) | 返回 List<Kv>,每行是独立的 com.jfinal.kit.Kv |
| 查询单行 Kv | Db.findFirstMap(sql, jsonFields, paras) | 返回 Kv;没有记录返回 null,不隐含 HTTP 404 |
| 执行完整 SQL 分页 | Db.paginateMap(page, size, countSql, findSql, jsonFields, paras) | PostgreSQL,返回包含 list、total、page、pageSize 的 Kv |
| 插入并返回记录 | Db.insertMapReturning(table, fields, jsonFields) | PostgreSQL,字段参数和返回值均为 Kv,不补充 id 或 tenant_id |
| 按明确列条件更新 | Db.updateMapByColumns(table, fields, conditions, timestampColumns) | PostgreSQL,字段与条件参数均为 Kv;条件不能为空,条件值为 null 时使用 IS NULL |
| Map/List 参数显式转换为 JSONB | Db.toJsonbParameters(paras) | 克隆参数数组,不修改调用者参数 |
| 带返回值事务 | Db.txResult(...) | 沿用现有事务 API |
| SQL 更新、计数 | Db.update(...)、Db.queryLong(...) | 沿用现有 API |
静态 Kv 查询方法使用主数据源,支持事务锁和 PostgreSQL RETURNING 语句。纯查询需要读副本时,可以显式使用 Db.useRead().findMaps(...),不要将写入或加锁 SQL 交给读副本。事务内连接仍由 java-db 管理。
import com.jfinal.kit.Kv;
String[] jsonFields = { "payload" };
Object[] parameters = Db.toJsonbParameters(tenantId, payload);
List<Kv> rows = Db.findMaps(
"select id,payload from app_event where tenant_id=? and payload @> ?::jsonb",
jsonFields, parameters);
Kv fields = Kv.create();
fields.put("id", eventId);
fields.put("tenant_id", tenantId);
fields.put("payload", payload);
Kv inserted = Db.insertMapReturning("app_event", fields, jsonFields);
Kv changes = Kv.create();
changes.put("payload", newPayload);
Kv conditions = Kv.create();
conditions.put("id", eventId);
conditions.put("tenant_id", tenantId);
int updated = Db.updateMapByColumns("app_event", changes, conditions, "update_time");
插入和按列更新会显式转换 Map/List 为 JSONB。表名、列名仅接受小写字母、数字和下划线,首字符不能是数字;内部会加标识符引号,不接受表达式或带 schema 的表名。值始终通过占位符绑定。需要复杂 SQL 或其他方言时使用已有 SQL API。
分页的两条 SQL 必须使用同一组参数,查询 SQL 不应提前包含 LIMIT/OFFSET;排序由调用方指定。页码和每页数量必须为正数,偏移量按 long 计算。列表与总数是两次查询;需要同一快照时由调用方选择相应事务隔离级别。
updateMapByColumns 的最后一个可变参数用于指定由数据库设置 CURRENT_TIMESTAMP 的列;不能同时出现在 fields 中。不传时间戳列时不会自动更新时间。字段和时间戳列均为空时返回 0。
米旺保留薄 Store,用于租户默认值、JSON 列名单、业务错误转换、用户状态检查和锁定。通用 SQL 拼接及 Map 转换已经委托 Db;雪花 ID、404/409 错误、具体用户表不能成为通用 Db 的隐含规则。
findMaps 在 Db 和 DbPro 中均返回 List<Kv>;paginateMap 的 list 字段也包含 Kv 行。可使用 row.getLong("id") 等类型读取方法。findFirstMap、paginateMap、insertMapReturning 返回 Kv;写入字段与更新条件也使用 Kv。updateMapByColumns 返回受影响行数 int。
使用 Kv 的类型转换
Kv row = Db.findFirstMap(sql, jsonFields, parameters);
if (row != null) {
Long id = row.getLong("id");
Integer quantity = row.getInt("quantity");
String name = row.getStr("name");
}
Kv page = Db.paginateMap(1, 20, countSql, findSql, jsonFields, parameters);
Long total = page.getLong("total");
List<Kv> rows = page.getAs("list");
Kv 的 getter 提供类型转换;缺失字段可能返回 null,赋给基本类型前应确认业务字段非空。HTTP 参数边界仍需校验范围和格式,不能用 getter 转换代替校验。Kv 不保证字段遍历顺序,不要依赖 JSON 对象的键顺序。
指定 JSON 列的内部对象可能仍是 Map/List;读取嵌套对象时用 Kv.create().set(map) 转换,不直接强制转换为 Kv。只有数据库记录与分页容器保证为 Kv。
toJsonbParameters 是 JDBC 参数数组转换方法,因此仍返回 Object[];数组中的 Kv(以及普通 Map/List)会被转换成 JSONB 参数。它不改变调用者的 Kv 或参数数组。上述新入口保持原方法名,改用 Kv 后,调用方应重新编译。
