MeteorCat / 数据打点量化

Created Sat, 30 Sep 2023 16:32:56 +0800 Modified Wed, 29 Oct 2025 23:24:53 +0800
2152 Words

数据打点量化

数据统计是日常工作常见业务, 最常见的就是需要汇总买量玩家的登录/充值/流失等, 需要了解游玩对象玩家的转化率.

除了游戏相关, 日常大数据也需要统计, 比如统计玩家购买物品统计购买的种类/购买频率通过大数据统计出购买喜好等

这种打点统计用几种方式:

  • 第三方推送: 走 Web 推送
  • 本地内存队列: 走 Redis/Mongo 队列推送
  • 本地日志推送: 这种方式是我最近看到, 通过服务端写入本地日志文件, 之后第三方接入通过监控日志文件在后台异步推送, 这几种是目前接触过的, 每种都有其优势.

第三方推送

首先是 第三方Web推送 , 在日常比较有两种方式:

  1. 客户端推送: 将负载移交到客户端和第三方交互, 但是数据准确性可能会被人篡改, 比如抓包查看到就能自己去随便推送, 这种方式数据准确性实际上很难说, 而且曾经发现过第三方自己刷自己接口增加自身买量数量的问题.
  2. 服务端推送: 这种对于相对响应速度要求不高, 因为在中间数据传输过很多层解析, 打点推送会走 dns(解析域名) -> resolve(IP最速解析) -> https(证书验证) -> shunt(后端负载均衡), 可以看到服务端推送如果大数据的时候大数据转发时候可能会超时卡住从而让整个服务停摆等待推送完成.

这里以前踩过坑, 第三方打点推送不要完全信任数据可以到达, 可能第三方服务某些时候需要停机维护等, 如果大数量打点推送的时候日志异常足够撑爆本地.

数据推送绝对不要采用同步方式, 而且如果是游戏服务端千万不要在内部服务耦合混进去, 无论同步异步在游戏服务端内部走 Web 推送都是很 “蠢” 的设计, 本身就是为了低延迟高并发的服务不要集成这种打点方式.

本地内存队列

这种比较常见也是相对来说兼顾低延迟高并发的方式, 直接采用内存监听队列方式, 本地直接内网推送节省打点过程的解析浪费和不走本地日志文件IO提升响应速度(注意采用长连接推送).

这种方式主要问题就是对于大数据打点的情况, 可能本地服务器内存可能直接一下子就被打满了.

一般队列除了生产者也需要消费者把数据提取出来放置到关系型数据库来进行数据落地.

本地日志推送

这种方式是最近看到某个第三方采用类似 elk 方式, 让游戏服务端将日志按照规则写入到本地日志文件 xxx.log 之中, 然后接入 SDK 会不断异步把日志推送到第三方那边过去.

这种方式其实也还行的折中方案, 主要将服务端日志打包按照规则推给第三方过滤处理, 某些方面不敏感的数据移交给第三方过滤也算可以, 让专业的人处理专业事.

搭建Web打点接口

这里采用 JavaSpring 处理, 对于这种频繁请求的接口对象最好不要采用脚本语言来进行服务开发(脚本语言会有频繁实例化数据库连接的情况, 而且大部分实现的长链接库性能也不是这么好).

这里需要先确定好访问形式, 按照现代来说直接 PUT 方式推送即可:

[推送方式] [请求链接]        [资源路径]  [权限Token] [打点命令]
  POST     http://127.0.0.1  /event      /xxx        /10000

比如创建登录事件 1000 , 需要先去获取登录获取提交的 token, 之后构建请求 http://127.0.0.1/event/[申请获得的TOKEN]/1000, 之后可以在表单内提交JSON写入事件.

Maven 的 pom.xml 配置:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- Spring 相关 -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <!-- 包体信息 -->
    <groupId>com.meteorcat</groupId>
    <artifactId>events</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>events</name>
    <description>events</description>
    <properties>
        <java.version>17</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>


    <dependencies>

        <!-- spring Redis:https://spring.io/projects/spring-data-redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- spring Web包 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- spring 开发配置 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>

        <!-- spring 开发进程 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>


    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

application.properties 配置如下:

# 系统配置
spring.application.name=Events
server.address=127.0.0.1
server.port=80
server.servlet.encoding.charset=UTF-8

# Redis对象
spring.data.redis.database=2
spring.data.redis.host=127.0.0.1
spring.data.redis.port=6379
#spring.data.redis.password=
spring.data.redis.timeout=10000

# Redis连接池
spring.data.redis.lettuce.pool.enabled=true
spring.data.redis.lettuce.pool.min-idle=0
spring.data.redis.lettuce.pool.max-active=8
spring.data.redis.lettuce.pool.max-wait=-1ms

config/RedisConfig.java 初始化配置如下:

package com.meteorcat.events.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisSocketConfiguration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * Redis配置类
 *
 * @author MeteorCat
 */
@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String hostname;

    @Value("${spring.data.redis.port}")
    private Integer port;

    @Value("${spring.data.redis.password:}")
    private String password;

    @Value("${spring.data.redis.database:0}")
    private Integer database;


    @Bean
    LettuceConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(
                hostname,
                port
        );
        if (!password.isBlank()) {
            config.setPassword(password);
        }
        config.setDatabase(database);
        return new LettuceConnectionFactory(config);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }

}

EventApi.java 具体功能业务:

package com.meteorcat.events.controllers;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;


@RestController
@RequestMapping("/api/event")
public class EventApi {

    /**
     * 日志句柄
     */
    Logger logger = LoggerFactory.getLogger(EventApi.class);


    /**
     * 事件前缀
     */
    static String EVENT_FORMAT = "events:%d";

    /**
     * Redis连接模板
     */
    private final RedisTemplate<String, String> redisTemplate;

    /**
     * JSON解析器
     */
    ObjectMapper mapper = new ObjectMapper();


    /**
     * 初始化确认 Redis 队列创建
     */
    EventApi(RedisTemplate<String, String> redisTemplate) {
        logger.warn("Create Redis Queue");
        this.redisTemplate = redisTemplate;
    }


    /**
     * 推送Token权限申请
     * @return JSON
     */
    @RequestMapping("/token")
    public Object token(){
        // todo:权限验证
        return "{}";
    }

    /**
     * 事件提交入口
     *
     * @param token 请求的Token
     * @param event 请求的事件
     * @return JSON
     */
    @RequestMapping("/{token}/{event}")
    public Object events(
            @PathVariable String token,
            @PathVariable Integer event,
            @RequestBody Map<String, Object> data
    ) {

        //todo: 验证token

        //todo: 验证事件id


        // 注意内置双下划线对象为系统预留的值, 首先就是创建事件记录
        data.put("__event__", event);
        data.put("__time__", System.currentTimeMillis());

        // 这里最好写入用户TOKEN的UID标识确认数据所有权
        data.put("__uid__", 0);


        // 数据格式转化为JSON
        String json;
        try {
            json = mapper.writeValueAsString(data);
        } catch (JsonProcessingException exception) {
            // 解析异常抛出错误
            logger.error(event.toString());
            return "{}";
        }

        // 推送事件到Redis
        String key = String.format(EVENT_FORMAT, event);
        redisTemplate.opsForList().rightPush(key, json);
        return json;
    }

}

测试推送内容:

# 启动后请求本地推送连接, POST内容推送JSON数据
http://127.0.0.1/api/event/098F6BCD4621D373CADE4E832627B4F6/1000

这里就是个简单数据打点监听器, 后续可以后台跑脚本把队列数据写入关系数据库, 然后细化写注册token细节写入等.

但是这种方式之前就说了, 主要 Web 接口通病就是需要走 DNS + 内网负载转发 + Redis连接池协议推送, 对于游戏服端来说这种集成模式并不适合内置集成.

搭建本地Redis打点接口

这种常规都是游戏服务端内置即可, 之前公司都是 skynet 集成推送本地 Redis 异步写入队列即可, 然后其他常驻后台的服务提取打点信息到关系型数据库即可.

这种没什么好说的, 基本上服务端关键调用下就行了.

搭建本地日志推送

这种方式和上面一致, 就是本地日志异步推送给远程清洗即可, 所以不做太多赘述, 总体思路都是怎么解决低延迟的问题.