Skip to content

用户币币账户缓存 ​

现货交易一刻不停地改写「还能用多少、已经占了多少」。若这笔账放在撮合之外,下单占用、盘口成交、撤单退回就要跨服务来回确认,延迟与半成功都会拖垮热路径。因此撮合进程按用户与币种就地记住可用与冻结:谁进簿,谁改账;占用、划转、释放与订单簿同一处串行落定,交易主链不再绕道问账务。

索引结构 ​

Table<userId, coinId, UserBBCoinWallet>

整张表可以想成一张二维账本:行是用户,列是币种,格子里是该用户在该币种下的钱包。查「某人某币还有多少可用、冻了多少」,一次按行键 + 列键定位即可;装入时也是同一坐标覆盖或写入。

实现上用 Guava 的 HashBasedTable 存这份二维关系,再包一层 Tables.synchronizedTable,保证多处读写时表结构本身的并发安全。真正改可用与冻结时,仍以撮合事件串行为准——同步表只护住「格子怎么取、怎么放」,不替代业务上的先记账再进簿。

索引只拆到用户 × 币种,不再为商户加第三维。同一用户同一币种在撮合侧只有一条钱包;商户身份记在钱包对象的 partnerId 上,供流水、分成等携带上下文,而不参与寻址。这样热路径键最短,也避免「同一用户因商户维度被拆成多份余额」的歧义。

钱包字段 ​

字段类型含义备注
idLong钱包主键与账务侧币币钱包记录对应
partnerIdLong商户 id不参与 Table 键;挂在对象上便于流水与分成
userIdLong用户 id与 Table 行键一致
coinIdLong币种 id与 Table 列键一致
totalAtomicLong可用 · 整数部分热路径加减改此字段
totalDecimalAtomicLong可用 · 小数部分与 total 拼成完整可用;对外见 getTotalBigDecimal
frozenAtomicLong冻结 · 整数部分下单占用、成交/撤单释放
frozenDecimalAtomicLong冻结 · 小数部分与 frozen 拼成完整冻结;对外见 getFrozenBigDecimal
paTotalAtomicLong商户手续费暂存成交计费时累加,供后续结算/分成
adTotalAtomicLong平台手续费暂存成交计费时累加,供后续结算
versionAtomicInteger版本号每次可用/冻结加减成功后递增
createTimeDate创建时间钱包装入缓存时带入
updateTimeDate修改时间冻结/解冻等改账成功后刷新

数量在热路径用定点整数运算;对外预检、落库常用 BigDecimal 口径(getTotalBigDecimal / getFrozenBigDecimal)。

如何写进来、如何改 ​

缓存里的格子不会凭空出现,也不会在进程外被直接改掉。写入大致分两类:装入快照与在快照上做增量。

装入:格子从哪来 ​

某用户某币种第一次被交易碰到时,按「内存 → Redis → MySQL → 必要时新建」补齐,再 put 进 Table:

  1. 内存已有 → 直接用,不再回源。
  2. 内存没有 → 读 Redis 里该用户币种快照;有则装入内存。
  3. Redis 也没有 → 读 MySQL 币币钱包;有则装入内存,并回写 Redis。
  4. 库中也没有 → 初始化一条零余额钱包写入库与缓存。

撮合启动恢复未完结委托时,会按委托涉及到的用户与币种批量从 Redis 预热进本表,避免开盘后第一笔才冷启动。同一用户装入过程会串行化,避免并发首次加载写出两份钱包。

修改:谁在改格子里的数 ​

装入之后,可用 / 冻结只在撮合事件处理链上改内存对象本身,一般不再整对象覆盖:

来源改什么
下单受理对应币种:可用 → 冻结(详见下一节)
撤单出簿剩余占用:冻结 → 可用
成交比价双方解冻占用、划转入账、累计手续费暂存;买方可能退回多冻部分
充值 / 提现 / 调账等外部变动先保证钱包已装入,再以「改账事件」进同一处理链,对可用或冻结做加减(或可用↔冻结对调)

改成功会带出账务流水意向,写入 Redis 缓冲,由后台异步刷进 MySQL。流水不作为 Table 里的常驻数据;查询展示可走库或检索,热路径只认内存钱包。

委托侧余额预检只读本表快照,不写;真正占用发生在撮合受理那一次增量。

增量:可用 → 冻结 ​

下单受理时,撮合在内存钱包上做的占用动作。钱仍在同一用户、同一币种的格子里:只是从「还能用」变成「已被挂单占用」——不是转到卖方或其它账户。买卖双方之间的资产交换发生在后续成交,不在本步骤。

在整条交易链上的位置:见撮合主流程 · 冻结建单。本节只写格子怎么被改。

调用前要满足什么 ​

  • Table 里已有 (userId, coinId) 格子(通常入口已 load;本步骤不做「从库新建钱包」)。
  • 调用方已算好:冻哪个币、冻多少(买冻计价币金额,卖冻基础币数量;引擎内为定点整数)。
  • 事件处在撮合处理链上(与进簿同链串行),避免旁路改余额。

逐步怎么做 ​

  1. 按键取钱包
    get(userId, coinId)。格子不存在 → 失败(无资产可改),整笔下单终止。

  2. 在钱包对象上做可用 → 冻结
    同一 UserBBCoinWallet 内顺序执行:

    • 先按数量减少可用(整数部分 + 小数部分一并处理;可用不足则整步失败,冻结不变)。
    • 再增加冻结同额。
      任一步失败 → 本步骤失败,不生成成功流水;调用方不得继续写委托热数据、不得进簿。
  3. 刷新修改时间
    成功后更新 updateTime;可用/冻结加减成功时 version 递增。

  4. 记下流水意向
    生成一条撮合侧账务流水意向,典型变动口径为:可用变动为负、冻结变动为正(同额),并带上业务类型(委托占用一类)与业务单号(如委托号)。
    流水意向随后由处理链写入 Redis 缓冲,异步落库;流水本身不常驻 Table。

成功 / 失败之后 ​

结果内存钱包调用方通常接着做什么
成功可用已减、冻结已加写 Redis 本笔增量、写委托热数据,再进入订单簿
失败(无格子 / 可用不足等)应保持未成功占用整笔下单失败;不建热委托、不进盘口

对称操作是 冻结 → 可用(撤单退回剩余占用):先减冻结、再加可用,同样只动同一格子。成交路径则会解冻占用并在买卖双方之间划转,属于另一类增量,不在本节展开。

如何保证一致性 ​

这里说的一致,主要指:同一用户币种上的可用/冻结,与未完结委托占用、成交结果对得上;以及进程崩溃后能靠 Redis / 库把权威态找回来。

  1. 权威在内存,改账跟进簿同链
    下单先改钱包再进订单簿;成交、撤单也在同一条撮合处理链上改剩余量与冻结。同一交易对事件串行,不会出现「簿上还有单、冻结已被旁路解掉」或「已成交却未解冻」这类交叉写。

  2. 先校验再加减,失败不进簿
    可用不够不冻、冻结不够不成交;加减失败则本事件失败(同步调用方拿到错误),不会带着半成功余额去挂单。多步成交改账内部带回滚补偿路径,避免半步成功留下脏数。

  3. 外部资金变动也进撮合改账通道
    充提等不直改 MySQL 当撮合真相,而是变成撮合侧的改账事件,与交易事件共用处理链,从而与挂单占用抢同一份可用/冻结。路径见充提改账协同。

  4. 装入只补洞,不覆盖热路径正在改的权威
    已在内存的格子优先用内存;回源只发生在格子缺失时。版本号随每次加减递增,便于旁路识别变更次序。

  5. 持久化是增量旁路,不是第二权威
    Redis / MySQL 承接快照与流水,供恢复与对账;运行中以内存为准。刷库积压或恢复顺序异常时,以撮合未完结委托 + Redis 缓冲重放装入为准,而不是用过期页面余额倒推。

委托预检与真实冻结之间仍可能有时间差(预检够、稍后受理时不够)——这是刻意的:预检不占坑,最终以受理瞬间串行改账结果为准。

详见下单主流程、撮合主流程、充提改账协同、手续费计费落地。同组:缓存总览。

TGEX · 交易所设计与业务流程