Files
lionwebsite-backend/docs/GALLERY_INDEX_REWRITE.md
T

151 lines
7.4 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.
# 图片索引缓存(GalleryIndex)— 重写模块说明
## 问题
在线阅读的索引是「gid → 每一页 imgkey」,存在 `cache.db` 的两张表里:
| 表 | 列 | 含义 |
|---|---|---|
| `gidToKey` | `gid`, `key`, `pages` | 画廊头部:gid、画廊 key、总页数 |
| `ImageKeyCache` | `gid`, `imgkey`, `page` | 每页的 imgkey |
旧实现(`GalleryManageService.cacheImagesKey`)依次做四件事:查缓存、判断是否需要重建、
外呼抓 mpv 页、逐页 `INSERT`。由此产生的实际问题:
1. **没有唯一约束**。实测生产缓存库有 4 组重复 gid、97 组重复 `(gid,page)`。
2. **整组替换不是原子的**。先写头部再逐页写,中途失败会留下「头部命中、页 key 缺失」的
半截索引;后续请求直接判定为已缓存,永远取不到图。
3. **用行数判断完整性**。`count(*) == pages` 无法发现「页码 1、3 而 pages=2」这种跳号。
4. **没有并发合并**。同一本画廊被并发打开时,两个构建者会互相删除对方刚写入的行。
5. **逐页写入放大写事务**,抓取与写入之间没有事务边界。
## 交付物与职责
| 文件 | 职责 |
|---|---|
| `Service/GalleryIndexService.java` | 索引的唯一读写入口:同 gid 合并、抓取在事务外、校验后整组短事务替换 |
| `Exception/GalleryIndexException.java` | 分类失败原因:INVALID_LINK / UNAVAILABLE / MALFORMED / TRANSIENT |
| `Configuration/GalleryIndexConfiguration.java` | 缓存库的事务管理器与读写模板(独立文件,未改 SqlConfiguration) |
| `Dao/cache/ImageCacheMapper.java` | 新增页码查询、脏行计数、批量写入(原有方法全部保留) |
| `Maintenance/GalleryIndexMaintenance.java` | 离线体检与修复工具(只删不健康 gid 的整组索引) |
| `scripts/migrate_image_index_cache.{sh,sql}` | 迁移:默认 dry-run、备份、清理、建唯一索引 |
| `Service/GalleryIndexServiceTest.java` | 服务编排的行为约束(无 Spring、无网络) |
| `Maintenance/GalleryIndexMaintenanceTest.java` | 工具在真实临时 SQLite 上的判定与删除范围 |
| `Dao/cache/ImageCacheMapperSqlTest.java` | 注解 SQL 在真实 MyBatis + SQLite 上的验证 |
## 接口
```java
GidToKey ensureIndex(String url) throws GalleryIndexException;
```
返回该画廊的 `gid` / `key` / `pages`。三条不变量:
- **抓取在事务外**:`upstream.fetchPageKeys(url)` 先于任何写事务执行;写事务只在
`publish(...)` 里开启,删除旧索引与批量写入在同一个事务内整体生效。
- **校验先于写入**:行必须满足 gid 与请求一致、页码恰好为 1..N、不重复、imgkey 非空,
否则抛 MALFORMED,此时尚未开启写事务,库中旧索引原样保留。
- **读在快照内**:`lookup` 在同一个读事务里读头部与页码,避免读到整组替换的中间状态。
失败原因:
| Reason | 触发条件 | isPermanent() | 建议处置 |
|---|---|---|---|
| INVALID_LINK | 链接里取不到 gid 或 key | true | 直接回「链接错误」 |
| UNAVAILABLE | 上游返回空页列表(下架/删除) | true | 回「该图片已下架或已被删除」 |
| MALFORMED | 上游页 key 不自洽 | false | 可重试,回「网络波动或其他异常」 |
| TRANSIENT | 上游异常或写库失败 | false | 可重试,回「网络波动或其他异常」 |
## 接入方式(由主模型完成 GalleryManageService 侧改动)
构造函数注入:
```java
final GalleryIndexService galleryIndexService;
```
`cacheImagesKey` 改为:
```java
public String cacheImagesKey(String url) {
Response response = Response.generateResponse();
try {
GidToKey gidToKey = galleryIndexService.ensureIndex(url);
return response.success(objectMapper.valueToTree(gidToKey)).toJSONString();
} catch (GalleryIndexException e) {
response.failure(e.getReason() == GalleryIndexException.Reason.INVALID_LINK
? "链接错误"
: e.isPermanent() ? "该图片已下架或已被删除" : "网络波动或其他异常");
return response.toJSONString();
}
}
```
Spring 装配无需额外配置:`GalleryIndexConfiguration` 已把 `GalleryIndexService` 注册为 bean,
依赖 `ImageCacheMapper`(cache 数据源)与两个 TransactionTemplate。原来的
`isIndexComplete` / `rollbackImageIndex` 私有方法与 `ImageCacheMapper` 的
`countImageKeyCacheByGid` / `delete*ByGid` 仍保留,供旧调用方与非索引路径使用。
读取路径 `getCachedImage` 不需要改动:它按 `(gid,page)` 查 ImageKeyCache,现在会走新加的
`ux_ImageKeyCache_gid_page` 唯一索引。
## 迁移
```bash
# 1. dry-run(只读,不带 --apply 绝不写入)
scripts/migrate_image_index_cache.sh --db /path/to/copy.db
# 2. 先在 SQLite 备份副本上演练
sqlite3 -readonly /home/lionwebsite/cache.db ".backup '/tmp/cache-copy.db'"
# 3. 确认后执行(自动做 .backup 备份并校验)
scripts/migrate_image_index_cache.sh --db /tmp/cache-copy.db --apply
```
迁移只做两类写入:删除不健康 gid 的整组索引(重复头部、半截、跳号、空 imgkey)、
删除孤儿页 key;随后建立 `ux_gidToKey_gid` 与 `ux_ImageKeyCache_gid_page`。
若清理后仍有重复,建唯一索引会失败并整体回滚。真实的 `/home/lionwebsite/cache.db` 未被本次工作修改。
离线修复工具:
```bash
java -cp sqlite-jdbc.jar Maintenance/GalleryIndexMaintenance.java --db /tmp/cache-copy.db # dry-run
java -cp sqlite-jdbc.jar Maintenance/GalleryIndexMaintenance.java --db /tmp/cache-copy.db --apply # 执行
```
对 `/home/lionwebsite` 下的库运行 `--apply` 会被拒绝,除非显式加 `--allow-production`。
## 测试
在 `/tmp` 的独立源码副本中运行(未复制任何生产 runtime config、未改生产库):
```
mvn -o -Djacoco.skip=true \
-Dtest='GalleryIndexServiceTest,GalleryIndexMaintenanceTest,ImageCacheMapperSqlTest' \
-DfailIfNoSpecifiedTests=false test
Tests run: 9, Failures: 0, Errors: 0 -- GalleryIndexMaintenanceTest
Tests run: 21, Failures: 0, Errors: 0 -- GalleryIndexServiceTest
Tests run: 7, Failures: 0, Errors: 0 -- ImageCacheMapperSqlTest
Tests run: 37, Failures: 0, Errors: 0
BUILD SUCCESS
```
覆盖的关键场景:
- 同 gid 24 线程并发只抓取一次、只发布一次;失败结果也被等待者共享;失败后可重试。
- 抓取发生在事务之外;所有删除与写入都在事务内;整组替换只有一个写事务。
- 行数相同但页码跳号、空 imgkey、空 key、pages<=0 均判定为不完整并重建。
- 写入失败整体回滚,旧索引与失败前逐字段一致。
- 上游畸形数据(gid 不符、页码重复/跳号/越界、imgkey 为空)在开启写事务之前被拒绝。
- 大规模索引按 BATCH_SIZE 分片写入;重复发布替换旧行而非累积。
- 注解 SQL 在真实 SQLite 上验证,含唯一约束确实拒绝重复写入。
## 已知边界
- 迁移后 gidToKey 433 条、ImageKeyCache 43704 条(原 443 / 43918)。
被删除的是 4 个冲突或不完整 gid 的整组索引与 214 行关联页 key,由下一次在线阅读重抓。
- 未做性能压测,不承诺加索引后的具体延迟收益,只确认查询计划已由全表扫描变为索引查找。
- lookup 的读事务只是为了拿到一致快照;实测 sqlite-jdbc 不允许在已建立的连接上设置
readOnly,因此读模板只设超时、不设只读标志。