首页 简历|笔试面试

如何设计一个投票系统?

  • 25年9月4日 发布
  • 219.45KB 共11页
如何设计一个投票系统?如何设计一个投票系统?如何设计一个投票系统?如何设计一个投票系统?如何设计一个投票系统?

如何设计一个投票系统?

这是球友的一个提问贴:

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);

}

}

}

开通会员 本次下载免费

所有资料全部免费下载! 推荐用户付费下载获取返佣积分! 积分可以兑换商品!
一键复制 下载文档 联系客服