多节点erlang实现排行榜

排行榜估计是个游戏都会做,云风也聊过,点击这里 查看,温馨提示,如果不想看八卦,直接跳到最后去。云风讲得也比较简单,好理解,但是实际在erlang操作细节蛮多,特别是我不想单点,要多节点。

总体上,在erlang做排行榜的话,思路还是云风那样的。

最初,我是用了mnesia的order set来维护前k个(精确的),全dirty操作,检测是否小于最后一名,否则不给进,由于并发原因会超出预定长度,适当做尾巴清理。但是后来计算排名的时候,需要遍历整表,当时规模是2W,然后取最后一名就慢太多了。ETS是百万分之一秒,遍历这么多次,每秒能处理就很有限了。大概实现看https://gist.github.com/roowe/296b2ab99fc05320188c ,hdb和mnesia接口基本一致,我们做了简单包装,加点统计信息什么的。

后来,想了下优化方案,只能从减少计算排名的时候遍历入手,怎么样可以少点遍历呢?做主排序值的计数器,用update_counter效率应该挺高的。同值排多少名,加上比这个值往前还有多少人就可以算出rank了,这样开销也就是遍历所有的主排序值(我们这个值就是1000-4000范围),从2W降到这个,基本毫无压力了。这个思路也是云风blog提到的做法,我只是加了同个主排序值的相对排名,他们家的相对排名是0,共用前面排名+1了,对于玩家来讲确实没多少区别,特别是后面的。

插曲,dirty_update_counter运行过程中是对,重启服务器之后会有数据出入,简单说,就是计数器重启之后记得不对,这个有点麻烦,目测是ets从dets和log加载数据有点问题,换成read和write就没有问题,暂时就用这个方法绕开了。

最后,准备完工的时候,当时测试,都是开一个节点一个进程,所以不会报错,也很快了。然后上多进程,一口气开了1W进程,然后mnesia狂报警告了,降到3k也是一样(稍微好点),开始出现清理尾巴资源竞争过于厉害的问题,基本让服务器处于不可用的状态。

后来,经过一夜的考虑,决定加事务去处理了,开了事务,开3k进程,跑了2min才跑完,略坑,主要是并发过于厉害,锁的问题。但是单进程又很快可以跑完,1s多。

再之后,决定挂个gen_server挡住消息来排队,然后清理也从插入分离,处理完所有消息之后,触发timeout之后才进行尾巴清理。

总体讲,多个节点,分别跑个排行榜进程,事务处理写入排行榜,触发timeout清理尾巴,性能上满足几千人在线,甚至1W在线,有兴趣就参考https://gist.github.com/roowe/a1c0aeabda42f53db608

Erlang简易的MySQL ORM实现

目的

在此之前,我写过一篇,如何在erlang去拼接SQL语句,《在erlang简单使用SQL语句》

现在,在这个基础上,对record做一层封装,我将一个#player{}扔到某个函数,就能帮我写数据库或者,删掉,或者更新等的一些常用几种db操作。

注:本文讨论的是,针对单record,也就是一张表,所以下面,你也不会看到多张表,联表什么的,现在大家只是把数据库做持久化而已,逻辑全部在内存做。也就是说player和goods要搞基,也是在内存,而不是通过SQL语句。

老项目的做法

%% 更新用户卡数据
update_user_card(RecUserCard) ->
    [id | FieldList] = record_info(fields, rec_user_card),
    ValueList = lists:nthtail(2, tuple_to_list(RecUserCard)),
    ZipList = lists:zip(FieldList, ValueList),
    db_center:update(user_card, ZipList, [{id, RecUserCard#rec_user_card.id}]).

上面这段代码比较典型,抽象地讲,update无非就是db table update_clause, where_clause等几个东西,但是我们代码当时各种复制粘帖,有时候对这些代码还是比较烦躁。

这种,相比那些直接将字段写死在代码又稍微好点,数据库将nickname2改为nickname的时候,代码不用动,因为我们做了从数据库映射成record声明的东西,声明变了,也就是record_info变了,再后面也就和数据库对上了。

新的做法

声明一个record_mysql_info,然后新加表的时候,声明下里面的定义。接着传给db_mysql_base就可以,什么zip之类就不用写了。

没有带r的就是针对这个表的操作,带r的就是传record进来。考虑有时候并不是操作record,而是操作该表,所以提供通用的没有record的操作,再特殊点,就直接去调erl_mysql,还不行就能自己拼写SQL(假设多表操作很少用,就不做支持了)。

核心代码也就那么一点点啦。

%% 约定:
%% 1、第一个字段是键值,作为key存在,跟mnesia的规则一样,简化底层代码,提高运行效率,通过约束减少不必要的运算
-module(db_mysql_base).
 
-export([select/2, select/3, update/3, delete/2]).
 
-export([
         r_update/2,
         r_delete/2,
         r_insert/2, 
         r_list_insert_withnot_id/2,
         r_list_insert_with_id/2
        ]).
 
-include_lib("emysql/include/emysql.hrl").
-record(record_mysql_info,{
          db_pool,
          table_name,
          fields,
          record_name,
          mod
         }).
 
 
select(RecordMysqlInfo, WhereClause) -> 
    select(RecordMysqlInfo, WhereClause, undefined).
 
select(#record_mysql_info{
          db_pool = DbPool,
          table_name = TableName,
          record_name = RecordName,
          mod = ModelMod
         }, WhereClause, Extras) ->
    SQL = iolist_to_binary(erl_mysql:select('*', TableName, WhereClause, Extras)),
    run_rows(DbPool, SQL,
             fun(List) ->
                     [ModelMod:out_db_hook(list_to_tuple([RecordName|Vals])) || Vals <- List]
             end).
 
update(#record_mysql_info{
          db_pool = DbPool,
          table_name = TableName
         }, UpdateClause, WhereClause) ->
    SQL = iolist_to_binary(erl_mysql:update(TableName, UpdateClause, WhereClause)),
    run_affected(DbPool, SQL).
 
delete(#record_mysql_info{
          db_pool = DbPool,
          table_name = TableName
         }, WhereClause) ->
    SQL = iolist_to_binary(erl_mysql:delete(TableName, WhereClause)),
    run_affected(DbPool, SQL).
 
%% 下面带r的是,是针对record的接口
%% 返回值{ok, Record},会自动处理,需不需要加上insert_id
%% 或者{error, Result}
r_insert(#record_mysql_info{
            db_pool = DbPool,
            table_name = TableName,
            fields = [_|RestFields] = Fields,
            mod = ModelMod
         }, Record) when is_tuple(Record)->
    [UndefId|RestVals] = Vals = record_to_vals(ModelMod, Record),
    {FilterUndefIdFields, FilterUndefIdVals} = 
        if
            UndefId =:= undefined ->
                {RestFields, RestVals};
            true ->
                {Fields, Vals}
        end,    
    SQL = iolist_to_binary(erl_mysql:insert(TableName, {FilterUndefIdFields, [FilterUndefIdVals]})),
    case emysql:execute(DbPool, SQL) of
        Result when is_record(Result, ok_packet) ->
            if 
                UndefId =:= undefined ->
                    {ok, setelement(2, Record, emysql_util:insert_id(Result))};
                true ->
                    {ok, Record}
            end;
        Result when is_record(Result, error_packet) ->
            {error, Result}
    end.
 
%% 多行插入的混合接口的返回值实在不好做,干脆要么有id要么没有id,
%% 在表设计的时候已经决定了这点,正常设计不会出现有时候要向MySQL要id,有时候又不要。
%% 多行插入也就日志系统使用会有一定的优化,其他场合应该都一条一条插入
r_list_insert_withnot_id(#record_mysql_info{
                            db_pool = DbPool,
                            table_name = TableName,
                            fields = [_|RestFields],
                            mod = ModelMod
                           }, RecordList) 
  when is_list(RecordList) ->    
    FilterUndefIdValsList = [tl(record_to_vals(ModelMod, Record)) || Record <- RecordList],
    SQL = iolist_to_binary(erl_mysql:insert(TableName, {RestFields, FilterUndefIdValsList})),    
    run_affected(DbPool, SQL).
 
r_list_insert_with_id(#record_mysql_info{
                         db_pool = DbPool,
                         table_name = TableName,
                         fields = Fields,
                         mod = ModelMod
                        }, RecordList) 
  when is_list(RecordList) ->
    ValsList = [record_to_vals(ModelMod, Record) || Record <- RecordList],
    SQL = iolist_to_binary(erl_mysql:insert(TableName, {Fields, ValsList})),    
    run_affected(DbPool, SQL).
 
 
%% update list的返回值不好定,所以不支持
r_update(#record_mysql_info{
            db_pool = DbPool,
            table_name = TableName,
            fields = [IdField|RestFields],
            mod = ModelMod
           }, Record) ->
    [Id|RestVals] = record_to_vals(ModelMod, Record),
    WhereClause = {IdField, '=', Id},
    UpdateClause = lists:zip(RestFields, RestVals),
    SQL = iolist_to_binary(erl_mysql:update(TableName, UpdateClause, WhereClause)),
    run_affected(DbPool, SQL).
 
 
r_delete(#record_mysql_info{
            db_pool = DbPool,
            table_name = TableName,
            fields = [IdField|_]
           }, Record) 
  when is_tuple(Record) ->
    Id = element(2, Record),
    WhereClause = {IdField, '=', Id},
    SQL = iolist_to_binary(erl_mysql:delete(TableName, WhereClause)),
    run_affected(DbPool, SQL);
r_delete(#record_mysql_info{
            db_pool = DbPool,
            table_name = TableName,
            fields = [IdField|_]
           }, RecordList) 
  when is_list(RecordList) ->
    Ids = [element(2, Record) || Record <- RecordList],
    WhereClause = {IdField, 'in', Ids},
    SQL = iolist_to_binary(erl_mysql:delete(TableName, WhereClause)),
    run_affected(DbPool, SQL).
 
 
%% -------------------- emysql封装 --------------------
run_affected(DbPool, SQL) ->
    case emysql:execute(DbPool, SQL) of
        Result when is_record(Result, ok_packet) ->
            {ok, emysql_util:affected_rows(Result)};
        Result when is_record(Result, error_packet) ->
            {error, Result}
    end.
 
%% run_rows(DbPool, SQL) ->
%%     run_rows(DbPool, SQL, fun(A) -> A end).
 
run_rows(DbPool, SQL, ResultFun) ->
    case emysql:execute(DbPool, SQL) of
        #result_packet{
           rows = Result
          } ->
            {ok, ResultFun(Result)};
        Result when is_record(Result, error_packet) ->
            {error, Result}
    end.
 
%% in_db_hook 存入数据库之前的操作
record_to_vals(ModelMod, Record) 
  when is_tuple(Record)->
    tl(tuple_to_list(ModelMod:in_db_hook(Record)));
record_to_vals(ModelMod, RecordList) 
  when is_list(RecordList)->
    [tl(tuple_to_list(ModelMod:in_db_hook(Record))) || Record <- RecordList].

使用例子

-module(db_player).
 
-export([select_by_id/1]).
 
-export([in_db_hook/1, out_db_hook/1]).
 
-include("db_player.hrl").
-include("define_mysql.hrl").
 
-define(TABLE_CONF, #record_mysql_info{
                       db_pool = db_game,
                       table_name = player,
                       record_name = player,
                       mod = ?MODULE,
                       fields = record_info(fields, player)
                      }).
 
 
%% --------------------通用代码--------------------
-export([update/1, delete/1, insert/1, r_list_insert_withnot_id/1, r_list_insert_with_id/1]).
 
update(Record)->
    db_mysql_base:r_update(?TABLE_CONF, Record).
 
delete(RecordOrList) ->
    db_mysql_base:r_delete(?TABLE_CONF, RecordOrList).
 
insert(Record) ->
    db_mysql_base:r_insert(?TABLE_CONF, Record).
 
r_list_insert_withnot_id(List) ->
    db_mysql_base:r_list_insert_withnot_id(?TABLE_CONF, List).
 
r_list_insert_with_id(List) ->
    db_mysql_base:r_list_insert_with_id(?TABLE_CONF, List).
 
in_db_hook(Record) ->
    Record.
 
out_db_hook(Record) ->
    Record
 
%% ----------------------------------------
 
select_by_id(Id) ->
    db_mysql_base:select(?TABLE_CONF, {id, '=', Id}).

有了db_player,我们就传#player{}就可以了,再也不用关心数据库字段啥,拼SQL啥,哪个表什么的等等,配好定义,一劳永逸。

浅谈游戏配置数据在erlang使用

ETS

在之前的手游项目(多服)的时候,我们的配置数据是从MySQL加载到ETS,然后从ETS里面lookup,加载的时候还用到string_to_term这种比较实用的方法,将配置里面的string转为erlang term,这样配置一些复杂的东西的时候,可以简化配置,算是DSL吧。比如任务系统,达成条件配成了,"{lv, 40}",表示这个任务40级的时候就可以完成了,在MySQL或者xsl里面就是字符串,但是在erlang里面就是个tuple,使用的时候,来个匹配就可以。然后有其他需求,爱怎么加就怎么加,比如我们还有强化1次,{equip_upgrade, 1},程序处理匹配就可以了。

使用心得

好处:支持全遍历和选择遍历,写个match_spec就可以了,查询也很快。

不足:每张表都需要写个load data函数,不过这个问题不大,封装下,基本就几行代码功夫,最初没封,都是复制粘帖,累。分布式多节点热更新不方便,要去每个节点触发reload data,有点不方便。

example

load_base_data(DbTable, EtsTable, HandleFun) ->
    L = db_base:select_all(DbTable, "*", []),
    hetsutil:lock(EtsTable),
    try
        hetsutil:truncate(EtsTable),
        lists:foreach(fun(Info) ->
                              RecInfo = HandleFun(Info),
                              hetsutil:insert(EtsTable, RecInfo)
                      end, L)
    catch _:R ->
            ?WARNING_MSG("load ~p failed R:~w Stack: ~p~n",[DbTable, R, erlang:get_stacktrace()])
    after
        hetsutil:unlock(EtsTable)
    end,
    ok.
 
init_base_dungeon() ->
    hmisc:load_base_data(base_dungeon, ?ETS_BASE_DUNGEON, fun handle_base_dungeon_from_db/1).
 
handle_base_dungeon_from_db(DbBase) ->
    RecBase = list_to_tuple([base_dungeon | DbBase]),
    RecBase#base_dungeon{
      reward = lib_reward:convert_common_reward(util:to_term_list(RecBase#base_dungeon.reward))
     }.

代码就不解释,大概意思就那样,不能直接用的。

生成代码

一开始,我觉得这个方式会有性能问题,而且麻烦,当时一开始同事的做法是用php写生成erlang代码的(其实就是字符串),有啥字段写啥,拼起来,很累。后来,我测试了ETS和生成代码的方式,ETS在高并发(瞬间几百或上千)的情况下,会稍微有点性能差,实际上,一般不会遇到这么BT的情况,其他情况两者差不多(印象中4倍),生成代码,编译之后内部估计也是hash,所以性能差不多。

这东西,最初我是比较反感用php写死那些字段,新加一张表,又得这样来一次,万一有100个字段,不是写数据生成的php代码,就得花上点时间。不过当时项目,我是后期加入,没写多少业务代码,没怎么被这php坑。后来,想通了,我就用erlang写了一套,上层只需要声明函数,每个字段是否需要特殊处理(转为term,或者record等),索引返回id等基础功能。这样新加表,写点定义即可,比以前有100倍+的生产力提高。

再后来,我了解有些团队是用VB生成erlang代码的(比如dcy),我面试一个主程,他的做法和dcy家一样,就是简单的get和all_id两个函数,不支持term的表示。有些东西,还是看团队需求吧,像dcy他们家那样确实简单,都不要写配置定义。

使用心得

好处:上线部署不需要外部依赖(如外部的csv,MySQL等),代码即配置。热更方便。 坏处:遍历比较麻烦,特别是像SELECT * FROM base WHERE type=1这种取某一类数据,再或者SELECT * FROM base WHERE lv>=10 ANDALSO lv=< 45,这种区间检索。不过,我后来有个比较赞的办法,就是对lv和type这个字段加人肉索引,这样可以减少遍历。

example

使用代码就不贴,写得比较搓,贴下生成的效果代码。

数据源大概就像下面这样。先简单的解释下,这是抄某个游戏的签到,reward字段在Excel里面是个字符串,格式是{BaseId, Num}。如果把它当成字符串的话,erlang代码就像reward = "{BaseId, Num}"了,但实际上,我们不加双引号,就是成了erlang的term了。其实,这里并不是转为term,而是term的更高一层,record,这样发奖励,我不用再写一层转化。还有个配置,player_card是跟策划约定好去另外一张表找BaseId,因为数量相同,只是每个月BaseId不一样,即data_base_login_reward_player_card:get(hmisctime:get_month())。

这个生成的代码,dcy他们家那种是做不到的,目测他们也不想要这种坑爹的feature。

id  reward  vip_double
1   {3,5000}    1
2   {1,20}  0
3   {26000003,1}    2
4   {3,7500}    0
5   {player_card,2} 3
%% Warning:本文件由data_generate自动生成,请不要手动修改
-module(data_base_login_reward).
 
-export([get/1]).
 
-include("common.hrl").
 
-include("define_reward.hrl").
 
-include("db_base_login_reward.hrl").
 
get(1) ->
    #base_login_reward{id = 1,
               reward = #reward_item{base_id = 3, num = 5000},
               vip_double = 1};
get(2) ->
    #base_login_reward{id = 2,
               reward = #reward_item{base_id = 1, num = 20},
               vip_double = 0};
get(3) ->
    #base_login_reward{id = 3,
               reward = #reward_item{base_id = 26000003, num = 1},
               vip_double = 2};
get(4) ->
    #base_login_reward{id = 4,
               reward = #reward_item{base_id = 3, num = 7500},
               vip_double = 0};
get(5) ->
    #base_login_reward{id = 5,
               reward =
               #reward_item{base_id =
                        data_base_login_reward_player_card:get(hmisctime:get_month()),
                    num = 2},
               vip_double = 3};
get(6) ->
    #base_login_reward{id = 6,
               reward = #reward_item{base_id = 3, num = 10000},
               vip_double = 4};
get(7) ->
    #base_login_reward{id = 7,
               reward = #reward_item{base_id = 1, num = 40},
               vip_double = 0};
get(8) ->
    #base_login_reward{id = 8,
               reward = #reward_item{base_id = 26000003, num = 2},
               vip_double = 5};
get(9) ->
    #base_login_reward{id = 9,
               reward = #reward_item{base_id = 3, num = 15000},
               vip_double = 0};
get(10) ->
    #base_login_reward{id = 10,
               reward =
               #reward_item{base_id =
                        data_base_login_reward_player_card:get(hmisctime:get_month()),
                    num = 3},
               vip_double = 6};
get(11) ->
    #base_login_reward{id = 11,
               reward = #reward_item{base_id = 3, num = 15000},
               vip_double = 7};
get(12) ->
    #base_login_reward{id = 12,
               reward = #reward_item{base_id = 1, num = 60},
               vip_double = 0};
get(13) ->
    #base_login_reward{id = 13,
               reward = #reward_item{base_id = 26000003, num = 3},
               vip_double = 8};
get(14) ->
    #base_login_reward{id = 14,
               reward = #reward_item{base_id = 3, num = 22500},
               vip_double = 0};
get(15) ->
    #base_login_reward{id = 15,
               reward =
               #reward_item{base_id =
                        data_base_login_reward_player_card:get(hmisctime:get_month()),
                    num = 4},
               vip_double = 9};
get(16) ->
    #base_login_reward{id = 16,
               reward = #reward_item{base_id = 3, num = 20000},
               vip_double = 10};
get(17) ->
    #base_login_reward{id = 17,
               reward = #reward_item{base_id = 1, num = 80},
               vip_double = 0};
get(18) ->
    #base_login_reward{id = 18,
               reward = #reward_item{base_id = 26000003, num = 4},
               vip_double = 11};
get(19) ->
    #base_login_reward{id = 19,
               reward = #reward_item{base_id = 3, num = 30000},
               vip_double = 0};
get(20) ->
    #base_login_reward{id = 20,
               reward =
               #reward_item{base_id =
                        data_base_login_reward_player_card:get(hmisctime:get_month()),
                    num = 5},
               vip_double = 12};
get(21) ->
    #base_login_reward{id = 21,
               reward = #reward_item{base_id = 3, num = 40000},
               vip_double = 13};
get(22) ->
    #base_login_reward{id = 22,
               reward = #reward_item{base_id = 1, num = 100},
               vip_double = 0};
get(23) ->
    #base_login_reward{id = 23,
               reward = #reward_item{base_id = 26000003, num = 5},
               vip_double = 14};
get(24) ->
    #base_login_reward{id = 24,
               reward = #reward_item{base_id = 3, num = 40000},
               vip_double = 0};
get(25) ->
    #base_login_reward{id = 25,
               reward =
               #reward_item{base_id =
                        data_base_login_reward_player_card:get(hmisctime:get_month()),
                    num = 7},
               vip_double = 15};
get(26) ->
    #base_login_reward{id = 26,
               reward = #reward_item{base_id = 3, num = 50000},
               vip_double = 15};
get(27) ->
    #base_login_reward{id = 27,
               reward = #reward_item{base_id = 1, num = 120},
               vip_double = 0};
get(28) ->
    #base_login_reward{id = 28,
               reward = #reward_item{base_id = 26000003, num = 5},
               vip_double = 15};
get(29) ->
    #base_login_reward{id = 29,
               reward = #reward_item{base_id = 3, num = 50000},
               vip_double = 0};
get(30) ->
    #base_login_reward{id = 30,
               reward =
               #reward_item{base_id =
                        data_base_login_reward_player_card:get(hmisctime:get_month()),
                    num = 9},
               vip_double = 15};
get(31) ->
    #base_login_reward{id = 31,
               reward = #reward_item{base_id = 3, num = 50000},
               vip_double = 15};
get(Var1) ->
    ?WARNING_MSG("get not find ~p", [{Var1}]), [].

最后还是贴下,我们生成这个的上层代码,生成的代码看似比较碉堡,不过确实写不了多少代码。

base_login_reward() ->
    RewardFun = fun(RewardStr) ->
                        {NewBaseId, NewNum} =
                            case bitstring_to_term(RewardStr) of
                                {player_card, Num} ->
                                    {"data_base_login_reward_player_card:get(hmisctime:get_month())", Num};
                                {BaseId, Num} ->
                                    {BaseId, Num}
                            end,                        
                        lists:concat(["#reward_item{base_id = ", NewBaseId ,
                                      ", num = ", NewNum, "}"])
                end,
    Fields = trans_to_fun(record_info(fields, base_login_reward), [{reward, RewardFun}]),
    [base_login_reward, base_login_reward, ["define_reward.hrl",
                                            "db_base_login_reward.hrl"],
     [default_get_generate_conf(Fields, id)]].

关于人肉索引,其实是这样的。在上层代码,检索出满足的lv之后,拿到lv去换出ids,然后这些就是ids就是满足条件的。type更是简化后的,一个type对应一群ids。

ids_by_lv(1) -> [6, 5, 4, 3, 2, 1];
ids_by_lv(5) -> [12, 11, 10, 9, 8, 7];
ids_by_lv(11) -> [18, 17, 16, 15, 14, 13];
ids_by_lv(15) -> [24, 23, 22, 21, 20, 19];
ids_by_lv(20) -> [29, 28, 27, 26, 25];
ids_by_lv(25) -> [34, 33, 32, 31, 30];
ids_by_lv(Var1) ->
    ?WARNING_MSG("ids_by_lv not find ~p", [{Var1}]), [].
 
all_lv() -> [1, 5, 11, 15, 20, 25].

在erlang简单使用SQL语句

代码地址

erl_mysql.erl https://gist.github.com/roowe/8840321 和 erl_mysql_tests.erl https://gist.github.com/8840329.git 具体怎么用,可以看测试代码。

为什么要有这个东西

记得我第一份实习的时候,写SQL都是直接写,然后也有些变量名大小写不规范啥的,该`的也没有。到了现在这家公司的时候,SQL是不允许直接拼写,除非特殊情况。有些同学可能觉得封不封都无所谓,写快代码的时候,倒是无所谓。否则,最好简单封下,像ORM那种就算了。否则要整天计较拼字符串的脏活。封装起来多好看嘛。

实现

公司直接封装是十分ugly的,而且支持很弱,比如写((lv=1) OR (lv=2)) AND (vip=1 OR vip=2)这种需要优先级,就没有法子,也幸好没写过那么复杂,XDDD。如果用s表达式,或许就不存在这种问题。

后来知道Sqerl,它又是基于ErlyWeb's ErlSQL的,看起来很不错。不过,有些东西我不需要。所以我在它的基础上面去掉了safe的支持和子查询,safe其实只要有字符串,没办法safe的,所以何必呢。比如nickname之类,作者的原意或许是是支持自定义的WHERE,我觉得挺好,但是说是不safe,有点那啥,并引入不必要的复杂性。简单的单元测试覆盖,写这种测试太烦了,所以点到而止,出bug再看看了。

设计上,应该是支持SQL语法,日后有需要再扩展。

代码

erl_mysql.erl

单元测试 erl_mysql_tests.erl

emysql与erlang-mysql-driver的pool模型

erlang-mysql-driver的pool

erlang-mysql-driver本身说是有pool(start_link再connect,mysql_dispatcher维护的是进程pid和emysql不一样),但实际上等于没有。因为一般fetch的时候call_server({fetch, PoolId, Query}, Timeout),然后mysql_dispatcher只有一个,这样大家都往里面发消息,然后等,这样发消息的进程太多的话,就会出现timeout的问题。尽管mysql_dispatcher背后是多进程,这样是没有意义的。大家可以去mysql_conn的send_msg(Pid, Msg, From, Timeout)加上io:format("~p~n", [{Pid, Msg, From, self()}]),看看输出什么就明白了。

那如何在erlang-mysql-driver使用pool呢?正确做法是use mysql_conn stand-alone,然后配合poolboy(https://github.com/devinus/poolboy),详细做法可以翻下https://github.com/ChicagoBoss/boss_db 的db启动和fetch部分。这里不详述了,因为我现在不用这个做法了,毕竟我不需要像boss_db那样牛(蛋)逼(疼),封装了常见的数据库作为model,所以需要poolboy来配合。其实内部一坨屎,难以扩展。

emysql

这个项目是2009年,算是比较新的,自带真进程池。emysql的conn_mgr维护的时候db tcp连接socket,然后执行SQL的时候,先去emysql_conn_mgr取个可用的socket,否则根据参数选择排队还是放弃。排队那块类似poolboy的实现。取到可用socket,就spwan进程出去发包收包,等结果,真正的多进程。这是大致流程,具体细节就得看代码了。

最后

使用emysql就不用过多关心底层了,直接application:start和emysql:add_pool就可以开箱即用。

./2013 stop;./2014 start

公司年终总结工作内容写得比较详细,这里就简要总结下,公司那份最后就放点出来。

简要总结

2013年春节前,在佳音实习,游戏也上线,虽然是我第一款参与研发,但也是一款不敢拿来出来讲的,山寨机游戏,不过积累了不少开发经验,虽然不及现在的乐树多。集团年终抽奖,我中了1500的购物卡(一等奖,上面还有特等),可把我乐死,毕竟是我一个月实习基本工资。年前,已经考虑想辞职了,闲着蛋疼,主策也打算跑路。年后,半年的合同也到了,待遇也谈不拢,可能大公司的流程多吧,找个刚毕业的,起始工资有硬性规定。所以,拿完开门利是不久之后,就提交了辞职申请了。多多少少不舍得。

辞职之后,闲了两个月,主要是弄论文,论文最后拿了优秀论文,学院就十多个名额吧,我们专业就我一个,Orz,不过确实很水。

之后陆陆续续去一些公司面试,基本被鄙视,刚毕业,用Linux,用C++,不懂面向对象,不懂设计模式,非计算机专业等等。不过,最后还是去了一家团队不错的小公司吧,去面那家公司,是因为我有个同学在那呆一天,而且当时是在华师附近,就投下简历试试看。面试很简单,也比较实在吧,基本是问点常识问题。当时懒得再找了,拿到offer就算了。最囧的时候,拿到offer之后还没决定是做客户端还是服务端。

去到那边之后,决定去做服务端,用的语言是erlang,从那之后就一发不可收拾,现在我的erlang水平基本算是那边最高的,主要是同事没我那般折腾。刚进去的时候,也是走得比较多的弯路,本身项目代码是拿隔壁页游的来改,在垃圾代码上改,所以维护和加功能都很疼。同时,也没有技能培训,也没文档,遇到开发问题,基本靠问和自己悟,零零碎碎积累,最后才完全掌握整个破系统,所以走了不少弯路。

再后面,有新的同学加入,他们的erlang基本就是我教的了,XDDDD,不过项目代码不干净,所以有时候我也挺苦恼,经常开玩笑,你们以后别写出来这样的代码。有机会参与到重构整个服务器代码,也是从那个时候开始,掌握OTP整套东西,应用,监控树什么的。在之前只管写好函数,底层基本不用管(尽管很糟糕)。

2013年最大的收获,或许就是学会了erlang吧,暂时我是掌握得最深的语言了。

公司年终总结

通过半年时间,技能方面,学会使用erlang及相关开发工具,用erlang进行快速开发。

项目方面,半年来,我进行800多次(老烽火636,改版的烽火188次,拳皇21次)的代码提交。老烽火基本是修bug,完成策划的开发需求,部署新服,解决外网反馈bug,定期更新服务器,突发的运营活动技术支持等,相对比较琐碎,不过从这段时间积累宝贵的游戏开发经验和游戏运营经验(服务端)。

改版烽火是我在乐树成长得最快的时期,虽然只有不到两个月,虽然有烽火的服务器代码,但是核心模块我还是选择从零做起。改版的工作内容有,1、重构了网络模块,架构上,性能方面跟以前差不多,解包和粘包部分优化一些多余的运算,代码更可读;2、数据库底层模块,升级第三方依赖库,添加监控树,SQL语句拼接添加完整的测试代码,覆盖所有分支;3、相关工具的重写和替换,从数据库记录生成record头文件(执行时间3s以内,以前是3-5min,),proto协议用make生成,构建项目换成rebar(编译时间从15min缩到3min),机器人的重写,方便业务开发期间直接测试,不依赖客户端同学;4、业务开发方面,两个月不到,和两个同学完成发版预期的服务器所有开发内容,我单独从0开始完成了武将,副本(含活动),物品等基本功能,保证了性能和代码可读。5、项目进度管理,我根据实际情况会给另外两个同学分配相应的任务,回头想想分配也算是合理,根据实际能力和以游戏系统分类,在此之后,谁的坑谁负责,分工细化之后有利于缩短开发时间。

2014年

还是继续研究erlang吧,目测一年之后,我估计就转移视线了,现在RabbitMQ的代码30%吃透了。2014年,争取吃透5个开源项目吧(erlang本身开源的大项目不多),同时了解下所有常用的OTP模块的实现,目前只看了gen_server实现,最后就是VM本身了。

用erlang写的命令行工具

起因

看到RabbitMQ有个脚本rabbitmqctl,通过传入参数即可控制正在运行的服务,比如加入集群,修改帐户权限,查看集群状态等等。

erlang本身实现了rpc,所以通过起个进程与正在运行的erlang进程交互还是挺方便的。

看了下RabbitMQ传入参数的实现,确实有点局限性,因为是自己写的命令行参数解析,略蛋疼。

之前用python做迅雷离线下载的时候用过相关的解析args的库类,erlang可能也有,google了下,真有,叫getopt。地址 https://github.com/jcomellas/getopt 文档很详细,慢慢看吧。我好久没用getopt相关的东西都一看就明白了。

example code

将项目某个打印record信息的工具,稍微改下,作为说明例子用,test_getopt.erl。

这个文件很简单,就是通过传入参数,然后打印record相关信息。因为最近在弄mnesia结构升级的功能支持和测试,需要在代码写record的字段,所以需要有东西打印record,这样我好复制粘帖。

思路很简单,将init:get_plain_arguments()传到getopt:parse获取到{ok, {Opts, [Command | Args]}} ,然后根据这些信息做逻辑就可以了。

用erlang操作erlang,适当的话,可以提高开发效率。

对了,作者那份代码是不支持打印中文信息的,我提了patch,但是还没merge。我暂时fork出来了,有兴趣的同学可以去https://github.com/roowe/getopt 看看。

-module(test_getopt).
 
-export([start/0]).
 
-record(attri2,{
          attack = 0,
          hp = 0,          
          magic = 0,
          hit = 0,
          def2 = 0
         }).
-record(test_player,{
          id,
          type,
          sex2,
          lv,
          good_list2 = [],
          partner_list = [],
          attri2 = #attri2{}
         }).
-record(goods,{
          id,
          lv,
          attri
         }).
 
-record(partner,{
          id,
          lv,
          attri=#attri2{}
         }).
 
commands_desc() ->
    [{"field [<record_name>]", "record字段"},
     {"default [<record_name>]", "record默认值"}
     ].
opt_spec_list() ->   
    [
     {help, $h, "help", undefined, "显示帮助,然后退出"}
    ].
usage() ->
    getopt:usage(opt_spec_list(), "pr_record", "<command> [<args>]", commands_desc()),
    halt(1).
parse_arguments(CmdLine) ->
    case getopt:parse(opt_spec_list(), CmdLine) of
        {ok, {Opts, [Command | Args]}} ->
            {ok, {list_to_atom(Command), Opts, Args}};
        {ok, {_Opts, []}} ->
            no_command;
        Error ->
            io:format("Error ~p~n", [Error]),
            no_command
    end.
start() ->
    {Command, Opts, Args} =
        case parse_arguments(init:get_plain_arguments()) of
            {ok, Res}  -> 
                Res;
            no_command ->
                usage()
        end,
    %% The reason we don't use a try/catch here is that rpc:call turns
    %% thrown errors into normal return values
    case catch action(Command, Args, Opts) of
        ok ->
            io:format("...done.~n", []),
            halt(0);
        {ok, Info} ->
            io:format("...done (~p).~n", [Info]),
            halt(0);        
        Other ->
            print_error("~p", [Other]),
            halt(2)
    end.
print_error(Format, Args) -> 
    fmt_stderr("Error: " ++ Format, Args).
 
fmt_stderr(Format, Args) -> 
    io:format(standard_error, Format ++ "~n", Args).
 
 
action(field, Args, _) ->
    lists:foreach(fun(RecordNameStr) ->
                          RecordName = list_to_atom(RecordNameStr),
                          io:format("(~w) -> ~w;~n", [RecordName, get_record_fields(RecordName)])
                  end, Args),
    ok;
action(default, Args, _) ->
    lists:foreach(fun(RecordNameStr) ->
                          RecordName = list_to_atom(RecordNameStr),
                          %% Fields = get_record_fields(RecordName),
                          %% ValueList = lists:zip(Fields, lists:nthtail(1, tuple_to_list(get_record_default(RecordName)))),
                          io:format("(~w) -> ~w;~n", [RecordName, get_record_default(RecordName)])
                  end, Args),
    ok;
action(Command, Args, Opts) ->
    io:format("Command: ~p Args: ~p Opts: ~p~n", [Command, Args, Opts]),
    invalid_command.
 
-define(RECORD_FIELDS(TableName), record_info(fields, TableName)).
get_record_fields(goods) ->
    ?RECORD_FIELDS(goods);
get_record_fields(test_player) ->
    ?RECORD_FIELDS(test_player);
get_record_fields(partner) ->
    ?RECORD_FIELDS(partner);
get_record_fields(attri2) ->
    ?RECORD_FIELDS(attri2).
 
-define(RECORD_DEFAULT(RecordName), #RecordName{}).
get_record_default(goods) ->
    ?RECORD_DEFAULT(goods);
get_record_default(test_player) ->
    ?RECORD_DEFAULT(test_player);
get_record_default(partner) ->
    ?RECORD_DEFAULT(partner);
get_record_default(attri2) ->
    ?RECORD_DEFAULT(attri2).

启动脚本,如果你们ebin有专门的目录,将那个路径加到-pa进去,我这是演示,所以在/tmp了。

[20:18:51] roowe@Laptop-Y400 /tmp$ cat test_getopt
#!/bin/bash
erl -pa ~/open_source/getopt/ebin/ -noinput -hidden -name pr_record$$@127.0.0.1 -boot start_clean  -s test_getopt -extra "$@"

效果如下:

[20:15:19] roowe@Laptop-Y400 /tmp$ ./test_getopt field test_player goods
(test_player) -> [id,type,sex2,lv,good_list2,partner_list,attri2];
(goods) -> [id,lv,attri];
...done.
[20:15:50] roowe@Laptop-Y400 /tmp$ ./test_getopt default test_player goods
(test_player) -> {test_player,undefined,undefined,undefined,undefined,[],[],{attri2,0,0,0,0,0}};
(goods) -> {goods,undefined,undefined,undefined};
...done.
[20:16:01] roowe@Laptop-Y400 /tmp$ ./test_getopt --help
Usage: pr_record [-h]  []

  -h, --help               显示帮助,然后退出
  field []    record字段
  default []  record默认值

RabbitMQ之priority_queue

整体hold不住RabbitMQ,所以开始一一看基本的数据结构的设计。

erlang自带的只有队列,实现是用两个list,以前没考虑过用链表实现队列,看了实现,确实挺不错,复杂度是n。以前用c的时候做题,需要队列的时候,就申请个100W的数组,记录下HEAD,然后LastIndex,取完一个HEAD,就++,插入就arr[LastIndex++]=v。

RabbitMQ的gen_server2消息是做了cache,然后消息是按照优先级来选取处理的,所以需要一个优先队列的基础数据结构,他们就参考queue.erl的实现,自己搞了个,而且还维护了len,所以普通队列也可以用这个,某些操作的效率比自带的高,比如len。

priority_queue的实现和我们命令式语言有点不一样,复杂度各方面会对堆实现的高,但基本够用就算了。详情就看代码把,也不是很长。完整的数据结构是{pqueue, [{priority, queue}…]},queue就是{queue, [], [], 0},复合起来搞。

游戏模块协议设计浅谈

今天,和好基友吃饭,问了下他们现在的协议设计是怎么搞的,原来还是和以前一样,那我就告诉他我们现在是怎么搞,他也听明白了。不过我还是写下总结,方便其他人参考下。

我们的数据结构操作无非就是CRUD(http://en.wikipedia.org/wiki/Create,_read,_update_and_delete), 同样,通信协议也可以这么搞。我还是先讲下我过去是采用什么办法吧。

过去使用的

首先,我这里不涉及具体的协议序列化,有人用自己搞的序列化(各种二进制),有人用json,有人用xml,有人用pb,有人用msgpack,等等。我们项目目前是用pb。

我实习以及公司这边页游,我参与的手游的老代码的协议设计是协议号N请求,然后是协议号N回复,然后需要什么信息就在协议号N回。

比如装备强化一级要扣若干coin,跟等级挂钩,那么客户端发个x协议带装备id给服务器,服务器就进行强化的逻辑完之后,然后需要和客户端同步数据,装备等级和属性变更,以及玩家剩余coin,这些信息会塞在协议x的结构里面。

message Strengthen{
    required int32 id = 1;
}
message StrengthenAck{
    required int32 ret = 1;
    required int32 attack = 2;
    required int32 lv = 3;
    required int32 player_coin = 4;
}

大概就像上面那样的消息结构(简略版,实际肯定不会那么少字段),并约定ret=0才是操作成功,否则就是error_code,而且我实习error_code不同协议意义还不一样,现在想起来,蛋蛋好疼。然后,这里会操作player_coin吧。好,我们继续举另外的例子。

再假设,我们有个coin商城,消耗coin就可以获得物品,协议大致如下。

message Shop{
    required int32 id = 1;
}
message ShopAck{
    required int32 ret = 1;
    required int32 player_coin = 2;
}
客户端只需要发给id过来,服务器就知道买哪件,然后扣coin,再告诉客户端,你的coin现在是多少。和上面是一样的操作。

这个时候就会有两条协议去更改player_coin了,其实都是同样的意思,仅仅coin变了,哪天出了bug,也相对难排错,关键是冗余了,而且ret也是冗余的,客户端需要一堆if ret。所以这种硬编码式有点不那么科学,不过还是很多团队这么搞,他们觉得这样搞,可能包会比较小。

现在使用的

现在使用的策略跟CRUD原则很接近,我们用简单的装备系统举例。

message Equip{
    optional int32 id=1;
    optional int32 lv=2;
    optional int32 attack=3;
    optional int32 defense=4;
}
message EquipUpdate{
    repeated Equip update_list=1;
    repeated Equip delete_list=2;
}
message Error{
    optional int32 error_code=1;
}
// 9000 null Error
// 15000 获取装备列表 null  EquipList
// 15001 统一更新协议 null EquipUpdate
// 15002 强化 Equip的id  正常回15001,出错回9000
// 15003 进阶 Equip的id  正常回15001,出错回9000
// 15004 卖出 Equip的id  正常回15001,出错回9000
15000是上线请求的,相当Read,然后客户端会做个cache存起来,然后我打开装备界面,关闭界面,再打开,如果中间没有数据变更,就很快的,因为不需要通信,老的方法,一打开就请求了,所以数据一多体验会差点。

15001是服务器给客户端,客户端是不会主动请求的,相当Update。客户端收到这个消息,如果是delete list的话,就统一删掉,在update list的话,那么检查cache有这个数据不,没有就add,有就update。

15002是我们之前谈到的强化,会将id放在Equip里面发给来,服务器也就从里面取,强化ok了就回15001回去,说这个Equip属性变成啥样了,coin什么的暂时在这不考虑,这是另外一条玩家属性刷新协议回,出错就回9000,而且里面的error_code是通用的,比如X协议coin不够,Y协议也是coin不够,那么回的error_code是同个,我们定义了宏表示。这样就避免以前每个协议带ret,而且意义还不一样的问题。

下面几条也是类似的。

主要秘密就是属性15001刷新那个,我们用到google optional这个特性,我们是不会将没有变更的数据发过去,这个时候客户端只需要merge下数据就可以了。具体性能相关没测试,目测不会首先成为瓶颈。

介绍如下。

If any of your elements are optional, the encoded message may or may not have a key-value pair with that tag number. Normally, an encoded message would never have more than one instance of an optional or required field. However, parsers are expected to handle the case in which they do. For numeric types and strings, if the same value appears multiple times, the parser accepts the last value it sees. For embedded message fields, the parser merges multiple instances of the same field, as if with the Message::MergeFrom method – that is, all singular scalar fields in the latter instance replace those in the former, singular embedded messages are merged, and repeated fields are concatenated. The effect of these rules is that parsing the concatenation of two encoded messages produces exactly the same result as if you had parsed the two messages separately and merged the resulting objects. That is, this:

MyMessage message;
message.ParseFromString(str1 + str2);

这种做法已经在我们内部成为标准了,有兴趣的同学可以参考下。

RabbitMQ源码之dtree.erl

dtree.erl

代码里面一堆gb_trees和gb_sets的操作,也算是学习gb_trees和gb_sets的操作的典范。

这是双键的数据结构,若干条如下的记录。

%% +----+--------------------+---+
%% | PK | SK1, SK2, ..., SKN | V |
%% +----+--------------------+---+

大概这样的数据结构主要是用来处理如下的需求,每个消息要接收之后要存储和转发到不同的地方。大概的数据结构就是{MsgId, QueueSet, Msg}。

先讲take的相关的接口,这个接口有take(PKs, SK, {P, S})和take(SK, {P, S}),前者可以理解成从{P, S}取回队列属于SK并且在MsgId在PKs里面的[{MsgId_1, Msg_1}, …,{MsgId_n, Msg_n}],后者可以理解成从{P, S}取回队列属于SK的[{MsgId_1, Msg_1}, …,{MsgId_n, Msg_n}]

接着还有个take_all(SK, {P, S}),就是通过SK取出所有的PKs,然后根据PKs返回所有。

上面的take实际是等于select_delete的。

知道查询,就看下insert,存储那块有两个gb_trees,一个维护Pri,一个维护SecondKey映射到PriKeys,PriKeys由gb_sets存储,说起来基本就这么简单。

如果不明白,可以看下相关阅读3那份文档,看完也大概知道这结构究竟是为什么需求服务的。

我一开始也觉得想设计简单的话,像{Pri, Second, Msg}一个信息要Len(QueueList)条,最后变成了Len(QueueList_1)+…+Len(QueueList_n),n是Len(Pri)。如果是上面的数据结构的话,Len(Pri)+Len(所有QueueList)。数据会有很多冗余,性能也上不去。