商业库存与按量履约事务
java-db 的线程绑定事务适合将原操作、库存分配、权益扣量和累计交付金额一起提交。数据库负责并发互斥,服务端负责当前资格与计量证据,客户端展示已经确认的事实。
请求、预占与交付分开
用户确认使用时,将选中批次的可用量转入预占量,不增加已用量。排队只决定等待顺序;只有独占位置真实可用,才形成开始含、结束不含的服务区间。多个窗口、重复列表请求和重复通知共享同一服务请求,不能再扣一次。
一种实用实现是采用短期服务租约:调度器持续确认资格和实际位置,随后追加已经确认的区间。调度器停顿超过租约时,未经证明的空档不补记服务时间,保留未交付量并展示暂停原因。租约不能取代实际服务可达性证据;真实环境应将流量入口健康与服务资格共同作为续租条件。
PostgreSQL 事务锁与累计金额
以下独立示例展示服务进度行的锁定、重复截止点去重、区间追加与累计到分。confirmedUntil 必须来自服务端已经核实的连续可服务区间,不能直接采用浏览器传入的时间。
create table demo_service_progress (
id bigint primary key,
promised_seconds bigint not null check(promised_seconds > 0),
delivered_seconds bigint not null default 0,
paid_fen bigint not null check(paid_fen >= 0),
delivered_fen bigint not null default 0,
confirmed_until timestamptz not null,
check(delivered_seconds between 0 and promised_seconds)
);
create table demo_service_interval (
id bigint primary key,
service_id bigint not null references demo_service_progress(id),
starts_at timestamptz not null,
ends_at timestamptz not null,
quantity bigint not null check(quantity > 0),
unique(service_id, starts_at),
check(ends_at > starts_at)
);
import java.math.BigInteger;
import java.sql.Connection;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;
public final class ConfirmedServiceLedger {
public void append(long serviceId, Instant confirmedUntil) {
Instant cutoff = confirmedUntil.truncatedTo(ChronoUnit.SECONDS);
boolean committed = Db.tx(Connection.TRANSACTION_READ_COMMITTED, () -> {
Row progress = Db.findFirst(
"select * from demo_service_progress where id=? for update", serviceId);
if (progress == null) {
throw new IllegalArgumentException("Service does not exist");
}
Instant start = progress.getTimestamp("confirmed_until").toInstant();
long promised = progress.getLong("promised_seconds");
long delivered = progress.getLong("delivered_seconds");
long seconds = Math.min(Duration.between(start, cutoff).getSeconds(), promised - delivered);
if (seconds <= 0) {
return true;
}
Instant end = start.plusSeconds(seconds);
long cumulative = delivered + seconds;
long amount = BigInteger.valueOf(progress.getLong("paid_fen"))
.multiply(BigInteger.valueOf(cumulative))
.divide(BigInteger.valueOf(promised)).longValueExact();
Db.update("""
insert into demo_service_interval(id, service_id, starts_at, ends_at, quantity)
values(?,?,?,?,?)
""", SnowflakeIdUtils.id(), serviceId, Timestamp.from(start), Timestamp.from(end), seconds);
Db.update("""
update demo_service_progress
set delivered_seconds=?, delivered_fen=?, confirmed_until=? where id=?
""", cumulative, amount, Timestamp.from(end), serviceId);
return true;
});
if (!committed) {
throw new IllegalStateException("Service transaction rolled back");
}
}
}
本例只演示已经确认的连续区间。发生中断后,应另建新区间起点并保留旧区间,不能直接把中断前的截止点推进到恢复后的时间。真实业务还需要在同一事务内移动批次数量,并关联原现金行。全部交付时累计金额等于原实付;例如300分对应1800秒,累计40秒交付6分,剩余责任294分。不要逐秒舍入后相加,也不要将40秒截成0分钟。
多个库存槽参与一次决策时,可以先用固定业务锁序列获取 PostgreSQL 事务级 advisory lock,再锁服务行和批次行。所有领域写入方必须遵守相同锁顺序。排期表还应约束同槽、同资源的半开区间不可重叠,预约和实际交付分别保存。
JSON 数量保留精度
请求模型可用 Number 保留 JSON 原数量,在业务边界显式转换,避免小数转换成整数后丢失原始含义。
import java.math.BigDecimal;
public record ServiceQuantityRequest(Number quantity) {
public long exactQuantity() {
if (quantity == null) {
throw new IllegalArgumentException("Quantity is required");
}
long exact = new BigDecimal(quantity.toString()).longValueExact();
if (exact <= 0) {
throw new IllegalArgumentException("Quantity must be positive");
}
return exact;
}
}
曝光与未交付责任
曝光使用独立观测事实:服务端匹配结果签发短期凭据,经核适配器确认前台可见比例、连续时长、可靠主体与测试排除后,才进入交付事务。同主体、同资源、同业务日的合格或待核事实通过唯一索引去重;待核不等失败,不能同时释放原量并发补偿。
服务到期后,原卡停止新使用,已经受理但未交付的责任单独保留。现金退款继续引用原本金范围;用户明确选择补交时,新批次关联原责任,原卡不复活。非现金补交保留来源和数量,不生成现金收入。
建议验证最后槽位争用、重复截止点、多窗口、40秒累计到分、短租约中断、待核观测跨期,以及退款与补交互斥。隔离合成测试证明工程行为,真实渠道可达与可信观测需要对应适配证据。
