Repo .git was damaged (refs/ missing, index truncated, object store near-empty). Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak, then full worktree committed as a rollback baseline.
225 lines
11 KiB
SQL
225 lines
11 KiB
SQL
-- ============================================================================
|
||
-- TamaBox「加载更多重复加载」根因排查 SQL(PostgreSQL)
|
||
-- 目标箱: aoi(/api/v1/user/aoi/questions,/_/aoi/3105 底部历史列表同款数据)
|
||
-- 依据: internal/db/questions.go GetByUserID (PinFirst) + route/question/page.go ListAPI
|
||
-- ListAPI 等价条件: user_id=aoi箱 AND answer<>'' AND is_private=false AND deleted_at IS NULL
|
||
-- 排序: is_pinned DESC, created_at DESC, id DESC;每页 20 条
|
||
-- 游标: "<isPinned>.<createdAtUnixNano>.<id>"(末条编码,下一页取严格更小的行)
|
||
-- 兼容: psql / pgAdmin / DBeaver(避免 psql 专属元命令;NOTICE 各客户端均可看到)
|
||
-- 若提示语法错误 -> 说明当前连接不是 PostgreSQL,请改连 PG 实例
|
||
-- 使用: 按 PART 顺序整段复制执行,把「结果 + NOTICE 消息」原样粘贴回来
|
||
-- ============================================================================
|
||
|
||
-- ============================================================================
|
||
-- PART A 环境 / 表结构 / 集合核对
|
||
-- 目的: 核对列类型与精度、索引,确认下方 SQL 筛选后条数与线上 API 总数一致
|
||
-- (线上 page_size=99 曾返回 38 条 -> 这里 answered_public_all 应=38,
|
||
-- 若不一致说明还有隐藏过滤或连接的不是同一库)
|
||
-- ============================================================================
|
||
|
||
SELECT 'PART A: 环境与结构核对' AS step;
|
||
|
||
SELECT current_setting('server_version') AS server_version,
|
||
current_setting('TimeZone') AS session_tz,
|
||
version() AS full_version;
|
||
|
||
-- 1) questions 关键列定义(看 created_at 是 timestamptz 还是 timestamp、精度几位)
|
||
SELECT column_name, data_type, datetime_precision, is_nullable, column_default
|
||
FROM information_schema.columns
|
||
WHERE table_name = 'questions'
|
||
AND column_name IN ('id','created_at','updated_at','deleted_at',
|
||
'user_id','answer','is_private','is_pinned','is_read')
|
||
ORDER BY ordinal_position;
|
||
|
||
-- 2) user_id 相关索引(若只有单列 user_id 索引即可;看是否缺复合索引影响性能,不影响正确性)
|
||
SELECT indexname, indexdef
|
||
FROM pg_indexes
|
||
WHERE tablename = 'questions' AND indexdef ILIKE '%user_id%';
|
||
|
||
-- 3) 目标集合核对:应=线上 API 总数(曾为 38)
|
||
SELECT u.id AS aoi_user_id,
|
||
u.domain AS domain,
|
||
count(*) FILTER (WHERE q.answer <> '' AND q.is_private = false)
|
||
AS answered_public_all,
|
||
count(*) FILTER (WHERE q.answer <> '' AND q.is_private = false AND q.is_pinned)
|
||
AS answered_public_pinned,
|
||
count(*) AS all_rows_for_user
|
||
FROM users u
|
||
LEFT JOIN questions q ON q.user_id = u.id AND q.deleted_at IS NULL
|
||
WHERE u.domain = 'aoi'
|
||
GROUP BY u.id, u.domain;
|
||
|
||
-- 4) 该集合最新 5 条:把 created_at 按 UTC 与 UnixNano 打出来,
|
||
-- 便于和线上 API JSON / next_cursor(形如 0.1788566476678885000.3089)逐位目视比对
|
||
SELECT q.id,
|
||
q.is_pinned,
|
||
q.created_at,
|
||
to_char(q.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US') AS created_at_utc_text,
|
||
(extract(epoch FROM q.created_at) * 1000000000)::numeric(30,0) AS created_at_unix_nano
|
||
FROM questions q
|
||
JOIN users u ON u.id = q.user_id AND u.domain = 'aoi'
|
||
WHERE q.answer <> '' AND q.is_private = false AND q.deleted_at IS NULL
|
||
ORDER BY q.is_pinned DESC, q.created_at DESC, q.id DESC
|
||
LIMIT 5;
|
||
|
||
-- 5) 是否存在 created_at 完全相同的行(同一时间多行时若旧版漏 id tiebreaker 会翻页跳行/重复)
|
||
SELECT q.created_at, count(*) AS n
|
||
FROM questions q
|
||
JOIN users u ON u.id = q.user_id AND u.domain = 'aoi'
|
||
WHERE q.answer <> '' AND q.is_private = false AND q.deleted_at IS NULL
|
||
GROUP BY q.created_at
|
||
HAVING count(*) > 1
|
||
ORDER BY n DESC
|
||
LIMIT 10;
|
||
|
||
|
||
-- ============================================================================
|
||
-- PART B 用纯 SQL 完整复刻 ListAPI 的复合键集翻页(核心复现)
|
||
-- 目的: 逐页打印每页的 id 序列。若 DB 层正常: 第1页 20 条、第2页 18 条后结束,
|
||
-- 且无任何一行与上一页重复(总 38 条不重不漏)。
|
||
-- 若 DB 层有 bug: 会出现重复页 / 跳页 / 死循环(脚本会检测并提前退出)。
|
||
-- 判读:
|
||
-- [DB 层正常] 第1页id集合与线上首页一致,第2页正常翻出剩余 -> 问题在应用层
|
||
-- (部署版本旧 / GORM 参数转换),与 DB 无关,见 PART C 指纹进一步定位。
|
||
-- [DB 层异常] 某页与上一页完全重复 / 提前空页 / 顺序错乱 -> 问题在数据或库配置,
|
||
-- 把输出贴回即可看到错在哪一页。
|
||
-- ============================================================================
|
||
|
||
SELECT 'PART B: keyset 翻页复现' AS step;
|
||
|
||
DROP TABLE IF EXISTS pg_temp.diag_pages;
|
||
|
||
DO $$
|
||
DECLARE
|
||
v_uid bigint;
|
||
v_page int := 0;
|
||
v_rows int;
|
||
v_ids text;
|
||
v_prev_ids text := '';
|
||
v_pin boolean; -- 上一页末条 is_pinned(游标锚点)
|
||
v_ts timestamptz; -- 上一页末条 created_at(游标锚点)
|
||
v_id bigint; -- 上一页末条 id(游标锚点)
|
||
v_last_pin boolean;
|
||
v_last_ts timestamptz;
|
||
v_last_id bigint;
|
||
v_note text;
|
||
r record;
|
||
BEGIN
|
||
SELECT id INTO v_uid FROM users WHERE domain = 'aoi' LIMIT 1;
|
||
IF v_uid IS NULL THEN
|
||
RAISE EXCEPTION 'users 表中不存在 domain=aoi 的用户,请把脚本里的 aoi 换成你的箱 domain';
|
||
END IF;
|
||
|
||
CREATE TEMP TABLE diag_pages (page_no int, n_rows int, ids text);
|
||
|
||
LOOP
|
||
v_page := v_page + 1;
|
||
v_ids := '';
|
||
v_rows := 0;
|
||
v_last_pin := NULL; v_last_ts := NULL; v_last_id := NULL;
|
||
|
||
FOR r IN
|
||
SELECT q.id, q.is_pinned, q.created_at
|
||
FROM questions q
|
||
WHERE q.user_id = v_uid
|
||
AND q.answer <> ''
|
||
AND q.is_private = false
|
||
AND q.deleted_at IS NULL
|
||
AND (
|
||
CASE
|
||
WHEN v_page = 1 THEN TRUE -- 首页无游标
|
||
WHEN v_pin THEN -- 游标位于置顶段: 置顶段更小 + 全部非置顶
|
||
(q.is_pinned = true AND (q.created_at < v_ts OR (q.created_at = v_ts AND q.id < v_id)))
|
||
OR q.is_pinned = false
|
||
ELSE -- 游标位于非置顶段: 非置顶段内更小
|
||
q.is_pinned = false AND (q.created_at < v_ts OR (q.created_at = v_ts AND q.id < v_id))
|
||
END
|
||
)
|
||
ORDER BY q.is_pinned DESC, q.created_at DESC, q.id DESC
|
||
LIMIT 20
|
||
LOOP
|
||
v_rows := v_rows + 1;
|
||
v_ids := v_ids || r.id::text || ',';
|
||
v_last_pin := r.is_pinned;
|
||
v_last_ts := r.created_at;
|
||
v_last_id := r.id;
|
||
END LOOP;
|
||
|
||
IF v_rows = 0 THEN
|
||
RAISE NOTICE '第 % 页: 0 行 -> 翻页正常结束(空页,等价 has_more=false)', v_page;
|
||
EXIT;
|
||
END IF;
|
||
|
||
v_ids := left(v_ids, -1);
|
||
INSERT INTO diag_pages VALUES (v_page, v_rows, v_ids);
|
||
RAISE NOTICE '第 % 页: % 行 ids=[%]', v_page, v_rows, v_ids;
|
||
|
||
-- 死循环防护: 本页与上一页逐字相同(key set 未前进)则提前退出并标记异常
|
||
IF v_ids = v_prev_ids THEN
|
||
RAISE NOTICE '!!! 异常: 第 % 页与第 % 页内容完全重复,游标未前进(复现线上 bug 的症状)', v_page, v_page - 1;
|
||
EXIT;
|
||
END IF;
|
||
v_prev_ids := v_ids;
|
||
|
||
-- 末页判定: 本页不足 20 行 -> 已无下一页(等价 ListAPI 的 has_more=false)
|
||
IF v_rows < 20 THEN
|
||
RAISE NOTICE '本页不足 20 行 -> 已到末页(等价 has_more=false),翻页完成';
|
||
EXIT;
|
||
END IF;
|
||
|
||
IF v_page >= 30 THEN
|
||
RAISE NOTICE '!!! 异常: 已达 30 页上限仍未收敛,疑似死循环';
|
||
EXIT;
|
||
END IF;
|
||
|
||
-- 前进游标锚点 = 本页末条
|
||
v_pin := v_last_pin;
|
||
v_ts := v_last_ts;
|
||
v_id := v_last_id;
|
||
END LOOP;
|
||
|
||
RAISE NOTICE '--- 汇总: 共 % 页 ---', (SELECT count(*) FROM diag_pages);
|
||
END $$;
|
||
|
||
-- 同一会话内再以表格形式输出一次(若客户端 NOTICE 显示不全,看这个即可)
|
||
SELECT page_no, n_rows, ids
|
||
FROM pg_temp.diag_pages
|
||
ORDER BY page_no;
|
||
|
||
-- 重复检测: 跨页是否存在重复 id(正常应为 0 行,无重复时只显示上一条提示)
|
||
SELECT '跨页重复检测: 若下方无第二张表结果则无重复' AS check_msg;
|
||
SELECT unnest(string_to_array(ids, ','))::bigint AS duplicated_question_id
|
||
FROM pg_temp.diag_pages
|
||
GROUP BY 1
|
||
HAVING count(*) > 1;
|
||
|
||
|
||
-- ============================================================================
|
||
-- PART C created_at 精度指纹核对(可选,用于区分「部署版本旧」vs「DB 环境问题」)
|
||
-- 目的: 若线上 next_cursor 里的 UnixNano(如 0.1788566476678885000.3089 中的
|
||
-- 1788566476678885000)能在下方查询里精确命中对应 id 的 created_at_unix_nano,
|
||
-- 说明 API 下发的游标与 DB 行完全对应 -> 问题几乎锁定在部署版本/API 层。
|
||
-- 若命中不了 / 差着末几位 -> 说明 DB 存值与线上 JSON 时间对不上,是库环境问题。
|
||
-- 用法: 把下方 id 列表替换成你想核对的线上行(默认最新几条 + 3089/3105)。
|
||
-- ============================================================================
|
||
|
||
SELECT 'PART C: created_at 精度指纹' AS step;
|
||
|
||
SELECT q.id,
|
||
q.is_pinned,
|
||
to_char(q.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US') AS created_at_utc_text,
|
||
(extract(epoch FROM q.created_at) * 1000000000)::numeric(30,0) AS created_at_unix_nano,
|
||
(extract(epoch FROM q.created_at) * 1000000000)::numeric(30,0) % 1000 AS nano_tail3 -- 应为0: 微秒精度*1000
|
||
FROM questions q
|
||
WHERE q.id IN (3109, 3105, 3089, 3088) -- 替换成线上首页前几条 id
|
||
ORDER BY q.id DESC;
|
||
|
||
-- ============================================================================
|
||
-- 判读指引(复制上面的 PART A/B 即可,C 按需):
|
||
-- 1) PART A.3 的 answered_public_all 应与线上 API 总数一致(曾为 38)
|
||
-- 2) PART B 第1页应=线上首页20条;第2页应翻出剩余且与第1页无重复 ->
|
||
-- DB 层 keyset 自洽 = 问题在应用层(部署版本/参数转换)
|
||
-- 3) 若 PART B 出现「某页与上一页完全相同」= 直接在 DB 层复现了 bug
|
||
-- 4) PART C 的 unix_nano 与线上 next_cursor 中间段逐位一致 = 版本/API 层问题
|
||
-- ============================================================================
|