0904
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
-- ============================================================================
|
||||
-- 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 层问题
|
||||
-- ============================================================================
|
||||
Reference in New Issue
Block a user