dexscript / design

DexScript - for Better Developer EXperience
http://dexscript.com
Apache License 2.0
4 stars 0 forks source link

Actor 序列化设计 #28

Open taowen opened 5 years ago

taowen commented 5 years ago

Actor 协程序列化不是仅仅把一个函数的stack frame序列化下来这么简单

反序列化之后咋继续?

function Order() {
  Step1()
  Step2() // serialize here
  Step3()
}

当我们反序列化stack frame的时候,要继续执行这个协程,协程需要知道Promise的返回值。例如

function Order() {
  Step1()
  returnValue := Step2() // serialize here
  Step3(returnValue)
}

继续执行的时候,Step2() 的返回值是什么?本质上来说,反序列化之后,要继续执行这个Order函数,需要调用一个函数

task.Resolve("this is the return value")

通过调用这个函数,把Promise的返回值用参数的方式提供,然后继续执行。但是问题不仅仅是这么简单,一个流程可能因为多个Promise同时被阻塞。例如

function Order() {
  promise1 := new Step1_1()
  promise2 := new Step1_2()
  await {
  case result1 := <- promise1 { /* do something */ }
  case result2 := <- promise2 { /* do something */ }
  }
}

这个时候继续执行是走result1的分支,还是走result2的分支?所以本质上,我们序列化的不是Order本身,而是序列化了一个task,这个task对应了一个对方在等待的promise。然后task被反序列化之后,resolve这个task,从而使得promise被满足,从而使得actor可以继续。

function Helper() {
  await {
  case Step1(): string {
    // Step1 这个 task 代表了对方正在等待的东西
   EncodeTask(Step1)
  }}
} 
function Order(helper: Helper) {
  promise := new helper.Step1()
  <-promise // 等待 promise
}

反序列化Task,然后继续

var task: Task<string>
task = DecodeTask<string>(encoded)
resolve 'hello' -> task

多个平行宇宙

如果 task 被反序列化多次,每次 resolve 给的参数不一样怎么办?其实现在的业务流程开发也是一样的,同一个订单id,被两个web 服务器,同时加载到内存中。内存中的两份 order 就是两个平行宇宙,它们的流程可以独立演进。平行宇宙在持久化到数据库的时候要合并到一条主线上,这个时候就需要对冲突进行仲裁。语言只需要提供 actor 的序列化和反序列化,持久化和基于持久化的冲突仲裁是数据库要解决的问题。

最简单的仲裁方式就是乐观锁。在表上加一个version字段,读取的时候记得基于某个version,保存的时候检查一下version没有变化再更新。

UPDATE xxx SET column1 = 'value1' WHERE version='loaded_version'

更高级一点的问题是幂等性如何保证。简单的业务的幂等,本身逻辑上就幂等,像设置某个字段。复杂业务的幂等,依赖于流水记录和流水号。这个情况下就需要新增流水和保存actor状态,两个数据库表操作在一个事务里完成。这么复杂的数据库操作怎么用 actor 序列化来表达?

逻辑上,actor状态分成三个部分:

recent_transactions + archived_transactions 就是全量的流水。我们把 state + recent_transactions 合并保存到 actor 的表里,这样就是一个单表操作的普通事务。然后由独立的 gc 进程把流水从 recent_transactions 往独立的 archived_transactions 流水表里搬家。这个搬家本身不需要数据库事务就可以完成。

所以理论上来说,语言本身还是只需要提供序列化和反序列化能力就可以。事务和幂等这些需求都可以交给基础设施来解决。

反射

元编程通过读取类型信息,产生 interface 和 function。例如给每个类生成一个 encode 方法。

序列化一切?

序列化一切肯定会有问题的。首先不是运行时vm里所有的东西,都是要被序列化的。我们仅仅需要序列化task这个对象能够引用到的对象。那么肯定对象图就需要切割,比如你reference了一个用户,只是用 user id 做了一个引用。这个用户背后是通过 RPC 调用的服务,所以序列化的时候只是序列化了 user id,而不是用户这个对象。

但是如果要序列化,那么谁来提供序列化这个能力。这个“东西”本身是不是要被序列化掉?类似的问题是序列化local variable的时候,global variable怎么办?类似的问题是 pure function 怎么表达 io 操作。明显有一些对象,不是可序列化 state 的一部分。

首先全局变量容易造成远距离的隐式依赖。但是没有全局变量,又很不方便。我们引入 context,就是 golang 里的 context 参数。

function Order(ctx: Context, productId: int64) {
   // ...
   ctx.Save()
  // ..
}

利用 ctx 对象,我们可以注入上下文。但是因为 ctx 总是做为第一个参数传递,所以实际函数调用的时候可以省略掉

function Order(productId: int64) {
   // ...
  $.Save() // $ 代表 context 参数
  // ...
}

这个 $ 变量本质上就是函数的一个参数。只是免得你每次都手工传,会自动传递给你调用的其他函数。这样我们就可以把 $ 变量特殊处理,不放入序列化的范围。反序列化的时候,$ 的值使用调用resolve时的上下文里的 $。相当于全局变量。

这个 context 当然也应该是 copy on write 的,通过创建一个新的 context 对象来修改。这个就额外引申出了怎么表达struct,怎么复制struct的问题

上下文 $ 是一个 Map 么?

Golang 的上下文就是一个类似Map的东西,无schema。Golang还有全局状态,更糟糕,直接依赖于具体实现,而不是接口。理想的情况是上下文是一个 interface,表明我希望外面给我提供的 SPI 是什么。

interface :: {
  $: Context
  SomeAPI()
  AnotherAPI(): string
}

interface Context {
  ContextKey1: string
  ContextKey2: string
}

只要在这个包里的function,它都依赖于 $ 这个特殊参数,它需要实现这个 interface。

总结

Actor 序列化不需要和数据库绑定,也无关数据库事务。但是还是需要语言提供几个特性来方便支持。