-- ============================================================================ -- 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 条 -- 游标: ".."(末条编码,下一页取严格更小的行) -- 兼容: 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 层问题 -- ============================================================================