数据结构不是面试题,是系统的骨架
古董级程序员,大厂出来后一直在创业公司,现在仍在一线写代码、做产品、和 AI 编程工具较劲。更完整的更新会放在微信公众号「字与码」:工作经历、新技术观察、AI 开发实践,以及这些年踩过的坑,都会陆续写在那里。
《AI时代的程序员修养》这个专栏想讨论的,不是“AI 会不会替代程序员”,而是程序员在 AI 已经能写大量代码之后,还应该抓住哪些硬东西。第一篇从“程序 = 算法 + 数据结构”讲起,这一篇继续往下挖:数据结构在真实系统里到底长什么样。
很多人一听数据结构,就想到链表反转、二叉树遍历、堆排序。那些当然有用,但工程里更常见的问题不是“会不会手写某个结构”,而是:你能不能看出这个业务其实需要队列、索引、状态机、事件日志或者一棵树。AI 可以照着你的描述写代码,但你得知道系统骨架该怎么搭。
数据结构不是代码形状,而是访问方式
数据结构的本质,不是某个类怎么写,也不是内存里指针怎么连。对工程系统来说,它首先是访问方式。
你要按 ID 快速找到对象,通常会想到哈希表或数据库主键索引。你要按时间顺序处理任务,就会想到队列或日志。你要按层级展示评论、组织架构、权限继承,就会想到树。你要按关键词搜索文章、日志、商品,就会想到倒排索引。你要只保留最近访问过的数据,就会想到 LRU。
这些结构一旦选定,系统的边界就跟着定了。
比如同样是“保存用户操作记录”,可以有三种完全不同的骨架:
| 结构 | 常见实现 | 适合的问题 | 不适合的问题 |
|---|---|---|---|
| 当前状态表 | user_state 表,一行代表最新状态 | 快速读取当前值 | 追溯历史、审计、重放 |
| 事件日志 | append-only log,每次操作追加一条 | 审计、回放、异步消费 | 直接查当前状态较慢 |
| 状态表 + 日志 | 状态表服务查询,日志服务追溯 | 大多数业务系统 | 写入路径更复杂 |
如果只让 AI “实现用户操作记录”,它很可能给你一张表:
create table user_actions (
id bigserial primary key,
user_id bigint not null,
action text not null,
created_at timestamptz not null
);
这不是错,但它没有回答关键问题:这是审计日志,还是业务状态?是否允许修改?是否需要重放?是否需要按用户分页?是否会被其他服务消费?是否有保留周期?这些问题不说清楚,后面的代码都只是猜。
哈希表:最快的路,也最容易失控
哈希表大概是工程里最常见的数据结构。缓存、去重、按 ID 聚合、连接池、会话表、限流计数,背后经常都是哈希表。
在 AI 生成代码里,哈希表最常见的问题不是不会用,而是太随便。
例如做一个批量查询接口,需要把商品 ID 去重后再查数据库。AI 很容易写出这样的代码:
def dedupe_product_ids(product_ids: list[str]) -> list[str]:
return list(set(product_ids))
这段代码能去重,但它破坏了顺序。如果前端传入 [B, A, B, C],希望返回也按第一次出现顺序排列,这个实现就不合格。更稳的写法是:
def dedupe_keep_order(product_ids: list[str]) -> list[str]:
seen = set()
result = []
for product_id in product_ids:
if product_id in seen:
continue
seen.add(product_id)
result.append(product_id)
return result
这里用到的仍然是哈希表,但多了一个顺序约束。很多线上 bug 就藏在这种“小差别”里。
再看一个更容易出问题的例子:本地缓存。
cache = {}
def get_user_profile(user_id):
if user_id not in cache:
cache[user_id] = load_user_profile(user_id)
return cache[user_id]
这段代码在 demo 里很好看,线上很危险。它没有容量上限,没有过期时间,没有并发保护,也没有区分缓存穿透和缓存击穿。用户 ID 如果足够多,这个 dict 会一直长。
让 AI 写缓存时,prompt 里至少要写清楚:
本地缓存要求:
- 使用 LRU,最多保留 10_000 个用户。
- 每个 entry 有 5 分钟 TTL。
- cache key 必须包含 user_id 和 profile_version。
- 并发加载同一个 key 时要做 singleflight,避免同时打爆数据库。
- 缓存命中率、淘汰次数、加载失败次数需要打指标。
一旦写到这个层面,哈希表就不只是一个 dict,而是一个有容量、时间、并发、观测边界的组件。
队列:它不是“晚点执行”这么简单
队列在系统里通常有三种用途:削峰、解耦、顺序化。
削峰很好理解。请求高峰时先把任务放进队列,worker 慢慢消费。解耦也常见,用户下单之后,发短信、发券、写统计都可以异步做。顺序化容易被忽略,比如同一个账户的资金变动,必须按顺序处理。
不同用途,对队列的要求不一样。
| 用途 | 队列要求 | 容易踩的坑 |
|---|---|---|
| 削峰 | 可堆积、可扩 worker、可限速 | 只会堆积,不知道什么时候拒绝新任务 |
| 解耦 | 可重试、可死信、可追踪 | 任务失败后无限重试,或者静默丢失 |
| 顺序化 | 同一 key 保序 | 多 worker 并发后顺序被打乱 |
| 延迟执行 | 支持 run_at 或延迟队列 | 扫描全表找到期任务 |
| 优先级调度 | 优先队列或多队列 | 高优任务饿死低优任务 |
AI 很容易把队列写成“一个表 + 一个 while 循环”:
while True:
job = db.fetch_one("select * from jobs where status = 'pending' limit 1")
if job:
run(job)
这段代码几乎不能上线。多个 worker 会抢同一条任务;没有锁;没有租约;没有重试;没有超时;没有批量;没有空闲等待;也没有索引。
稍微像样一点的数据库队列,至少要考虑:
select *
from jobs
where status = 'pending'
and run_at <= now()
order by priority desc, run_at asc
limit 100
for update skip locked;
这里 for update skip locked 很关键。它让多个 worker 可以并发抢任务,但不会抢到同一批。对应索引也要跟上:
create index idx_jobs_ready
on jobs (status, run_at, priority desc);
这还是简化版。真实系统还会有 locked_by、locked_at、attempt、max_attempts、last_error、idempotency_key、timeout_at。队列从来不是“晚点执行”这么简单,它是系统里控制吞吐、顺序、失败和恢复的结构。
树:层级数据最怕“先递归再说”
树结构在业务里很常见:评论、菜单、组织架构、品类、权限、文件夹、知识库节点。
AI 写树结构代码时,最常见的毛病是递归到底:
def load_children(node_id):
children = db.query("select * from nodes where parent_id = ?", node_id)
return [
{**child, "children": load_children(child["id"])}
for child in children
]
这段代码非常直观,也非常危险。每个节点一次查询,节点一多就是 N+1。树深一点还有栈溢出风险。如果数据里出现环,递归会直接跑飞。
树结构设计时,要先问几件事:
- 树的最大深度是多少?
- 一次查询是查子节点,还是查整棵子树?
- 节点会不会移动?
- 是否需要按路径排序?
- 是否要查祖先链?
- 是否允许软删除?
- 数据里是否可能出现环?
不同答案,对应不同结构。
| 场景 | 推荐结构 | 原因 |
|---|---|---|
| 菜单、浅层评论 | 邻接表 parent_id | 写入简单,查询一两层够用 |
| 文件夹、知识库目录 | 路径枚举 path | 查整棵子树和排序方便 |
| 权限继承、组织架构 | 闭包表 | 祖先、后代查询都稳定 |
| 只读树、大规模展示 | 预计算树快照 | 读性能好,适合缓存 |
如果用路径枚举,数据大概会长这样:
id=1, path=/1/
id=2, path=/1/2/
id=3, path=/1/2/3/
查 2 的子树,就是:
select *
from nodes
where path like '/1/2/%'
order by path;
这时 path 字段就成了数据结构本身。它牺牲了移动节点的成本,换来了读子树的简单和稳定。AI 如果不知道这个取舍,就会倾向于写递归查询,因为那段代码看起来最顺手。
索引:它是数据库里的数据结构
很多程序员知道数据库要建索引,但不一定把索引当数据结构看。
索引当然是数据结构。B+ 树索引适合范围查询和排序,哈希索引适合等值查询,倒排索引适合文本检索,向量索引用来做近似最近邻搜索。不同索引支持的访问方式不同。
一个典型慢查询是:
select *
from tool_call_history
where user_id = :user_id
and created_at >= :start
and tool_id = :tool_id
order by created_at desc
limit 100;
如果 AI 只看 SQL,不看数据规模,可能会觉得没问题。但索引怎么建,决定它是毫秒级还是秒级。
这类查询通常需要一个复合索引:
create index idx_tool_call_user_tool_time
on tool_call_history (user_id, tool_id, created_at desc);
字段顺序不是随便排的。user_id 和 tool_id 是等值过滤,created_at 既参与范围又参与排序。这个索引能让数据库沿着用户和工具定位,再按时间倒序扫出前 100 条。
如果换一个需求:
select tool_id, count(*)
from tool_call_history
where created_at >= :start
group by tool_id;
那上面的索引就不一定合适。它按用户开头,而这个查询根本没有用户条件。你可能需要 (created_at, tool_id),或者直接做按天汇总表。
让 AI 写数据访问层时,我通常会要求它同时输出:
请给出:
- 主要查询模式
- 每个查询的预期数据量
- 推荐索引
- 为什么索引字段按这个顺序排列
- 哪些查询不能靠这个索引优化
- 是否需要汇总表或物化视图
这比“帮我写 ORM 查询”有用得多。
日志:append-only 是一种很硬的结构
很多系统最后都会长出一条日志:操作日志、审计日志、消息日志、事件日志、binlog、WAL。
日志看起来只是“多写一行记录”,其实是非常强的数据结构。它的核心约束是 append-only:只追加,不原地修改。这个约束带来几个好处:
- 容易审计,因为历史不会被覆盖。
- 容易重放,因为顺序稳定。
- 容易异步消费,因为消费者可以记 offset。
- 容易恢复,因为可以从某个位置重新处理。
但日志也有代价。它会不断增长;消费者可能落后;重复消费必须幂等;消息顺序可能只在分区内成立;schema 演进会变麻烦。
一个事件日志表可以很简单:
create table account_events (
id bigserial primary key,
account_id bigint not null,
event_type text not null,
event_id uuid not null,
payload jsonb not null,
created_at timestamptz not null default now(),
unique(event_id)
);
这里 event_id 是幂等键。消费者重复处理时,必须能识别同一事件。created_at 不是严格顺序,真正顺序通常依赖自增 ID、日志 offset 或版本号。
如果账户余额由事件累加得出,还需要版本控制:
update accounts
set balance = balance + :delta,
version = version + 1
where id = :account_id
and version = :expected_version;
这就把日志、状态表、乐观锁连起来了。AI 可以写这些 SQL,但前提是你告诉它这是 append-only 事件流,不是普通 CRUD。
一份给 AI 的数据结构设计提示词
写到这里,可以把方法收敛成一段更实用的 prompt。
先不要写业务代码。
请为这个功能做数据结构设计:
1. 列出核心实体、字段、唯一约束、状态字段。
2. 列出主要访问模式:按 ID 查、按时间查、按层级查、按关键词查、按状态扫描、按优先级出队。
3. 为每个访问模式选择合适的数据结构:哈希表、队列、树、倒排索引、B+ 树索引、事件日志、状态机等。
4. 给出推荐数据库索引,并解释字段顺序。
5. 分析复杂度、数据量上限、内存上限和可能的热点。
6. 写出 5 个 AI 容易生成错的实现,并解释为什么错。
7. 最后再给出代码实现计划。
这段 prompt 有点长,但它逼着 AI 先画骨架。骨架对了,代码可以迭代;骨架错了,代码越多越难救。
回到工程判断
数据结构不是面试题,也不是只存在于算法课里的概念。它在系统里变成缓存、队列、索引、树、日志、状态机、消息体、聚合表。
AI 会让代码生成越来越便宜,但不会让结构选择变得无所谓。恰恰相反,当代码变便宜以后,错误结构会更快被写进系统里。
所以程序员要练的不是“我能不能亲手写出所有数据结构”,而是看到一个需求时,能不能先问:
- 这个功能主要按什么方式访问数据?
- 数据会不会增长到必须限制内存或分页?
- 是否需要顺序、优先级、去重、幂等、审计、回放?
- 是读多写少,还是写多读少?
- 是查当前状态,还是查历史过程?
- 索引能不能支撑主要查询?
- AI 生成的代码有没有把结构假设藏起来?
这些问题问出来,AI 才有机会写出能进系统的代码。否则,它只是把你没想清楚的地方,用更快的速度写成了技术债。
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。