
简介这份资源是面向游戏服务器开发者的完整源码包基于轻量级高并发框架Skynet构建重点解决游戏后端与MySQL、Redis两类数据库的协同交互问题。源码中实现了数据库访问层将游戏逻辑产生的数据操作转换为SQL语句执行并设计了缓存管理机制把热点数据存入Redis以减轻数据库压力、加快读取速度同时涉及连接池、异步IO处理与负载均衡等性能优化思路适合具备一定Lua与网络编程基础、希望深入理解游戏服务器架构的开发者参考学习。压缩包共22个文件约619KB以lua脚本为主体辅以py、pyc、proto、pb等文件涵盖服务模块、配置节点、客户端与协议定义等内容目录结构清晰。目前已有132人学习下载可帮助读者梳理Skynet服务划分、数据库交互与缓存设计的实现脉络并从中借鉴监控日志与安全防护的工程思路。1. 从一份 skynet 游戏服务端源码说起MySQL 与 Redis 到底怎么分工如果你正在找一个能跑起来的游戏服务端骨架而不是那种只有目录结构的空壳这份基于 skynet 框架、同时接了 MySQL 和 Redis 的源码包值得拆一拆。skynet 是轻量级 actor 模型的服务端框架每个服务是一个独立 Lua 虚拟机服务之间靠消息通信天然适合做网关、战斗、排行榜这类需要横向拆分的模块。这份源码把持久化层拆成两路MySQL 存账号、角色、背包这类必须落盘且要事务保证的数据Redis 扛排行榜、在线状态、跨服缓存这类高频读写、允许短暂不一致的数据。适合谁正在搭第一个游戏服务端、或者手里有 skynet 但持久化层一直没理顺的开发者。下面按「先跑通、再拆模块、最后避坑」的顺序走一遍。2. 环境搭建与首次启动从零把服务跑起来2.1 依赖清单与版本选择skynet 对系统库有硬依赖MySQL 和 Redis 的客户端库又各自有坑先把版本对齐能省掉一半的编译报错。我一般会固定下面这套组合不追最新版因为 skynet 的 C 模块对 Lua 版本和编译选项比较敏感。组件建议版本说明操作系统Linux x86_64skynet 在 Linux 下编译最顺macOS 需要额外处理动态库路径Lua5.4skynet 自带 lua 源码但第三方 C 模块要按 5.4 编译skynet随源码包附带不要单独去拉最新版源码包里的版本和业务代码是配套的MySQL5.7 或 8.08.0 默认认证插件变了老客户端连不上要改Redis5.0 以上用到 stream 或新命令才需要 6.x普通缓存 5.0 够gcc / make系统自带编译 skynet 和 C 服务用版本选完先确认系统里有没有这几个开发库缺了会在 make 阶段报cannot find -lmysqlclient这类错。# 检查 MySQL 和 Redis 客户端开发库是否就位 ldconfig -p | grep mysqlclient ldconfig -p | grep hiredis # 如果没有按发行版装以 Debian 系为例 apt-get install -y libmysqlclient-dev libhiredis-dev这两条命令的作用是确认动态链接库能被系统找到。ldconfig -p列出当前缓存里的所有共享库grep 过滤出目标。如果输出为空说明开发库没装编译时链接阶段一定失败。装完库之后不需要重启但要让 ldconfig 重新扫描一次部分发行版装包时自动做了。2.2 编译 skynet 与启动顺序skynet 的编译分两步先编框架本身再编业务用到的 C 服务。源码包里通常有一个makeall.sh或者顶层 Makefile但直接跑之前先看一眼 3rd 目录下有没有需要单独编的库。# 进入源码根目录 cd skynet-server # 编译 skynet 核心-j 后面跟 CPU 核数加快编译 make linux -j4 # 如果源码包把 C 服务单独放了目录进对应目录再 make cd service makemake linux是 skynet 的标准编译目标它会编译出skynet可执行文件和一批基础 C 服务。-j4是并行编译核数越多越快但报错信息会交错第一次编译建议不加-j方便定位错误。编译完成后根目录会出现skynet可执行文件这是整个服务端的入口。启动顺序有讲究MySQL 和 Redis 必须先于 skynet 就绪否则 skynet 里的连接服务会在初始化阶段直接失败退出。我习惯写一个启动脚本把顺序固定下来。# 先确认 MySQL 和 Redis 在跑 systemctl status mysql systemctl status redis # 再启动 skynet配置文件路径按源码包实际位置改 ./skynet config/config.luaconfig/config.lua是 skynet 的启动配置里面定义了thread数量、bootstrap入口服务、logger路径等。thread一般设成 CPU 核数设太大反而因为上下文切换掉性能。bootstrap指向第一个被启动的服务通常是main服务由它再去拉起网关、数据库代理等模块。启动后如果看到日志里打印出各服务启动成功的记录说明骨架跑通了。2.3 数据库连接配置怎么改源码包里数据库配置一般集中在一个 Lua 文件里常见命名是config/db.lua或config/mysql.lua。改之前先确认 MySQL 里已经建好了对应的库和表表结构通常在sql/目录下有一个.sql文件。-- config/db.lua 示例结构 return { mysql { host 127.0.0.1, port 3306, database game_db, user game_user, password your_password, charset utf8mb4, max_conn 8, -- 连接池上限 timeout 5000, -- 毫秒 }, redis { host 127.0.0.1, port 6379, db 0, auth , -- 没设密码就留空 pool_size 4, }, }max_conn是 MySQL 连接池上限游戏服务端一般不需要太大8 到 16 足够因为 skynet 的数据库代理服务是串行处理请求的连接开多了也是排队。timeout设 5000 毫秒是保守值跨机房部署要适当放大。Redis 的db索引按业务分比如 0 号库存会话、1 号库存排行榜避免不同业务互相干扰。auth留空表示没设密码生产环境一定要设。提示改完配置不要急着全量启动先用一个最小测试服务连一下两个库确认账号密码和网络都通再启动完整业务。3. MySQL 持久化层账号、角色与背包的落盘逻辑3.1 为什么用连接池而不是每次新建连接游戏服务端对数据库的请求特点是「高频、短小」——一次登录要查账号、查角色、更新最后登录时间可能几百毫秒内就有好几个查询。如果每个查询都新建一条 MySQL 连接光 TCP 握手和认证就吃掉大部分时间QPS 根本上不去。连接池的做法是启动时建好固定数量的连接请求来了从池里取用完还回去省掉反复建连的开销。源码包里通常有一个mysql_pool.lua或类似文件核心逻辑是维护一个空闲连接队列。取连接时如果队列为空且未达上限就新建达到上限就等待。这里有个容易翻车的点等待没有超时的话一旦有连接泄漏借了没还整个池会被慢慢耗尽表现为服务运行一段时间后所有数据库请求卡死。-- 连接池取连接的核心逻辑简化示意 function pool:acquire() local conn table.remove(self.idle) if conn then return conn end if self.count self.max_conn then self.count self.count 1 return self:create_conn() end -- 池满等待归还带超时 return self:wait_for_idle(self.timeout) end function pool:release(conn) if conn.broken then self.count self.count - 1 return end table.insert(self.idle, conn) endacquire先从空闲队列拿拿不到且没到上限就新建到了上限就等。release时如果连接已损坏比如查询超时被服务端断开要把它从计数里减掉不能放回空闲队列否则下次取出来还是坏的。这个「坏连接检测」是很多简易连接池漏掉的一步也是线上偶发查询失败的常见原因。3.2 角色数据的读写分离与事务边界角色数据里有些操作必须在一个事务里完成比如「扣元宝 加道具」两步要么都成功要么都回滚。源码包里一般会把这类操作封装成一个存储过程或者在 Lua 层用事务包起来。-- 扣元宝加道具放在一个事务里 local conn mysql_pool:acquire() conn:query(BEGIN) local ok1 conn:query(string.format( UPDATE role SET gold gold - %d WHERE rid %d AND gold %d, cost, rid, cost)) if not ok1 or conn:affected_rows() 0 then conn:query(ROLLBACK) mysql_pool:release(conn) return false, gold_not_enough end local ok2 conn:query(string.format( INSERT INTO bag (rid, item_id, count) VALUES (%d, %d, %d) .. ON DUPLICATE KEY UPDATE count count %d, rid, item_id, count, count)) if not ok2 then conn:query(ROLLBACK) mysql_pool:release(conn) return false, insert_failed end conn:query(COMMIT) mysql_pool:release(conn) return true这段逻辑的关键在UPDATE语句里的AND gold cost条件。把余额判断放进 SQL 的 WHERE 里而不是先查再判断再更新能避免并发下的超扣问题——两个请求同时读到余额足够各自扣一次结果扣成负数。放进 WHERE 后数据库的行锁保证只有一个能更新成功另一个affected_rows为 0直接回滚。ON DUPLICATE KEY UPDATE是 MySQL 的语法道具已存在就累加数量不存在就插入省掉一次查询。3.3 表结构设计与索引注意点源码包的sql/目录里通常有建表语句直接导入即可但有几个字段设计值得留意。角色表的主键一般是rid角色 ID账号表主键是account背包表用(rid, item_id)做联合唯一索引。如果背包表只建了自增主键没建联合唯一索引ON DUPLICATE KEY UPDATE就不生效会插入重复道具。-- 背包表的关键索引 CREATE TABLE bag ( id bigint NOT NULL AUTO_INCREMENT, rid bigint NOT NULL, item_id int NOT NULL, count int NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_rid_item (rid, item_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_rid_item这个联合唯一索引是ON DUPLICATE KEY UPDATE能工作的前提。没有它每次加道具都会插新行背包里同一个道具出现多条记录读取时还要做聚合越往后越慢。另外count字段用int而不是varchar避免字符串比较带来的隐式转换和索引失效。注意导入建表语句前先确认 MySQL 的sql_mode如果开了STRICT_TRANS_TABLES插入超长字符串会直接报错而不是截断这在不同环境迁移时经常导致「本地能跑线上报错」。4. Redis 缓存层排行榜、会话与跨服数据4.1 排行榜用有序集合而不是数据库排序排行榜是 Redis 在游戏服务端里最典型的用法。如果用 MySQL 做每次刷新排行榜都要ORDER BY score DESC LIMIT 100数据量一大就慢而且高频刷新会把数据库拖垮。Redis 的ZSET有序集合天生就是干这个的插入和更新是 O(log N)取前 N 名是 O(log N M)。-- 更新玩家分数并获取排名 local redis redis_pool:acquire() -- ZADD 更新分数分数变了排名自动调整 redis:zadd(rank:level, score, role: .. rid) -- ZREVRANGE 取前 100 名带分数 local top redis:zrevrange(rank:level, 0, 99, WITHSCORES) -- ZREVRANK 查某个玩家当前排名从 0 开始展示时加 1 local rank redis:zrevrank(rank:level, role: .. rid) redis_pool:release(redis)ZADD的分数用等级或者战力值成员用role:rid这种带前缀的字符串方便区分不同业务的数据。ZREVRANGE从高到低取WITHSCORES让返回结果带上分数省一次查询。ZREVRANK返回的是从 0 开始的排名前端展示要加 1。这里有个细节如果玩家分数没变重复ZADD不会产生新成员只是更新分数所以可以放心地在每次分数变化时调用。4.2 会话与在线状态过期时间是关键玩家登录后服务端需要记录「这个账号当前在哪个网关、哪个角色在线」这类数据放 Redis 并设置过期时间比放内存更可靠——网关重启后状态还在玩家不会莫名掉线。源码包里一般用SETEX或SET带EX参数来写。-- 记录在线状态30 分钟过期 local key online: .. account redis:setex(key, 1800, gateway_id) -- 心跳时续期 redis:expire(key, 1800) -- 查询某账号在哪个网关 local gw redis:get(key)setex的第二个参数是秒数1800 表示 30 分钟。心跳续期用expire重置过期时间这样只要玩家还在线key 就不会消失玩家异常掉线没发心跳30 分钟后 key 自动过期状态自然清理不需要额外的清理任务。这个「过期即下线」的设计比手动维护在线列表省事得多也避免了服务崩溃后残留脏数据。4.3 缓存与数据库的一致性处理Redis 里的数据和 MySQL 里的数据难免有短暂不一致比如玩家改了昵称MySQL 更新成功但 Redis 更新失败。源码包里常见的处理策略是「先写库再删缓存」而不是「先写库再更新缓存」。-- 更新昵称先写 MySQL再删 Redis 缓存 local ok mysql:query(string.format( UPDATE role SET name %s WHERE rid %d, new_name, rid)) if ok then redis:del(role:info: .. rid) -- 删缓存下次读时重建 end为什么是删而不是更新因为更新缓存需要把完整数据重新组装一遍如果组装逻辑和读取逻辑不一致缓存里就会留下错误数据。删掉之后下次读取时走「缓存未命中 → 查库 → 写缓存」的标准流程数据一定是库里的最新值。这个策略的代价是下一次读会穿透到数据库但游戏场景里昵称修改频率很低完全可以接受。提示删缓存也可能失败如果对一致性要求高可以在删失败后把 key 丢进一个重试队列由后台服务异步补偿。5. 避坑与排查那些让服务半夜挂掉的细节5.1 连接池耗尽导致所有请求卡死现象服务运行几小时后所有涉及数据库的操作全部超时日志里没有明显报错只是请求一直不返回。原因某条代码路径借了连接但没归还通常是查询中途抛异常release没被执行到。连接池的空闲队列逐渐见底新请求全部卡在等待上。解决把acquire和release用pcall包起来确保异常时也能归还。更稳妥的做法是在连接池里加一个「借出时间戳」后台定时扫描超过阈值未归还的连接强制回收并打日志这样即使有泄漏也能自愈。5.2 MySQL 8.0 认证插件不兼容现象本地用 MySQL 5.7 一切正常换到 MySQL 8.0 后连接直接报Authentication plugin caching_sha2_password cannot be loaded。原因MySQL 8.0 默认认证插件从mysql_native_password换成了caching_sha2_password老版本的客户端库不认识。解决要么升级客户端库要么把账号的认证插件改回去ALTER USER game_user% IDENTIFIED WITH mysql_native_password BY password;。改完FLUSH PRIVILEGES生效。生产环境建议直接升级客户端库改插件只是临时方案。5.3 Redis 大 key 拖慢整个实例现象排行榜相关操作偶尔卡顿Redis 慢查询日志里出现ZRANGE耗时几百毫秒。原因排行榜的 ZSET 成员数过多比如把所有玩家都塞进一个 key几十万成员时ZRANGE虽然复杂度不高但网络传输和序列化开销大。解决按区服或时间段拆分 key比如rank:level:s1、rank:level:s2每个 key 只存本服玩家。取全服排名时用ZUNIONSTORE合并或者干脆只展示本服排名。另外定期清理不活跃玩家减少成员数。5.4 时区不一致导致时间字段错乱现象数据库里存的时间比实际时间差 8 小时或者跨时区部署时登录时间对不上。原因MySQL 服务端时区、客户端连接时区、Lua 里os.time()的时区三者不一致。解决统一用 UTC 时间戳存储展示时再转本地时区。连接 MySQL 时显式设置SET time_zone 00:00Lua 里用os.time()拿到的就是 UTC 秒数。这样无论服务器部署在哪里存进去的时间都是可比的。5.5 skynet 服务阻塞导致消息堆积现象某个服务比如战斗计算处理变慢发给它的消息在队列里越堆越多最终内存暴涨。原因skynet 的服务是单线程处理消息的如果某个消息的处理逻辑里有同步阻塞操作比如同步查数据库整个服务的消息队列就会卡住。解决把阻塞操作拆到独立的服务里用异步消息回调的方式处理。比如数据库查询交给专门的db服务业务服务发请求后继续处理其他消息等db服务回包再继续。这是 skynet 的核心用法也是新手最容易违反的一条。6. 进阶技巧用压测和日志把问题提前暴露源码包跑通只是第一步真正上线前得知道它能扛多少并发。我一般会写一个简单的压测脚本模拟 N 个玩家同时登录、查角色、写背包观察 QPS 和延迟。skynet 自带一个skynet.abort和调试控制台但压测更适合用外部工具。# 用 wrk 压测网关的登录接口假设网关监听 8888 wrk -t4 -c100 -d30s --latency \ -s login.lua \ http://127.0.0.1:8888/login-t4是 4 个线程-c100是 100 个并发连接-d30s压 30 秒--latency输出延迟分布。login.lua是自定义脚本构造登录请求的 body。重点看Latency的 P99 值如果 P99 超过 200 毫秒说明数据库或 Redis 那边有瓶颈要回去查慢查询。日志方面skynet 的logger服务会把日志写到文件但默认级别可能不够细。我习惯在数据库代理服务里加一条「慢查询日志」超过 100 毫秒的查询把 SQL 和耗时打出来。-- 在数据库代理服务里包一层耗时统计 local start skynet.now() local result conn:query(sql) local cost skynet.now() - start if cost 100 then skynet.error(string.format(slow query: %dms, sql%s, cost, sql)) endskynet.now()返回的是厘秒1/100 秒所以 100 对应 1 秒不对skynet 的now单位是厘秒100 厘秒等于 1 秒。要统计 100 毫秒应该用 10。这个单位换算我踩过坑一开始按毫秒算结果慢查询日志一条都不打排查半天才发现是单位问题。从那以后我每次用skynet.now()做耗时统计都强制在代码注释里写清单位并且用skynet.now() - start的结果和os.clock()对一遍确认量级没错。压测和慢日志配合基本能在上线前把大部分性能问题暴露出来。剩下的就是根据压测结果调连接池大小、Redis 超时、skynet 线程数这些参数反复几轮直到 P99 稳定在可接受范围。这套流程走下来这份源码包就不只是「能跑」而是「敢用」了。希望帮到你。本文还有配套的精品资源点击获取