外观
用户币币账户缓存
现货交易一刻不停地改写「还能用多少、已经占了多少」。若这笔账放在撮合之外,下单占用、盘口成交、撤单退回就要跨服务来回确认,延迟与半成功都会拖垮热路径。因此撮合进程按用户与币种就地记住可用与冻结:谁进簿,谁改账;占用、划转、释放与订单簿同一处串行落定,交易主链不再绕道问账务。
索引结构
Table<userId, coinId, UserBBCoinWallet>整张表可以想成一张二维账本:行是用户,列是币种,格子里是该用户在该币种下的钱包。查「某人某币还有多少可用、冻了多少」,一次按行键 + 列键定位即可;装入时也是同一坐标覆盖或写入。
实现上用 Guava 的 HashBasedTable 存这份二维关系,再包一层 Tables.synchronizedTable,保证多处读写时表结构本身的并发安全。真正改可用与冻结时,仍以撮合事件串行为准——同步表只护住「格子怎么取、怎么放」,不替代业务上的先记账再进簿。
索引只拆到用户 × 币种,不再为商户加第三维。同一用户同一币种在撮合侧只有一条钱包;商户身份记在钱包对象的 partnerId 上,供流水、分成等携带上下文,而不参与寻址。这样热路径键最短,也避免「同一用户因商户维度被拆成多份余额」的歧义。
钱包字段
| 字段 | 类型 | 含义 | 备注 |
|---|---|---|---|
| id | Long | 钱包主键 | 与账务侧币币钱包记录对应 |
| partnerId | Long | 商户 id | 不参与 Table 键;挂在对象上便于流水与分成 |
| userId | Long | 用户 id | 与 Table 行键一致 |
| coinId | Long | 币种 id | 与 Table 列键一致 |
| total | AtomicLong | 可用 · 整数部分 | 热路径加减改此字段 |
| totalDecimal | AtomicLong | 可用 · 小数部分 | 与 total 拼成完整可用;对外见 getTotalBigDecimal |
| frozen | AtomicLong | 冻结 · 整数部分 | 下单占用、成交/撤单释放 |
| frozenDecimal | AtomicLong | 冻结 · 小数部分 | 与 frozen 拼成完整冻结;对外见 getFrozenBigDecimal |
| paTotal | AtomicLong | 商户手续费暂存 | 成交计费时累加,供后续结算/分成 |
| adTotal | AtomicLong | 平台手续费暂存 | 成交计费时累加,供后续结算 |
| version | AtomicInteger | 版本号 | 每次可用/冻结加减成功后递增 |
| createTime | Date | 创建时间 | 钱包装入缓存时带入 |
| updateTime | Date | 修改时间 | 冻结/解冻等改账成功后刷新 |
数量在热路径用定点整数运算;对外预检、落库常用 BigDecimal 口径(getTotalBigDecimal / getFrozenBigDecimal)。
如何写进来、如何改
缓存里的格子不会凭空出现,也不会在进程外被直接改掉。写入大致分两类:装入快照与在快照上做增量。
装入:格子从哪来
某用户某币种第一次被交易碰到时,按「内存 → Redis → MySQL → 必要时新建」补齐,再 put 进 Table:
- 内存已有 → 直接用,不再回源。
- 内存没有 → 读 Redis 里该用户币种快照;有则装入内存。
- Redis 也没有 → 读 MySQL 币币钱包;有则装入内存,并回写 Redis。
- 库中也没有 → 初始化一条零余额钱包写入库与缓存。
撮合启动恢复未完结委托时,会按委托涉及到的用户与币种批量从 Redis 预热进本表,避免开盘后第一笔才冷启动。同一用户装入过程会串行化,避免并发首次加载写出两份钱包。
修改:谁在改格子里的数
装入之后,可用 / 冻结只在撮合事件处理链上改内存对象本身,一般不再整对象覆盖:
| 来源 | 改什么 |
|---|---|
| 下单受理 | 对应币种:可用 → 冻结(详见下一节) |
| 撤单出簿 | 剩余占用:冻结 → 可用 |
| 成交比价 | 双方解冻占用、划转入账、累计手续费暂存;买方可能退回多冻部分 |
| 充值 / 提现 / 调账等外部变动 | 先保证钱包已装入,再以「改账事件」进同一处理链,对可用或冻结做加减(或可用↔冻结对调) |
改成功会带出账务流水意向,写入 Redis 缓冲,由后台异步刷进 MySQL。流水不作为 Table 里的常驻数据;查询展示可走库或检索,热路径只认内存钱包。
委托侧余额预检只读本表快照,不写;真正占用发生在撮合受理那一次增量。
增量:可用 → 冻结
下单受理时,撮合在内存钱包上做的占用动作。钱仍在同一用户、同一币种的格子里:只是从「还能用」变成「已被挂单占用」——不是转到卖方或其它账户。买卖双方之间的资产交换发生在后续成交,不在本步骤。
在整条交易链上的位置:见撮合主流程 · 冻结建单。本节只写格子怎么被改。
调用前要满足什么
- Table 里已有
(userId, coinId)格子(通常入口已load;本步骤不做「从库新建钱包」)。 - 调用方已算好:冻哪个币、冻多少(买冻计价币金额,卖冻基础币数量;引擎内为定点整数)。
- 事件处在撮合处理链上(与进簿同链串行),避免旁路改余额。
逐步怎么做
按键取钱包
get(userId, coinId)。格子不存在 → 失败(无资产可改),整笔下单终止。在钱包对象上做可用 → 冻结
同一UserBBCoinWallet内顺序执行:- 先按数量减少可用(整数部分 + 小数部分一并处理;可用不足则整步失败,冻结不变)。
- 再增加冻结同额。
任一步失败 → 本步骤失败,不生成成功流水;调用方不得继续写委托热数据、不得进簿。
刷新修改时间
成功后更新updateTime;可用/冻结加减成功时version递增。记下流水意向
生成一条撮合侧账务流水意向,典型变动口径为:可用变动为负、冻结变动为正(同额),并带上业务类型(委托占用一类)与业务单号(如委托号)。
流水意向随后由处理链写入 Redis 缓冲,异步落库;流水本身不常驻 Table。
成功 / 失败之后
| 结果 | 内存钱包 | 调用方通常接着做什么 |
|---|---|---|
| 成功 | 可用已减、冻结已加 | 写 Redis 本笔增量、写委托热数据,再进入订单簿 |
| 失败(无格子 / 可用不足等) | 应保持未成功占用 | 整笔下单失败;不建热委托、不进盘口 |
对称操作是 冻结 → 可用(撤单退回剩余占用):先减冻结、再加可用,同样只动同一格子。成交路径则会解冻占用并在买卖双方之间划转,属于另一类增量,不在本节展开。
如何保证一致性
这里说的一致,主要指:同一用户币种上的可用/冻结,与未完结委托占用、成交结果对得上;以及进程崩溃后能靠 Redis / 库把权威态找回来。
权威在内存,改账跟进簿同链
下单先改钱包再进订单簿;成交、撤单也在同一条撮合处理链上改剩余量与冻结。同一交易对事件串行,不会出现「簿上还有单、冻结已被旁路解掉」或「已成交却未解冻」这类交叉写。先校验再加减,失败不进簿
可用不够不冻、冻结不够不成交;加减失败则本事件失败(同步调用方拿到错误),不会带着半成功余额去挂单。多步成交改账内部带回滚补偿路径,避免半步成功留下脏数。外部资金变动也进撮合改账通道
充提等不直改 MySQL 当撮合真相,而是变成撮合侧的改账事件,与交易事件共用处理链,从而与挂单占用抢同一份可用/冻结。路径见充提改账协同。装入只补洞,不覆盖热路径正在改的权威
已在内存的格子优先用内存;回源只发生在格子缺失时。版本号随每次加减递增,便于旁路识别变更次序。持久化是增量旁路,不是第二权威
Redis / MySQL 承接快照与流水,供恢复与对账;运行中以内存为准。刷库积压或恢复顺序异常时,以撮合未完结委托 + Redis 缓冲重放装入为准,而不是用过期页面余额倒推。
委托预检与真实冻结之间仍可能有时间差(预检够、稍后受理时不够)——这是刻意的:预检不占坑,最终以受理瞬间串行改账结果为准。