投票是个「看起来简单、写起来全是坑」的功能

2026-09-03 46 0  当前有 2 人在读此篇文章

拼豆图纸小程序里有个「这张图难不难」的投票:三个选项、每人一票、可改票、要实时看到分布条。第一版我二十分钟就写完了——然后花了一下午处理它带来的并发和数据膨胀问题。

这篇把完整设计过程拆开:数据模型 → 接口幂等 → 并发安全 → 规模演进 → 前端状态机。代码取自线上实现,标注了哪些是已落地、哪些是优化方案。

一、需求约束反过来决定数据模型

先把约束列全,模型自己就出来了:

  • 每人每帖只算一票(要认人 → 需要用户标识)
  • 可以改票,改票要立即反映到统计上
  • 前端要一次性拿到「总票数 + 各档占比 + 我投了什么」
  • 老帖没有数据时,模块不能渲染出空壳

于是拆成两个 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)) . ")"
);

Jinming

95后典型金牛座,强迫症。

相关推荐

暂无评论

发表评论

小程序 小程序
小程序