Files
tamabox/outputs/pg-cursor-pagination-diagnose.sql
T
tamakyi e3e59e7c69 chore: baseline snapshot before moderation-strictness feature
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.
2026-09-10 10:56:27 +08:00

225 lines
11 KiB
SQL
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.
-- ============================================================================
-- 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 层问题
-- ============================================================================