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 或另一套事务连接:
import java.sql.Connection;
import nexus.io.db.activerecord.Db;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;
// 以下代码位于已配置数据源的业务方法内,tenantId 由服务端认证上下文提供。
long orderId = SnowflakeIdUtils.id();
Long id = Db.txResult(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。 - 事务回调抛出
RuntimeException或Error时,回滚后原样抛出,保留业务异常类型与对象身份;受检异常包装为ActiveRecordException。这使 HTTP 层可以准确转换明确的业务拒绝,并把未知基础设施结果与业务拒绝区分开。嵌套事务仍共享外层连接,异常处理不能绕过整体回滚。 - 嵌套调用共享事务连接,内层返回不代表事务已经独立提交。嵌套
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, paras) / 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 后,调用方应重新编译。
无需解析 JSON 列时,可以使用 Db.findFirstMap(sql, paras) 或 DbPro.findFirstMap(sql, paras),不必传入空的 JSON 列数组。没有匹配记录仍返回 null,调用 getter 前必须判空;静态 Db 入口继续使用主数据源。
需要解析 JSON 列时使用 findFirstMap(sql, jsonFields, parameters),其中 parameters 为 Object[];无 SQL 参数时显式传 new Object[0]。避免仅传 findFirstMap(sql, jsonFields):新增重载后,String[] 可能匹配为 SQL 参数数组。单个 SQL 参数本身为 String[] 时,用 new Object[] { values } 包装;SQL NULL 使用 (Object) null 或 new Object[] { null }。
PostgreSQL 配额与稿位的串行受理
发布配额、最后一个稿位等有限资源,可以在 READ_COMMITTED 事务内先锁定责任账号,再读取当前计数并写入事实。相同责任账号的并发请求依次受理;后一个请求取得锁后能读到前一个事务的实际提交结果。数据库唯一约束继续作为最后一道一致性保障。
外层业务与内层原操作封装应使用相同的事务隔离级别。嵌套调用共享连接;在进入事务前确定级别,避免在执行查询后尝试提升级别。锁内只执行本地数据库动作,图片解码或外部服务调用宜在适当的独立阶段完成。
import java.sql.Connection;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;
public final class DraftReservation {
public boolean reserve(long accountId, long postId) {
return Db.tx(Connection.TRANSACTION_READ_COMMITTED, () -> {
Row account = Db.findFirst(
"select id from demo_account where id=? for update", accountId);
if (account == null) {
throw new IllegalArgumentException("账号不存在");
}
Row slot = Db.findFirst("""
select n from generate_series(1,10) n
where not exists (
select 1 from demo_draft where account_id=?
and slot_no=n and released_at is null
) order by n limit 1
""", accountId);
if (slot == null) {
return false;
}
Db.update("""
insert into demo_draft(id,account_id,post_id,slot_no)
values(?,?,?,?)
""", SnowflakeIdUtils.id(), accountId, postId, slot.getInt("n"));
return true;
});
}
}
示例表应另外建立 (account_id, slot_no) 在 released_at is null 条件下的唯一索引。实际多租户项目须在锁、查询、写入和唯一约束中同时包含租户键。送审、配额暂占、原操作引用应同事务提交;审核退回只释放受理时锁定的原周期,编辑不能改变首次公开期限。
PGobject 查询结果转换
PgObjectUtils 可以把查询得到的 JSON/JSONB 包装值转换为实体或列表,转换结果写回原 Row 或 Kv 字段。Kv 的 toBean 同时接受 PGobject 和 JSON 字符串;已经是实体的值保持原样,因此重复转换不会再次强制转换为 PGobject。
import com.jfinal.kit.Kv;
import nexus.io.kit.PgObjectUtils;
public class JsonSettingExample {
public Setting convert(Kv record) {
PgObjectUtils.toBean(record, "setting", Setting.class);
return record.getAs("setting");
}
public static class Setting {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}
空值与类型约定:
- Row/Kv 中已经为
null的字段保持null。 toBean遇到空白 JSON 文本或内部值为空的 PGobject 时写入null,不生成其他类型的对象。- Row 的
toListBean和toListMap遇到空白文本或内部值为空的 PGobject 时写入空列表。 - Row 的
toMap遇到空白文本时写入空 Kv。 - 直接返回结果的
toBean(PGobject, Class)、toListBean(PGobject, Class)接受nullPGobject,并返回null;其空白文本行为也保留为返回null。
有效 JSON 按目标类型解析,非法 JSON 的解析异常仍向调用方传播。应用应保持 JSON 运行时依赖与所用工具包兼容。
直接返回 Kv 与 Instant 参数
DbPro.findMaps(sql, paras) 和 Db.findMaps(sql, paras) 提供不指定 JSON 字段的查询入口,返回 List<Kv>,保留驱动原始列值。findFirstMap 无匹配时返回 null。静态方法委托给主数据源的 DbPro,命名数据源使用 Db.use(name)。
PostgreSQL 的参数绑定支持 Instant,内部转换为 Timestamp,保留时间精度。带返回值的事务直接使用现有 txResult,无需再编写应用 SQL 工具类。
import com.jfinal.kit.Kv;
import java.sql.Connection;
import java.time.Instant;
import java.util.List;
import nexus.io.db.activerecord.Db;
import nexus.io.model.exception.BusinessException;
List<Kv> rows = Db.findMaps("select id, title from task where created_at > ?", Instant.now().minusSeconds(3600));
Kv saved = Db.txResult(Connection.TRANSACTION_REPEATABLE_READ, () -> {
Kv row = Db.findFirstMap("select id, status from task where id=? for update", 123L);
BusinessException.require(row != null, 404, "NOT_FOUND", "Task not found");
Db.update("update task set updated_at=? where id=?", Instant.now(), row.getLong("id"));
return row;
});
最外层按业务需要选择隔离级别,所有相关调用加入同一线程绑定事务。失败时让异常向外传播,以保持回滚语义。
