rwadurian/docs/issues/mining-contribution-sync-so...

257 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 修复方案评估:挖矿分配并发覆盖贡献值
## 概览
需要解决三个问题:
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() 本身就不修改 totalContributionsave() 不写它是逻辑自洽的
- 可通过单元测试充分验证
---
## 方案 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 同步:
### 改动点 1contribution-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(),
},
```
### 改动点 2mining-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. **数据修复** — 部署后触发全量同步
三步均可在同一次部署中完成。