如何设计一个投票系统?
如何设计一个投票系统?
这是球友的一个提问贴:
1753406051738-734a77d8-b26e-40c3-abe9-cd38f6fac789.png
我整理了一下,参考答案,供大家参考。
面试回答:
这个系统的核心挑战:第一高并发,热门投票可能瞬间有几万人同时投票;第二是数据一
致性,必须严格保证一人一票;第三是反作弊,防止刷票行为;第四是多活动。
首先是 API 网关,负责限流、鉴权和路由分发。这里我会配置针对投票接口的限流规则,
比如单 IP 每秒最多 10 次请求。
其次是业务层,包括投票服务、活动管理服务和反作弊服务。
然后是数据层,采用 Redis + MySQL 的组合。Redis 主要承载高频的投票操作,MySQL 负
责数据持久化和复杂查询。
消息层可以用 RocketMQ 或者 Kafka,进行数据同步等。
数据库我会设计三张核心表:
• 投票活动表存储活动的基本信息,包括标题、时间范围、状态等。这里我会加上状态
字段和时间索引,便于快速查询当前进行中的活动。
• 投票选项表存储每个活动的候选项,通过外键关联到活动表。
• 投票记录表是关键,我会按月分表来避免单表过大。最重要的是在活动 ID 和用户 ID
上建立联合索引,从数据库层面保证一人一票。同时记录 IP、设备 ID、用户代理等
信息,为反作弊提供数据支撑。
Redis 是整个系统的性能核心:
• 我会用 SET 存储每个活动已投票的用户列表,比如键名是“vote:users:活动 ID”,值是
用户 ID。这样检查用户是否已投票就是一个 SISMEMBER 操作,时间复杂度是
O(1)。
• 我会用 HASH 存储每个活动各个选项的得票数,键名是“vote:count:活动 ID”,field 是
选项 ID,value 是票数。
• 为了保证原子性,我会用 Lua 脚本把检查用户是否投票、记录投票、更新计数这几
个操作包装成一个原子操作。脚本逻辑是先检查用户是否在已投票集合中,如果没有
就添加用户到集合,同时对应选项计数加一。
用户发起投票时,我的处理流程是这样的:
• 第一步检查活动是否存在、是否在有效期内、选项是否合法等。
• 第二步是反作弊检测,我会从多个维度检查:IP 频率是否正常、设备指纹是否异常
等。如果发现异常就直接拒绝。
• 第三步是并发控制,我会用 Redisson 分布式锁避免同一用户进行重复投票,锁的键
是“vote:lock:活动 ID:用户 ID”。
• 第四步,通过 Lua 脚本完成投票记录和计数更新。如果用户已经投过票,返回失
败。
• 第五步是异步持久化,投票成功后,我会把详细的投票记录发送到消息队列,异步写
入 MySQL。这样用户能第一时间得到反馈。
对于反作弊,我会通过以下几个维度来考量:
• IP,用滑动窗口算法限制单 IP 的投票频率,比如每分钟最多 10 票。我会用 Redis 的
INCR 命令配合过期时间来实现。
• 设备维度,要求客户端传递设备指纹,同一设备在同一活动中只能投一票。我会把设
备 ID 和活动 ID 组合作为 Redis 的 key 来记录。
• 我会记录用户最近几次的投票时间戳,分析时间间隔是否过于规律。如果发现投票间
隔非常固定,可能是脚本行为。
• ****如果用户在极短时间内对多个活动投票,也可能是异常行为。
为了应对高并发,我会做几个优化:
第一,在活动开始前,我会把活动信息、选项信息预先加载到 Redis,并初始化计数器。
第二,查询统计结果主要走 Redis,只有在 Redis 没有数据时才去数据库同步。
第三,所有非核心的操作都通过消息队列异步处理,比如数据库持久化、统计报表等。
第四,投票记录表按时间分表,如果数据量特别大,还可以考虑按活动 ID 哈希分库。
对于数据一致性问题,首先要在投票记录表上建立联合索引,确保数据库层面不会有重复
投票;其次,每个投票请求都要有唯一标识,确保幂等。
核心表结构:
-- 投票活动表
CREATE TABLE vote_activities (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL COMMENT '活动标题',
description TEXT COMMENT '活动描述',
start_time DATETIME NOT NULL COMMENT '开始时间',
end_time DATETIME NOT NULL COMMENT '结束时间',
status TINYINT DEFAULT 1 COMMENT '状态:1-进行中 2-已结束 3-已暂停',
max_votes_per_user INT DEFAULT 1 COMMENT '每用户最大投票数',
total_votes BIGINT DEFAULT 0 COMMENT '总投票数',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP,
INDEX idx_status_time (status, start_time, end_time),
INDEX idx_created_at (created_at)
) COMMENT '投票活动表';
-- 投票选项表
CREATE TABLE vote_options (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL COMMENT '活动 ID',
option_text VARCHAR(500) NOT NULL COMMENT '选项内容',
vote_count BIGINT DEFAULT 0 COMMENT '得票数',
sort_order INT DEFAULT 0 COMMENT '排序',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_activity_id (activity_id),
FOREIGN KEY (activity_id) REFERENCES vote_activities(id)
) COMMENT '投票选项表';
-- 投票记录表(分表)
CREATE TABLE vote_records_202412 (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL COMMENT '活动 ID',
option_id BIGINT NOT NULL COMMENT '选项 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
client_ip VARCHAR(50) NOT NULL COMMENT '客户端 IP',
user_agent VARCHAR(500) COMMENT '用户代理',
device_id VARCHAR(100) COMMENT '设备 ID',
vote_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_activity_user (activity_id, user_id),
INDEX idx_activity_option (activity_id, option_id),
INDEX idx_user_time (user_id, vote_time),
INDEX idx_ip_time (client_ip, vote_time)
) COMMENT '投票记录表-按月分表';
vote_records 表按月分表,避免单表数据量过大。同时在 activity_id + user_id 上建立
唯一索引,从数据库层面保证一个用户在一个活动中只能投一票。
Redis 缓存设计:
@Component
public class VoteRedisManager {
private static final String VOTE_ACTIVITY_KEY = "vote:activity:%d";
private static final String VOTE_COUNT_KEY = "vote:count:%d";
private static final String USER_VOTE_KEY = "vote:user:%d:%d"; //
activity_id:user_id
private static final String VOTE_LOCK_KEY = "vote:lock:%d:%d";
/**
* 检查用户是否已投票
* 使用 SET 数据结构存储已投票用户
*/
public boolean hasUserVoted(Long activityId, Long userId) {
String key = String.format("vote:users:%d", activityId);
return redisTemplate.opsForSet().isMember(key, userId.toString());
}
/**
* 记录用户投票
* 使用 SET + HASH 组合存储
*/
public boolean recordVote(Long activityId, Long userId, Long optionId) {
String userSetKey = String.format("vote:users:%d", activityId);
String countHashKey = String.format("vote:count:%d", activityId);
// 使用 Lua 脚本保证原子性
String luaScript =
"if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then " +
" return 0 " + // 已投票
"else " +
" redis.call('SADD', KEYS[1], ARGV[1]) " +
" redis.call('HINCRBY', KEYS[2], ARGV[2], 1) " +
" redis.call('EXPIRE', KEYS[1], ARGV[3]) " +
" redis.call('EXPIRE', KEYS[2], ARGV[3]) " +
" return 1 " + // 投票成功
"end";
List<String> keys = Arrays.asList(userSetKey, countHashKey);
List<String> args = Arrays.asList(userId.toString(), optionId.toString(),
"86400");
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class), keys, args.toArray());
return result != null && result == 1;
}
/**
* 获取投票统计
* 使用 HASH 存储各选项得票数
*/
public Map<String, Object> getVoteStatistics(Long activityId) {
String countHashKey = String.format("vote:count:%d", activityId);
return redisTemplate.opsForHash().entries(countHashKey);
}
}
SET 存储已投票用户,便于快速检查;HASH 存储各选项得票数,便于实时统计;Lua 脚
本保证操作的原子性。
投票服务实现:
@Service
@Slf4j
public class VoteService {
@Autowired
private VoteRedisManager redisManager;
@Autowired
private VoteRecordMapper voteRecordMapper;
@Autowired
private AntiCheatService antiCheatService;
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* 用户投票核心逻辑
*/
@Transactional
public VoteResult submitVote(VoteRequest request) {
Long activityId = request.getActivityId();
Long userId = request.getUserId();
Long optionId = request.getOptionId();
// 1. 基础校验
VoteActivity activity = validateVoteActivity(activityId);
if (activity == null) {
return VoteResult.fail("投票活动不存在或已结束");
}
// 2. 反作弊检测
AntiCheatResult cheatResult = antiCheatService.checkVote(request);
if (!cheatResult.isPassed()) {
log.warn("投票反作弊拦截: userId={}, reason={}", userId,
cheatResult.getReason());
return VoteResult.fail("投票失败,请稍后重试");
}
// 3. 分布式锁防止并发重复投票
String lockKey = String.format("vote:lock:%d:%d", activityId, userId);
boolean lockAcquired = false;
try {
lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (!lockAcquired) {
return VoteResult.fail("投票过于频繁,请稍后重试");
}
// 4. Redis 原子性投票操作
boolean voteSuccess = redisManager.recordVote(activityId, userId,
optionId);
if (!voteSuccess) {
return VoteResult.fail("您已经投过票了");
}
// 5. 异步持久化到数据库
VoteRecord record = VoteRecord.builder()
.activityId(activityId)
.optionId(optionId)
.userId(userId)
.clientIp(request.getClientIp())
.userAgent(request.getUserAgent())
.deviceId(request.getDeviceId())
.voteTime(new Date())
.build();
// 发送到消息队列异步处理
rabbitTemplate.convertAndSend("vote.record.exchange",
"vote.record.save", record);
// 6. 实时统计更新
rabbitTemplate.convertAndSend("vote.statistics.exchange",
"vote.statistics.update",
VoteStatisticsEvent.builder()
.activityId(activityId)
.optionId(optionId)
.increment(1)
.build());
return VoteResult.success("投票成功");
} finally {
if (lockAcquired) {
redisTemplate.delete(lockKey);
}
}
}
/**
* 获取投票结果
*/
public VoteResultDTO getVoteResult(Long activityId) {
// 优先从 Redis 获取实时数据
Map<String, Object> statistics = redisManager.getVoteStatistics(activityId);
if (statistics.isEmpty()) {
// Redis 无数据,从数据库同步
syncVoteDataToRedis(activityId);
statistics = redisManager.getVoteStatistics(activityId);
}
// 构造返回结果
List<VoteOptionResult> options = statistics.entrySet().stream()
.map(entry -> VoteOptionResult.builder()
.optionId(Long.valueOf(entry.getKey()))
.voteCount(Long.valueOf(entry.getValue().toString()))
.build())
.sorted((a, b) -> Long.compare(b.getVoteCount(), a.getVoteCount()))
.collect(Collectors.toList());
return VoteResultDTO.builder()
.activityId(activityId)
.options(options)
.totalVotes(options.stream().mapToLong(VoteOptionResult::getVoteCount).
sum())
.build();
}
}
先做业务校验,再做反作弊检测,然后用分布式锁保证并发安全,最后通过 Redis 脚本完
成投票,数据库持久化通过消息队列异步处理。
反作弊系统设计:
@Service
@Slf4j
public class AntiCheatService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 综合反作弊检测
*/
public AntiCheatResult checkVote(VoteRequest request) {
// 1. IP 频率限制
if (!checkIpFrequency(request.getClientIp())) {
return AntiCheatResult.reject("IP 访问过于频繁");
}
// 2. 用户行为检测
if (!checkUserBehavior(request.getUserId())) {
return AntiCheatResult.reject("用户行为异常");
}
// 3. 设备指纹检测
if (!checkDeviceFingerprint(request.getDeviceId(), request.getActivityId())) {
return AntiCheatResult.reject("设备异常");
}
// 4. 时间窗口检测
if (!checkTimeWindow(request.getUserId(), request.getActivityId())) {
return AntiCheatResult.reject("投票时间异常");
}
return AntiCheatResult.pass();
}
/**
* IP 频率限制:滑动窗口算法
*/
private boolean checkIpFrequency(String clientIp) {
String key = "anti_cheat:ip:" + clientIp;
String luaScript =
"local current = redis.call('INCR', KEYS[1]) " +
"if current == 1 then " +
" redis.call('EXPIRE', KEYS[1], ARGV[1]) " +
"end " +
"return current";
Long count = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(key),
"60" // 60 秒窗口
);
return count != null && count <= 10; // 每分钟最多 10 次
}
/**
* 设备指纹检测
*/
private boolean checkDeviceFingerprint(String deviceId, Long activityId) {
if (StringUtils.isEmpty(deviceId)) {
return false; // 必须有设备 ID
}
String key = String.format("anti_cheat:device:%s:%d", deviceId, activityId);
// 同一设备同一活动只能投票一次
Boolean exists = redisTemplate.hasKey(key);
if (Boolean.TRUE.equals(exists)) {
return false;
}
// 记录设备投票
redisTemplate.opsForValue().set(key, "1", Duration.ofHours(24));
return true;
}
/**
* 用户行为模式检测
*/
private boolean checkUserBehavior(Long userId) {
String key = "anti_cheat:user_behavior:" + userId;
// 获取用户最近的投票时间戳
List<Object> timestamps = redisTemplate.opsForList().range(key, 0, -1);
long currentTime = System.currentTimeMillis();
// 检测是否存在机器行为(投票间隔过于规律)
if (timestamps != null && timestamps.size() >= 3) {
List<Long> times = timestamps.stream()
.map(t -> Long.valueOf(t.toString()))
.sorted()
.collect(Collectors.toList());
// 检测时间间隔是否过于规律(可能是脚本)
if (isRobotBehavior(times)) {
return false;
}
}
// 记录本次投票时间
redisTemplate.opsForList().leftPush(key, currentTime);
redisTemplate.opsForList().trim(key, 0, 9); // 只保留最近 10 次
redisTemplate.expire(key, Duration.ofHours(1));
return true;
}
private boolean isRobotBehavior(List<Long> timestamps) {
if (timestamps.size() < 3) return false;
List<Long> intervals = new ArrayList<>();
for (int i = 1; i < timestamps.size(); i++) {
intervals.add(timestamps.get(i) - timestamps.get(i-1));
}
// 计算时间间隔的标准差,如果过小说明过于规律
double variance = calculateVariance(intervals);
return variance < 1000; // 方差小于 1 秒说明可能是机器行为
}
}
IP 限频防止单点攻击,设备指纹防止设备复用,行为模式检测识别机器投票,时间窗口
检测发现异常投票模式。
缓存预热与降级方案:
@Component
public class VoteCacheWarmer {
/**
* 活动开始前预热缓存
*/
@EventListener
public void warmupCache(VoteActivityStartEvent event) {
Long activityId = event.getActivityId();
// 预热活动基础数据
VoteActivity activity = voteActivityMapper.selectById(activityId);
String activityKey = String.format("vote:activity:%d", activityId);
redisTemplate.opsForValue().set(activityKey, activity, Duration.ofHours(2));
// 预热投票选项
List<VoteOption> options = voteOptionMapper.selectByActivityId(activityId);
String optionsKey = String.format("vote:options:%d", activityId);
redisTemplate.opsForValue().set(optionsKey, options, Duration.ofHours(2));
// 初始化计数器
String countKey = String.format("vote:count:%d", activityId);
for (VoteOption option : options) {
redisTemplate.opsForHash().put(countKey,
option.getId().toString(),
option.getVoteCount().toString());
}
redisTemplate.expire(countKey, Duration.ofHours(2));
log.info("投票活动缓存预热完成: activityId={}", activityId);
}
}
@Component
public class VoteCircuitBreaker {
private final AtomicInteger errorCount = new AtomicInteger(0);
private volatile boolean circuitOpen = false;
private volatile long lastFailTime = 0;
/**
* 熔断保护
*/
public VoteResult executeWithCircuitBreaker(Supplier<VoteResult>
voteOperation) {
// 检查熔断器状态
if (circuitOpen) {
if (System.currentTimeMillis() - lastFailTime > 30000) { // 30 秒后尝试恢复
circuitOpen = false;
errorCount.set(0);
} else {
return VoteResult.fail("系统繁忙,请稍后重试");
}
}
try {
VoteResult result = voteOperation.get();
if (result.isSuccess()) {
errorCount.set(0); // 成功则重置错误计数
} else {
incrementError();
}
return result;
} catch (Exception e) {
incrementError();
return VoteResult.fail("系统异常,请稍后重试");
}
}
private void incrementError() {
int errors = errorCount.incrementAndGet();
if (errors >= 10) { // 连续 10 次失败则开启熔断
circuitOpen = true;
lastFailTime = System.currentTimeMillis();
log.warn("投票服务熔断器开启,错误次数: {}", errors);
}
}
}