「让 AI 帮我写一个功能」,说起来轻松,真做起来会发现难点根本不在写代码——而在想清楚要什么、把方案定下来、把改动切成能验证的小步、以及上线后怎么回滚。这篇用一个真实复杂度的需求把整条流程走完。
选的需求是:给 IM 系统加「群聊未读数与已读回执」。它足够难——涉及数据模型设计、历史数据迁移、多端同步、幂等上报、超大群下的容量取舍,还要和撤回、乱序、多设备这些边界打架。全程我大概花了两个工作日,其中敲键盘的时间不到三小时。
一、先写规格:把「想要」变成「可验收」
我给自己定了一条硬规矩:开对话之前,先写一页规格。规格没写完就去找 AI,等于让它替你猜需求,而它猜出来的东西通常很漂亮但不对。
这次写出来的规格(原文,含那条最重要的"不做"):
# 需求:群聊未读数与已读回执
## 背景
用户反馈:进群之后不知道有多少条没看;发出去的消息不知道对方看没看。
现状:会话表只有 last_seq,没有任何"读到哪"的记录。
## 输入 / 输出
- 输入:客户端上报「某会话已读到 seq=N」;查询「我的会话列表里每个会话有多少未读」
- 输出:会话列表接口带 unread 字段;群聊消息支持"已读 3 人"(只显示人数,不做已读名单)
## 边界条件
- 同一 seq 重复上报:幂等,不报错
- 乱序上报(先报 100 再报 80):只前进不后退(单调取大)
- 撤回的消息:不计入未读(撤回后该 seq 变为 countable=0)
- 多设备:同一用户两台设备共享读位点;在手机上读了,电脑端未读数也要降下来
- 超大群(10 万人):不能给每个成员存计数器
## 验收标准
1. 上报已读后,重新拉会话列表,unread 立刻变 0
2. 重复上报 10 次,结果与上报 1 次相同(幂等)
3. 先报 100 再报 80,位点仍为 100
4. 撤回一条未读消息后,unread 减 1
5. 10 万成员群,任一群成员拉会话列表 P99 < 50ms
## 明确不做的事
- 不做"已读名单"(谁读了谁没读),只做人数
- 不做逐消息回执(只做会话级位点)
- 不做跨会话的全局未读排序优化
- 不做未读数的推送(客户端自己拉)
最后那段"明确不做"是省钱的关键。第一次没写,AI 热心地把"已读名单表"也设计进去了——多一张表、多一套写放大,而这功能我们根本不需要。
二、先要方案,不要代码
规格丢给它,第一句话是:
先别写代码。给我两个以上方案,说明各自的读写放大、存储成本、以及会在哪些边界上出问题。
它给了两个,正好是我想要的那种对比:
| 方案 | 做法 | 写放大 | 读放大 | 致命问题 |
|---|---|---|---|---|
| A. 每成员计数器 | unread_count 每来一条消息给所有成员 +1 | 每条消息 × 成员数(万人群 = 1 万次写) | O(1) | 计数一旦算错永远无法自愈;撤回、多端同步都要额外补偿 |
| B. 位点差值 | 会话存 last_seq,成员存 last_read_seq,未读 = 差值 | 每条消息只有 1 次写(会话维度) | O(1) 计算,但需按会话批量查 | 需要维护成员读位点表;撤回消息需标记 countable=0 |
选 B。理由不是"看起来更优雅",而是 A 有一个不可接受的特性:计数器错了没法修。IM 里消息会撤回、会重发、会有多端并发上报,A 方案每次异常都要写补偿逻辑,而 B 方案的位点天然幂等——错了重新算一遍就对。
这一步只花了五分钟,但它决定了后面所有代码的形态。如果我先让它写代码,大概率会拿到 A 方案,然后在第四天发现计数器对不上。
三、数据模型与迁移:先让旧数据能活
方案定了,下一步是让它给出 DDL,并专门问一句"迁移怎么办":
现有 conversation 表已经有数据(约 12 万行),成员表约 340 万行。请给出:新表 DDL、索引、以及不锁表的迁移步骤,并说明回滚方式。
它给的东西我改了三处:
-- 1) 会话表:记录会话当前最大 seq(用于算未读)与撤回后的可计数 seq
ALTER TABLE conversation
ADD COLUMN last_seq BIGINT NOT NULL DEFAULT 0 COMMENT '会话内最新 seq',
ADD COLUMN last_countable_seq BIGINT NOT NULL DEFAULT 0 COMMENT '剔除撤回后的最新可计数 seq';
-- 大表变更必须显式指定在线 DDL,具体语法与版本有关,以官方文档为准
-- ALTER TABLE conversation ADD COLUMN ..., ALGORITHM=INPLACE, LOCK=NONE;
-- 2) 成员读位点:一个用户在一个会话里读到哪
CREATE TABLE conversation_read (
uid BIGINT UNSIGNED NOT NULL,
conv_id BIGINT UNSIGNED NOT NULL,
last_read_seq BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (uid, conv_id), -- 查询维度:按用户拉会话列表
KEY idx_conv (conv_id) -- 次要维度:群内已读人数统计
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
我改的三处:
- 加了
last_countable_seq:撤回消息不计入未读,如果只存last_seq,撤回后要么改last_seq(会破坏消息顺序语义),要么客户端自己过滤(多端各写一遍)。多一个字段,服务端一次算清。 - 索引顺序:它原本给的是
PRIMARY KEY (conv_id, uid)。但我们的主查询是「按 uid 拉会话列表」,主键必须符合这个维度。我提醒它:"查询模式是用户维度,不是会话维度",它立刻认同并调整。 - 迁移分四步:它一开始说"直接 ALTER 加列 + 写个脚本刷历史位点"。我要求它明确到「可中断、可回滚」的四步:
① 加列(默认 0,老代码不受影响)
② 双写:新消息写入时同时更新 last_seq / last_countable_seq
③ 回填:后台任务按 conv_id 分批把 last_seq 刷成历史最大值(每次 1000 会话,可中断续跑)
④ 切读:会话列表接口开始使用 unread;观察一周后清理旧兜底逻辑
回滚:任何一步都能停在原地——列默认 0 不影响老逻辑;接口侧用开关切回旧实现
"迁移能不能回滚"这件事 AI 默认不考虑,必须问。它给的第一版方案是"一次性刷全表",在 12 万会话上跑起来就是一次不可中断的长事务。
四、分步实现:一次一个文件,每步都能验证
这是真正"写代码"的阶段,但我的节奏是:一次只让它改一个文件、加一个函数,改完立刻编译 + 上一步的验证方式。下面是四步的要点(提示词做了精简)。
第 1 步:上报已读接口(幂等 + 单调 + 多端)
实现
POST /conv/read,入参{conv_id, seq}。要求:① 重复上报同一 seq 幂等;② 只前进不后退(GREATEST语义);③ 必须校验调用者是会话成员;④ 更新要原子,用 Redis 或 SQL 都给出方案。
它给了 Lua 版本(生产上走 Redis + 异步落库),核心是这段:
-- KEYS[1] = read:conv:{conv_id}:uid:{uid}
-- ARGV[1] = 客户端上报的 seq
local cur = tonumber(redis.call('GET', KEYS[1]) or '0')
local want = tonumber(ARGV[1])
if want <= cur then
return cur -- 幂等 + 单调:不后退
end
redis.call('SET', KEYS[1], want)
return want
我在它第一版里改了一个地方:它把"成员校验"放在 Lua 外面,且只在接口层查了一次缓存。群里踢人之后缓存可能还没过期,于是被踢的人理论上还能上报已读。加一条约束让它改成"以 DB 为准、缓存只做加速",并接受"极端情况下多一次查库"。
第 2 步:未读数批量计算
会话列表接口要返回每个会话的 unread。现在要查 20 个会话的未读,不要写循环单查,给批量方案。
它先是写了 20 次查询,我一说"20 个会话"它就换成了:
SELECT c.conv_id,
GREATEST(c.last_countable_seq - COALESCE(r.last_read_seq, 0), 0) AS unread
FROM conversation c
LEFT JOIN conversation_read r ON r.conv_id = c.conv_id AND r.uid = ?
WHERE c.conv_id IN (?, ?, ...); -- 一次查 20 个会话
GREATEST(..., 0) 这个保护很重要——没有它,数据异常时会出现负数未读,前端显示 -3 条。这类边界 AI 会给,但不一定会主动想到,我在规格里写了"未读不能为负",它才加上。
第 3 步:多端同步
同一用户两台设备:A 设备上报已读后,B 设备的会话列表也要立刻反映。请给出同步方案,并说明为什么不该在每个设备存独立位点。
这一步它答得比我好。它指出:如果每个设备存独立位点,"多端未读数不一致"会变成常态(用户在手机读了,电脑还显示 99+,然后用户以为电脑上有新消息)。所以位点按「用户 × 会话」维度共享,设备只是上报者。代价是:用户想"在电脑上重新看到某条消息",需要的是消息跳转而不是位点回退——这是两个功能,不要混。
这段结论我直接写进了设计文档,因为它省掉了我后面一次返工。
第 4 步:群内已读人数
群聊消息下方显示"已读 3 人"。要求:只算人数、不算名单;10 万成员群单次查询 P99 < 50ms。
它给的方案是 SELECT COUNT(*) FROM conversation_read WHERE conv_id=? AND last_read_seq >= ? —— 在 10 万人群里这就是一次 10 万行的索引扫描。我让它自己想替代方案,它给了三个,我们选了「阈值降级」:群成员数超过 5000 时,只返回"已读人数 ≥ 100 时显示『99+ 人已读』",用近似的 Redis 计数器(按会话维度增量维护)代替精确 COUNT。
这不是 AI 主动提出的取舍,是我逼它给的——提示词是「如果这个查询在 10 万成员规模下跑不动,给我三个降级方案,说明各自的误差」。问法和答案一样重要。
五、测试:断言必须自己过一遍
四步做完,我让它把测试补齐,然后自己做反向验证:随机挑三个测试,把实现改错,看测试会不会红。
它写了一组表驱动测试,其中这条我最看重:
func TestUnread(t *testing.T) {
cases := []struct {
name string
lastCountableSeq int64
lastReadSeq int64
want int64
}{
{"正常未读", 100, 80, 20},
{"全部已读", 100, 100, 0},
{"位点领先于会话(异常数据)", 100, 120, 0}, // 不能返回负数
{"新会话还没读过", 5, 0, 5},
{"撤回后位点被剔除", 8, 10, 0},
}
...
}
第三条和第五条是我加的。它原本没有"位点领先"这个用例——而那正是生产上最容易出现的脏数据(用户换了设备、后端重放、手工修数据)。
反直觉的地方:AI 写的测试覆盖率通常很漂亮,但断言经常是"没有报错就算通过"。所以我的规矩是"测试失败只允许改实现;改断言必须我说清原期望为什么是错的"。
六、上线:灰度、指标、回滚
上线前我让它列了一份检查清单,并按它的建议补了一个"服务端开关":
feature:
unread_v2: false # 关掉即回到旧逻辑(旧逻辑仍然维护 last_seq,不做破坏性清理)
灰度顺序:内部账号 → 5% 用户 → 全量。观察的指标(它列的,我删掉了两个不好采集的):
im_read_report_total{result}:上报成功/幂等忽略/非法成员im_unread_query_duration_seconds:会话列表接口耗时(P50/P99)im_unread_negative_total:算出来为负的次数(必须恒为 0,不为 0 立刻回滚)im_group_read_count_duration_seconds:群已读人数查询耗时
最后一个指标救了一次:灰度第二天 im_unread_negative_total 出现了 17 次,全部集中在"用户退出群聊再重新加入"的场景——重新入群后位点表被删了,但会话的 last_seq 还在。修复只需要在重新入群时把位点初始化成加入时的 join_seq(这个字段我们在 IM 教程里提过,正好用上)。没有这个指标,这 17 个负数会以"显示 -17 条未读"的形式被用户发现。
七、AI 真正帮到我的三件事,和坑我的三件事
帮到的:
- 方案对比:计数器 vs 位点差值的写放大换算,我说"给我算一下万人大群",它三十秒给了一张表,而我可能要画十分钟;
- 样板消灭:DDL、表驱动测试、Lua 脚本、Prometheus 指标埋点,这类东西它写得又快又整齐;
- 边界清单:撤回、乱序、多端、踢人这些边界,我说"列出这个功能所有会出问题的边界",它给的表比我拍脑袋全。
坑到我的:
- 反复给"计数器方案":它天然偏好写放大 O(1) 的直觉方案,不会主动算 1 万倍写放大——你不逼它算,它就默认;
- 迁移不考虑可回滚:第一版永远是"直接改表 + 刷全表";
- 索引给错维度:它按"数据关系"给主键(会话在前),而不是按"查询模式"给主键(用户在前)。
八、沉淀:把这次的教训写成规则
这四个坑我全部写进了项目的 AGENTS.md:
## 数据模型约定
- 涉及"计数"的需求,先算写放大(条目数 × 受影响行数),超过 100 就改用位点/差值模型
- 主键与索引必须按查询模式设计,并在 PR 描述里写出主查询与 EXPLAIN 结果
- 任何加列/加表必须给出四步迁移(加列 → 双写 → 回填 → 切读)与回滚方式
- 迁移脚本必须可中断、可续跑,单批不超过 1000 行
## 验收约定
- 涉及数值的字段必须给出"不可能出现的值"的用例(负数、超界、空)
- 上线必须带功能开关与回滚步骤,并列出"必须恒为 0"的告警指标
写完之后我做了个实验:让 AI 用同一套规则再设计一遍"消息撤回"。这次它主动给了位点方案、主动写了撤回事件的 countable=0 设计、主动列了四步迁移。规则文件的效果比任何提示词技巧都直接。
小结
这个功能从需求到上线花了两个工作日,AI 承担的部分大概是把其中"打字"和"查样板"的时间压掉了七成,但它一次都没有替我做过这四个决定:要什么(规格)、选哪个方案(取舍)、这步对不对(验证)、错了怎么退(回滚)。
流程的价值就在这四件事上。把它们做扎实,AI 是加速器;跳过它们,AI 只是把返工提前了——而且返工的时候你往往已经忘了当初为什么这么设计。