拼豆图纸小程序里有个「这张图难不难」的投票:三个选项、每人一票、可改票、要实时看到分布条。第一版我二十分钟就写完了——然后花了一下午处理它带来的并发和数据膨胀问题。
这篇把完整设计过程拆开:数据模型 → 接口幂等 → 并发安全 → 规模演进 → 前端状态机。代码取自线上实现,标注了哪些是已落地、哪些是优化方案。
一、需求约束反过来决定数据模型
先把约束列全,模型自己就出来了:
- 每人每帖只算一票(要认人 → 需要用户标识)
- 可以改票,改票要立即反映到统计上
- 前端要一次性拿到「总票数 + 各档占比 + 我投了什么」
- 老帖没有数据时,模块不能渲染出空壳
于是拆成两个 post meta,而不是一个大数组:
difficulty_stats => ['easy' => n, 'mid' => n, 'hard' => n] // 聚合,读多
difficulty_votes => [user_id => 'easy|mid|hard'] // 明细,写多,用于幂等与改票
为什么不一锅炖:详情页「获取统计」是高频只读请求,只需要前三行数据;而投票要读写整张明细表。混在一起,每次读统计都要把几千条投票记录反序列化一遍。
二、接口层:校验、身份、幂等
小程序没有 Cookie/Session,靠 openid 认人。后端拿 openid 自动建号(首次访问创建,之后直接查),把「微信身份」映射成 WP 的 user_id,后面所有 post meta 都用 user_id 当 key,避免 openid 明文散落在各张表里。
$user = get_user_by('login', $openid);
if ($user) return $user->ID;
$user_id = wp_insert_user([
'user_login' => $openid,
'nickname' => '微信用户_' . substr($openid, -6),
'user_email' => $openid . '@example.com',
'role' => 'subscriber',
'user_pass' => wp_generate_password(16, false),
]);
return is_wp_error($user_id) ? 0 : $user_id;
选项用严格白名单校验
三、核心逻辑:幂等 + 改票的计数平移
这是整个功能最容易写错的地方。要处理三种情况:首次投、重复投同一项、改投另一项。
$prev = isset($votes[$user_id]) ? $votes[$user_id] : null;
if ($prev !== $vote) {
// 改票:先把旧票减回去(不小于 0),再加新票,保证总数恒定
if ($prev && isset($stats[$prev])) {
$stats[$prev] = max(0, intval($stats[$prev]) - 1);
}
$stats[$vote] = isset($stats[$vote]) ? intval($stats[$vote]) + 1 : 1;
$votes[$user_id] = $vote;
update_post_meta($post_id, 'difficulty_stats', $stats);
update_post_meta($post_id, 'difficulty_votes', $votes);
}
// 无论是否改票,都回传最新统计,前端省一次请求
return $this->make_success([
'stats' => $stats,
'total' => array_sum($stats),
'my_vote' => $vote,
]);
两个细节:
max(0, n-1)兜底——脏数据或人工改库可能让计数为负,减出负数会让分布条出现 -12% 这种鬼值。$prev !== $vote这一句同时实现了幂等:重复投同一项直接跳过写入,返回最新统计,客户端重复点击、网络重发都不会刷票。
四、上线才会暴露的三个问题
1. 竞态:读-改-写不是原子操作
上面的代码有个致命前提:读 stats → 改 → 写回,这三步之间不能被打断。但 PHP-FPM 是多进程的,两个用户同时投票时,可能都读到 easy=5,各自 +1 后写回 6,一票凭空消失。低流量下复现不了,一上量就丢数据。
解法 A(推荐):自定义表 + SQL 原子自增,把自增交给数据库:
$wpdb->query($wpdb->prepare(
"INSERT INTO {$wpdb->prefix}post_votes (post_id, opt, cnt)
VALUES (%d, %s, 1)
ON DUPLICATE KEY UPDATE cnt = cnt + 1",
$post_id, $vote
));
解法 B:WordPress 里的简易互斥锁,拿不到锁就退避重试:
$lock_key = 'vote_lock_' . $post_id;
$locked = false;
for ($i = 0; $i < 5; $i++) {
if (wp_cache_add($lock_key, 1, '', 5)) { $locked = true; break; }
usleep(50000); // 50ms
}
if (!$locked) return $this->make_error('系统繁忙,请重试');
try {
/* 读-改-写 */
} finally {
wp_cache_delete($lock_key);
}
2. 膨胀:votes 数组是线性增长的
difficulty_votes 存的是全量映射,一万人投票就是一万条。每次有人投票,都要反序列化整个数组 → 改一个值 → 整体序列化写回。帖子火了以后,这一行 meta 会变成几百 KB,写放大非常严重。
演进路线:数据量小的时候 post meta 完全够用;过千票就该拆表 (post_id, user_id, option) 加唯一索引 (post_id, user_id),改票用 INSERT ... ON DUPLICATE KEY UPDATE;也可以反着存——把票记在 user meta 里(每人一条,规模天然受限),代价是查「我投过没」快、查「全站统计」慢,需要额外维护聚合表。
3. 列表页的 N+1 查询
图纸库一页 20 张图,每张都要展示难度共识,就是 20 次 get_post_meta(每张还要取 stats 和 votes,翻倍)。解决办法是冗余一个 total 字段 + 批量查询:
// 写入时顺带维护冗余计数,列表页只需一个轻量字段
update_post_meta($post_id, 'difficulty_total', array_sum($stats));
// 列表页用一次查询拿回全部,别在循环里 get_post_meta
$ids = wp_list_pluck($posts, 'ID');
$meta = $wpdb->get_results(
"SELECT post_id, meta_value FROM {$wpdb->postmeta}
WHERE meta_key='difficulty_total' AND post_id IN (" . implode(',', array_map('intval', $ids)) . ")"
);



暂无评论