257 lines
7.9 KiB
Markdown
257 lines
7.9 KiB
Markdown
# 修复方案评估:挖矿分配并发覆盖贡献值
|
||
|
||
## 概览
|
||
|
||
需要解决三个问题:
|
||
1. **主因修复** — 消除挖矿分配对 totalContribution 的并发覆盖
|
||
2. **安全网修复** — 恢复 DailySnapshot 全量同步功能
|
||
3. **数据修复** — 将 12 个受影响用户的 mining 数据校正
|
||
|
||
---
|
||
|
||
## 方案对比
|
||
|
||
| 方案 | 改动范围 | 风险 | 可靠性 | 实施难度 |
|
||
|------|---------|------|-------|---------|
|
||
| A. 挖矿分配只更新挖矿字段 | 1个文件 | **低** | **高** | **低** |
|
||
| B. Repository 分方法 | 2个文件 | **低** | **高** | 低 |
|
||
| C. 数据库层面原子更新 | 1个文件 | 中 | 高 | 中 |
|
||
| D. 乐观锁 version 字段 | 4个文件+迁移 | 中高 | 高 | 高 |
|
||
| E. 贡献值独立表 | 多文件+迁移 | **高** | 高 | **高** |
|
||
|
||
---
|
||
|
||
## 方案 A:挖矿分配路径只更新挖矿字段(推荐)
|
||
|
||
**思路**:挖矿分配调用 save() 时,不写 totalContribution 和 lastSyncedAt。
|
||
|
||
**改动**:只改 `mining-account.repository.ts`,给 save() 加一个选项参数。
|
||
|
||
```typescript
|
||
// mining-account.repository.ts
|
||
async save(
|
||
aggregate: MiningAccountAggregate,
|
||
tx?: TransactionClient,
|
||
options?: { skipContributionUpdate?: boolean },
|
||
): Promise<void> {
|
||
const snapshot = aggregate.toSnapshot();
|
||
|
||
const updateData: any = {
|
||
totalMined: snapshot.totalMined.value,
|
||
availableBalance: snapshot.availableBalance.value,
|
||
frozenBalance: snapshot.frozenBalance.value,
|
||
};
|
||
|
||
// 仅在贡献值同步路径才写入这两个字段
|
||
if (!options?.skipContributionUpdate) {
|
||
updateData.totalContribution = snapshot.totalContribution.value;
|
||
updateData.lastSyncedAt = snapshot.lastSyncedAt;
|
||
}
|
||
|
||
await client.miningAccount.upsert({
|
||
where: { accountSequence: snapshot.accountSequence },
|
||
create: { /* 所有字段 - 新账户需要全部 */ },
|
||
update: updateData,
|
||
});
|
||
}
|
||
```
|
||
|
||
**调用方改动**:只需在 mining-distribution.service.ts 的 save 调用加一个参数:
|
||
```typescript
|
||
await this.miningAccountRepository.save(account, tx, { skipContributionUpdate: true });
|
||
```
|
||
|
||
**优点**:
|
||
- 改动极小(1个文件加参数,1个调用方加选项)
|
||
- 向后兼容 — 不传 options 的调用方行为不变
|
||
- 完全消除竞争条件 — 挖矿分配路径不再触碰 totalContribution
|
||
- 不需要数据库迁移
|
||
|
||
**缺点**:
|
||
- 语义上 save() 变得不纯粹(不总是保存所有字段)
|
||
- 新增调用方需要知道传不传 options
|
||
|
||
**风险:低**
|
||
- 逻辑简单直观,不引入新概念
|
||
- mine() 本身就不修改 totalContribution,save() 不写它是逻辑自洽的
|
||
- 可通过单元测试充分验证
|
||
|
||
---
|
||
|
||
## 方案 B:拆分 Repository 方法
|
||
|
||
**思路**:创建专用的 `saveMiningResult()` 方法,只更新挖矿相关字段。
|
||
|
||
```typescript
|
||
// mining-account.repository.ts 新增方法
|
||
async saveMiningResult(aggregate: MiningAccountAggregate, tx?: TransactionClient): Promise<void> {
|
||
const snapshot = aggregate.toSnapshot();
|
||
const transactions = aggregate.pendingTransactions;
|
||
|
||
const executeInTx = async (client: TransactionClient) => {
|
||
await client.miningAccount.update({
|
||
where: { accountSequence: snapshot.accountSequence },
|
||
data: {
|
||
totalMined: snapshot.totalMined.value,
|
||
availableBalance: snapshot.availableBalance.value,
|
||
frozenBalance: snapshot.frozenBalance.value,
|
||
// 不写 totalContribution 和 lastSyncedAt
|
||
},
|
||
});
|
||
|
||
if (transactions.length > 0) {
|
||
await client.miningTransaction.createMany({ /* 同原逻辑 */ });
|
||
}
|
||
};
|
||
// ...
|
||
}
|
||
```
|
||
|
||
**优点**:
|
||
- 方法职责清晰 — save() 保存全量,saveMiningResult() 只保存挖矿结果
|
||
- 不修改现有 save() 签名
|
||
|
||
**缺点**:
|
||
- 新增一个方法,代码量稍多
|
||
- 需要在 mining-distribution.service.ts 改调用方
|
||
- 用 update 而非 upsert,如果账户不存在会报错(但实际场景不会,因为有贡献值才会参与挖矿)
|
||
|
||
**风险:低**
|
||
- 与方案 A 本质相同,只是组织方式不同
|
||
|
||
---
|
||
|
||
## 方案 C:数据库层面原子 increment
|
||
|
||
**思路**:挖矿分配不加载整个 aggregate,直接用 Prisma 的 increment 操作。
|
||
|
||
```typescript
|
||
// mining-distribution.service.ts 中
|
||
await tx.miningAccount.update({
|
||
where: { accountSequence: account.accountSequence },
|
||
data: {
|
||
totalMined: { increment: reward.value },
|
||
availableBalance: { increment: reward.value },
|
||
// 不写 totalContribution
|
||
},
|
||
});
|
||
```
|
||
|
||
**优点**:
|
||
- 完全无竞争 — 数据库层面原子操作
|
||
- 不需要加载 aggregate
|
||
|
||
**缺点**:
|
||
- 绕过了 DDD aggregate 模式 — mine() 方法和 pendingTransactions 不再被使用
|
||
- 需要单独处理 MiningTransaction 的写入(不能从 aggregate 获取 balanceBefore/After)
|
||
- 改动较大,需要重构挖矿分配的核心逻辑
|
||
- 丢失了 aggregate 内的业务校验
|
||
|
||
**风险:中**
|
||
- 需要重构核心挖矿循环
|
||
- 需要额外逻辑计算 balanceBefore/After 用于交易流水
|
||
- 不再经过 aggregate 校验
|
||
|
||
---
|
||
|
||
## 方案 D:乐观锁 (version 字段)
|
||
|
||
**思路**:给 mining_accounts 加 version 字段,save 时检查版本号。
|
||
|
||
```prisma
|
||
model MiningAccount {
|
||
// ... existing fields
|
||
version Int @default(0)
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
await client.miningAccount.update({
|
||
where: { accountSequence: snapshot.accountSequence, version: snapshot.version },
|
||
data: { ...fields, version: { increment: 1 } },
|
||
});
|
||
// 如果 version 不匹配,抛出异常,调用方重试
|
||
```
|
||
|
||
**优点**:
|
||
- 标准并发控制模式
|
||
- 适用于所有写入路径
|
||
|
||
**缺点**:
|
||
- 需要数据库迁移
|
||
- 需要在所有写入路径添加重试逻辑
|
||
- 挖矿分配每秒运行,重试频率可能很高
|
||
- 增加系统复杂度
|
||
|
||
**风险:中高**
|
||
- 数据库迁移需要在生产环境执行
|
||
- 重试逻辑引入额外复杂度
|
||
- 高频写入场景下乐观锁冲突率高,可能影响性能
|
||
|
||
---
|
||
|
||
## 方案 E:贡献值独立表
|
||
|
||
**思路**:将 totalContribution 从 mining_accounts 移出,或在查询时 JOIN contribution 表。
|
||
|
||
**不推荐** — 改动过大,涉及 schema 迁移、多处查询改动、跨服务依赖增加。
|
||
|
||
---
|
||
|
||
## DailySnapshot 安全网修复(独立于主因修复)
|
||
|
||
无论选择哪个主因方案,都需要修复 DailySnapshot 同步:
|
||
|
||
### 改动点 1:contribution-service 事件 payload 补全
|
||
```typescript
|
||
// snapshot.service.ts
|
||
payload: {
|
||
snapshotDate: dateStr,
|
||
snapshotId: snapshot.id?.toString(), // 补充
|
||
totalContribution: networkTotalContribution.toString(), // 字段名对齐
|
||
networkTotalContribution: networkTotalContribution.toString(),
|
||
activeAccounts: activeAccounts.length,
|
||
totalAccounts,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
```
|
||
|
||
### 改动点 2:mining-service 解析适配
|
||
```typescript
|
||
// contribution-sync.service.ts handleDailySnapshotCreated
|
||
const data = {
|
||
snapshotId: data.snapshotId || 'unknown',
|
||
snapshotDate: data.snapshotDate,
|
||
totalContribution: data.totalContribution || data.networkTotalContribution || '0',
|
||
activeAccounts: data.activeAccounts || data.accountCount || 0,
|
||
};
|
||
```
|
||
|
||
### 改动点 3:验证 API 端点
|
||
确认 `GET /api/v1/snapshots/{date}/ratios` 的返回格式与 `fetchContributionRatios()` 的解析匹配。
|
||
|
||
---
|
||
|
||
## 数据修复方案
|
||
|
||
主因修复部署后,需要一次性修正 12 个受影响用户的数据:
|
||
|
||
### 方式 1:触发全量 DailySnapshot 同步(推荐)
|
||
修好 DailySnapshot 后,手动触发一次全量同步,自动从 contribution-service 拉取所有正确值。
|
||
|
||
### 方式 2:手动 API 调用
|
||
对每个受影响用户,调用 contribution-service 获取 effectiveContribution,更新到 mining_accounts。
|
||
|
||
### 方式 3:直接 SQL 更新
|
||
从 contribution_accounts 表查出正确值,更新 mining_accounts(需要用户明确授权)。
|
||
|
||
---
|
||
|
||
## 推荐实施顺序
|
||
|
||
1. **方案 A**(主因修复)— 改动最小、风险最低、逻辑自洽
|
||
2. **DailySnapshot 修复** — 恢复安全网
|
||
3. **数据修复** — 部署后触发全量同步
|
||
|
||
三步均可在同一次部署中完成。
|