ShowMeBug是一款远程面试工具,双方可以通过在线面试板进行实时交流。所以关键的技术点就是“实时同步”。关于实时同步,ShowMeBug采用了以下技术。OT转换算法从本质上讲,ShowMeBug的核心是多人同时在线实时编辑,这也是难点所在。由于网络原因,操作可能会异步到达、丢失或与其他操作冲突。仔细想想,这是一个复杂的问题。经过研究,用户体验最好的方式是OT转换算法。该算法最早由C.Ellis和S.Gibbs于1989年提出,目前被quip和googledocs使用。OT算法允许用户自由编辑任意行,包括冲突操作,在不加锁的情况下也能很好的支持。其核心算法如下:文档操作统一分为以下三种操作(Operation):retain(n):保留n个字符insert(s):插入字符串sdelete(s):删除字符串s,然后删除clientRecord与服务器的历史版本,每次操作经过一定的转换后推送到另一端。转换的核心是S(o_1,o_2)=S(o_2,o_1)也就是说,将并发的操作进行转换合并,形成一个新的操作,然后应用到历史版本上,实现无锁同步编辑.下图演示了相应的运算转换过程。https://daotestimg.dao42.com/ipic/070918.jpg该算法难点在于分布式实现。客户端和服务端都需要记录历史,保持一定的顺序。还需要进行转换算法处理。OTRails端的处理本质上是基于websocket的算法应用。所以我们毫无疑问地选择了ActionCable作为它的基础。认为它应该为我们节省很多时间。其实,我们错了。ActionCable实际上与NodeJS版本的socket.io相同。它没有任何可靠性保证。做一些聊天工具是可以的,或者做消息通知允许漏推甚至重复推送也是可以的。但是像OT算法这么强的要求是行不通的。由于网络传输的不可靠性,我们必须按顺序处理每个操作。所以首先,我们实现了一个mutex,也就是我们为某个面试板准备了一把锁,同时只能进行一个操作。锁使用的是Redis锁。实际情况如下:defunlock_pad_history(lock_key)logger.debug"\[padable\]unlock(lock\_key:#{lock\_key})..."old\_lock\_key=REDIS.get(\_pad\_lock\_history\_key)ifold\_lock\_key==lock\_keyREDIS.del(\_pad\_lock\_history\_key)elselog="\[FIXME\]unlock\_pad\_historyexpired:lock\_key=#{lock\_key},old\_lock\_key=#{old\_lock\_key}"logger.error(log)e=RuntimeError.new(log)ExceptionNotifier.notify\_exception(e,lock\_key:lock\_key,old\_lock\_key:old\_lock\_key)endend#为防止死锁,锁的时间为5分钟,超时自动解锁,但在解锁时会出现异常deflock_pad_history(lock_key)returnREDIS.set(\_pad\_lock\_history\_key,lock\_key,nx:true,ex:5\*60)enddefwait_and_lock_pad_history(lock_key,retry_times=200)total\_retry\_times=retry\_times而!lock\_pad\_history(lock\_key)sleep(0.05)logger.debug'\[padable\]locked,waiting50ms...'retry\_times-=1raise"wait\_and\_lock\_pad\_history(in#{total\_retry\_times\*0.1}s)#{lock\_key}failed"ifretry\_times==0endlogger.debug"\[padable\]lockingit(lock\_key:#{lock\_key})..."end服务器的并发控制完成后,client通过“StateQueue”技术逐条排队并释放操作记录,核心如下:{constructor(outstanding_history){this.outstation\_history=outstanding\_history}sendHistory(channel,history){returnnewPadChannelAwaitingWithHistory(this.outstanding\_history,history)}receiveHistory(channel,history){returnnewPadChannelAwaitingConfirm(pair\_history)}\[0\])}(channel,history){if(this.outstanding\_history.client\_id!==history.client\_id){thrownewError('confirmHistoryerror:client\_idnotequal')}返回padChannelSynchronized}}classPadChannelAwaitingWithHistory{sendHistory(channel,history){letnewHistory=composeHistory(this.buffer\_history,history)returnnewPadChannelAwaitingWithHistory(this.outstanding\_history,newHistory)}}letpadChannelSynchronized=newPadChannelSynchronized()exportdefaultpadChannelSynchronized上面实现了一个排队发送的场景另外,我们设计了一个PadChannel专门管理与服务端通信的Events,维护历史状态,处理断开和重传,操作转换和验证。定义自己的历史(history)协议解决了编辑器协调的问题,这才是真正问题的开始。每一次“代码运行”、“编辑”、“清除终端”、“第一次同步”都是需要记录的历史操作。因此,ShowMeBug定义了如下协议:#包含以下内容:edit(更新编辑器内容)、run(执行命令)、clear(清除终端)、sync(同步数据)#select(光标)、locate(定位)#历史格式如下:##{#op:'run'|'编辑'|'选择'|'定位'|'clear'#id:id//全局唯一操作自增id,前端第一次传入时为null,server填写,如果返回为空,则表示拒绝写入此历史#version:'v1'//数据格式版本#prev_id:prev_id//JS端生成历史时上次从服务器收到的id,用于标识操作顺序#client_id:client_id//历史的唯一标识由客户端生成#creator_id:creator_id//运营商的用户id,为了安全,前端第一次传入时为null,由中台填充#event:{//op在编辑时,记录编辑器OT转换后的数据,看这里:https://github.com/Aaaaash/bl...#[length,"string",length]#//当op为select时,记录theeditorSelectthearea(includingthecursor)#}#snapshot:{#editor_text:''//记录当前编辑器内容的快照,已填充由服务器#language_type:''//记录当前编辑器的语言类型#terminal_text:''//记录当前终端快照#}#}#created_at:created_at//生成时间值得注意的是client_id是一个客户端生成的8位随机码,用于去重和与客户端的ACK确认。id是redis在server端生成的自增id,client会根据这个来判断history是否是新的。prev_id用于记录操作转换时需要转换操作的历史队列。事件是最重要的操作记录。我们用OT转换数据存储,比如:[length,"string",length]通过上面的设计,我们覆盖了面试板的所有操作细节,从而实现多人面试的实时同步,自动化面试题与面试语言同步,操作回放等核心功能。限于篇幅总结,这里只提到ShowMeBug的核心技术,更多细节我们后续会继续分享。ShowMeBug目前承载了3000条面试记录,成功支撑了大量实战面试官的面试,可靠性得到进一步保障。有两个重要的编程范式值得考虑:如何在不受信任的链路上设计有序可靠的传递协议,关键是明确定义协议数据和处理异步事件。如何平衡研发效率和稳定性的关系,比如实现busy-wait锁,允许某些原因失败,但妥善处理用户提示和重试。既高效完成功能,又不影响用户体验。ShowMeBug(showmebug.com)让你的技术面试更有效率,帮助你找到你想要的候选人。
