高并发统计数据+攻略统计数据持久化和初始化(8.12)

一、高并发统计数字

1、业务分析

(1)为什么使用Redis?

viewnum(阅读量)、replynum(评论量)、favornum(收藏量)、sharenum(分享量)、thumbsupnum(点赞量)这些数据具有典型的高频并发写入特征。

  • 访问量大: 任何用户打开页面都会触发读取,点击交互会触发写入。
  • 更新频繁: 尤其是阅读量和点赞量,属于典型的写多读多场景。

①MySQL 的局限性

  • 效率低下:基于磁盘存储I/O 开销大,高并发写入时响应显著变慢。
  • 锁竞争严重:为保证数据一致性需加锁高并发下易引发严重的锁等待与阻塞
  • 扩展性差:单表更新压力过大,难以支撑海量并发。

② Redis 的核心优势

  • 原子性操作单线程模型保证 INCR 等自增命令天然原子,无脏数据无需复杂锁机制
  • 高性能:纯内存存储,读写达微秒级,轻松应对每秒数十万并发写入。
  • 减轻主库压力:承载高频计数,通过异步同步回 MySQL 持久化,保护核心业务库稳定。
(2)Redis数据怎么存储

方式一:String类型存储(独立Key模式)Map<String, String>

​ 将对象的每个属性拆解为独立的 Redis Key 进行存储。

viewnum:117   -> "200"
replynum:117  -> "3"
favornum:117  -> "10"
sharenum:117  -> "1"
thumbsup:117  -> "34"

优点:直白容易理解

缺点:key过多,浪费空间,拓展性差

方式二:String 类型存储(JSON序列化模式)Map<String, Object>

​ 将整个对象序列化为 JSON 字符串,作为一个整体 Value 存储在单个 Key 中。

strategy_num:117 "(viewnum:200, replynum:3, favornum:10, sharenum:1, thumbsup:34)"

优点:解决key过多的问题,查询很快

缺点:修改麻烦并且有并发问题

修改步骤:

​ ①读取缓存获取数据

​ ②将数据转换成java对象

​ ③将java对象中的viewnum + 1

​ ④将java对象转化为json串

​ ⑤将json串存储到redis中

并发情况模拟:
假设缓存中初始数据为 0,此时两人(客户端 A 和客户端 B)同时发起操作:

  1. 客户端 A 读取缓存数据,获取到 0
  2. 此时 客户端 B 也能正常读取缓存数据,同样获取到 0(因为 A 还在处理中,尚未写回)。
  3. 两人都在各自的内存中将数据 +1 变为 1
  4. 客户端 A 将结果 1 写回 Redis。
  5. 客户端 B 随后也将结果 1 写回 Redis,直接覆盖了 A 的数据。

结果: 两次操作后,最终结果依然是 1 而不是预期的 2。这种模式在并发量小时只是修改麻烦,但在并发量大时,极易导致数据丢失。

方式三:Hash 类型存储(Hash Map 模式)Map<String, Map<String, String>>

KEY: strategy_static_hash:117
-----------------------
viewnum    -> 200
replynum   -> 3
favornum   -> 10
sharenum   -> 1
thumbsupnum-> 34

优点:单独操作某个小key的值,是redis的原子操作,解决并发问题,不会丢数据;解决key过多问题

缺点:需要自己封装查询方法,实现遍历查询

场景 推荐方案 理由
互联网平台 方式三 (Hash) 高并发场景下,点赞、浏览数更新频繁,必须保证数据准确性(原子性),且需要频繁单独更新某个字段。
后台管理系统 方式二 (String JSON) 数据基本不改变,或者只有管理员偶尔修改,不存在高并发写入冲突。此时方式二读取性能最好,开发最简单。
简单的计数器 / 临时状态 方式一 (String) 仅当只需要关注某一个指标,或者指标之间完全无关联时使用。

2、代码实现阅读量++和评论++

高频的统计自增操作页面数据查询接口(Controller)分离,独立发送请求,主要基于以下两点考量:

  1. 读写解耦,保障性能:避免高频的统计写入操作占用过多资源,防止底层服务(Redis/MySQL)被拖垮,从而保障页面数据查询接口的稳定与高效。
  2. 支持页面静态化:为未来将页面转为静态资源(如接入 CDN)预留空间。若将统计操作耦合在查询接口中,页面静态化后接口不再被访问,统计功能将随之失效

参数是攻略id,返回格式是Map

viewnum:117   -> 200
replynum:117  -> 3
favornum:117  -> 10
sharenum:117  -> 1
thumbsup:117  -> 34
sid           -> 1      后期数据持久化,要将上面数据存到MySql中去
@PostMapping("/viewnumIncr")
    public R<Map<String, Object>> viewnumIncr(Long sid){
        Map<String, Object> map = strategyService.viewnumIncr(sid);
        return R.ok(map);
    }
	 /**
     * 获取 key,viewnum + 1,再获取到最新的缓存,返回缓存
     */
	@Override
    public Map<String, Object> viewnumIncr(Long sid) {
        String key = strategyHashInit(sid);
        redisService.incrementCacheMapValue(key, "viewnum",1);
        Map<String, Object> cacheMap = redisService.getCacheMap(key);
        return cacheMap;
    }

    @Override
    public Map<String, Object> replynumIncr(Long sid) {
        String key = strategyHashInit(sid);
        redisService.incrementCacheMapValue(key, "replynum",1);
        Map<String, Object> cacheMap = redisService.getCacheMap(key);
        return cacheMap;
    }
-------------------------------------------------------------------------------------------------
    @Getter
	public enum RedisKeys {

    STRATEGY_STATIS_HASH("strategy_statis_hash", -1),

    // 验证码
    VERIFY_CODE("verify_code", 60 * 3);
    ...
}
--------------------------------------------------------------------------------------------------
     /**
     * 初始化策略缓存
     * 拼接key,如果key不存在,就创建缓存,否则返回缓存key
     */
    private String strategyHashInit(Long sid) {
        String key = RedisKeys.STRATEGY_STATIS_HASH.join(sid.toString());
        if(!redisService.hasKey(key)){
            Strategy strategy = baseMapper.selectById(sid);
            Map<String,Object> map = new HashMap<>();
            map.put("viewnum", strategy.getViewnum().intValue());
            map.put("replynum", strategy.getReplynum().intValue());
            map.put("favornum", strategy.getFavornum().intValue());
            map.put("sharenum", strategy.getSharenum().intValue());
            map.put("thumbsupnum", strategy.getThumbsupnum().intValue());
            // 后期持久化
            map.put("id", strategy.getId());
            redisService.setCacheMap(key, map);
        }
        return key;
    }

注意:数据同步,Redis缓存中的数据要与MySql表中的数据一致。

二、攻略统计数据持久化

​ 上面我们已经把viewnum(阅读量)、replynum(评论量)存储到Redis缓存中了,现在我们要考虑维护MySql表,定期写回给MySql。

image-20260812204417402

先写一个定时任务,再远程调用Feign。Feign接口中定义方法,写降级。

@Component("strategyTask")
public class StrategyTask {

    @Autowired
    private RemoteStrategyService remoteStrategyService;
    // 统计数据持久化任务
    public void statisHashPersistence(){
        remoteStrategyService.statisHashPersistence(SecurityConstants.INNER);
    }
}
-----------------------------------------------------------------------------------------------
    @PostMapping("/strategies/feign/statisHashPersistence")
    R<?> statisHashPersistence(@RequestHeader(SecurityConstants.FROM_SOURCE) String source);
------------------------------------------------------------------------------------------------
/**
 * 攻略 远程服务降级回调
 *
 * @author y
 * @date 2026-08-06
 */
@Component
public class RemoteStrategyFallbackFactory implements FallbackFactory<RemoteStrategyService> {
    private static final Logger log = LoggerFactory.getLogger(RemoteStrategyFallbackFactory.class);
    @Override
    public RemoteStrategyService create(Throwable throwable) {
        log.error("攻略服务调用失败:{}", throwable.getMessage());

        return new RemoteStrategyService() {
            @Override
            public R<?> statisHashPersistence(String source) {
                return R.fail(Lists.newArrayList(), "统计数据持久化失败:" + throwable.getMessage());
            }
        };
    }
}
    @PostMapping("/feign/statisHashPersistence")
    public R<?> statisHashPersistence(){
        strategyService.statisHashPersistence();
        return R.ok();
    }
---------------------------------------------------------------------------------------------------
     /**
     * 先拼接前缀,再获取所有key,最后遍历key,获取缓存,更新redis数据库
     */
    @Override
    public void statisHashPersistence() {
        String key = RedisKeys.STRATEGY_STATIS_HASH.join("*");
        Collection<String> keys = redisService.keys(key);
        if (keys != null && keys.size()>0){
            for (String k : keys) {
                Map<String, Object> map = redisService.getCacheMap(k);
                Long id = (Long) map.get("id");
                Integer viewnum = (Integer) map.get("viewnum");
                Integer replynum = (Integer) map.get("replynum");
                Integer sharenum = (Integer) map.get("sharenum");
                Integer thumbsupnum = (Integer) map.get("thumbsupnum");
                Integer favornum = (Integer) map.get("favornum");
                lambdaUpdate().eq(Strategy::getId,id)
                        .set(Strategy::getViewnum,viewnum)
                        .set(Strategy::getReplynum,replynum)
                        .set(Strategy::getSharenum,sharenum)
                        .set(Strategy::getThumbsupnum,thumbsupnum)
                        .set(Strategy::getFavornum,favornum)
                        .update();

            }
        }
    }

三、攻略统计数据初始化

​ 初始化就是把MySql里的数据预热到Redis中,正常是拿MySql近期的数据,不可能拿所有数据到Redis中。方法是根据create_time把当前天数减N天,大于这个时间的数据放到Redis中。本次项目中是拿所有数据。

1、初始化时机:服务器第一次启动时

2、方法:使用监听器

3、Spring框架中有哪些不同类型的事件?

Spring 提供了以下5种标准的事件:

(1)上下文更新事件(ContextRefreshedEvent):在调用ConfigurableApplicationContext 接口中的refresh()方法时被触发。

(2)上下文开始事件(ContextStartedEvent):当容器调用ConfigurableApplicationContext的Start()方法开始/重新开始容器时触发该事件。

(3)上下文停止事件(ContextStoppedEvent):当容器调用ConfigurableApplicationContext的Stop()方法停止容器时触发该事件。

(4)上下文关闭事件(ContextClosedEvent):当ApplicationContext被关闭时触发该事件。容器被关闭时,其管理的所有单例Bean都被销毁。

(5)请求处理事件(RequestHandledEvent):在Web应用中,当一个http请求(request)结束触发该事件。

如果一个bean实现了ApplicationListener接口,当一个ApplicationEvent 被发布以后,bean会自动被通知。

4、代码实现

@Component
public class StatisHashInitListener implements ApplicationListener<ApplicationReadyEvent> {

    @Autowired
    private IStrategyService strategyService;
    @Autowired
    private RedisService redisService;

    // 当这个事件到来时
    @Override
    public void onApplicationEvent(ApplicationReadyEvent event) {
        System.err.println("程序初始化完成====");
		
        // 查询所有数据
        List<Strategy> strategies = strategyService.list();
        for(Strategy strategy:strategies){
            // 获取id,拼接key
            Long id = strategy.getId();
            String key = RedisKeys.STRATEGY_STATIS_HASH.join(id.toString());
            // Redis数据是新的,所以如果redis这个key有数据,就不存到Redis中了;否则将数据预热
            if(redisService.hasKey(key)){
                continue;
            }
            Map<String,Object> map = new HashMap<>();
            map.put("viewnum", strategy.getViewnum().intValue());
            map.put("replynum", strategy.getReplynum().intValue());
            map.put("favornum", strategy.getFavornum().intValue());
            map.put("sharenum", strategy.getSharenum().intValue());
            map.put("thumbsupnum", strategy.getThumbsupnum().intValue());

            map.put("id", strategy.getId());
            redisService.setCacheMap(key, map);
        }
    }
}

拓展:

Q1:为什么要执行 .intValue()

:主要是为了解决 Java 中的类型转换与空指针风险。当 getSharenum() 返回的是 Long 类型或包装类时,需要显式转换为 intInteger 类型以匹配 Map 的泛型要求;同时,若该字段为 null,调用 .intValue() 会触发明确的空指针异常,迫使开发者提前处理空值。

Q2:存入 Redis 默认是 String 吗?

:是的。Redis 底层所有的 Value 本质上都是二进制安全的 String 类型。但 Redis 具备智能编码优化机制,当检测到存入的字符串是纯数字时,会在内存中将其自动优化为 64位有符号整数(long)存储,以节省内存并提升运算效率;而在对外读取时,又会将其转回字符串返回给客户端。

posted on 2026-08-12 21:21  冬冬咚  阅读(4)  评论(0)    收藏  举报