| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
A infinite scalable advertisement management system, baked with replicated advertisement business state machine, replicated log system, and fault recovery mechanism. Guaranteed the consistency and durability of the advertisement operation.
When I saw the requirements for this topic, I was wondering if a QPS (Queries Per Second) > 10,000 could be solved simply using a single Redis instance. So, I started thinking about this problem and came up with a more interesting solution. This solution involves using an in-memory database to address the issue, along with a Redis stream for handling log ordering, and PostgreSQL for persistence. As it's a local in-memory database, the read operations can be infinitely scaled using solutions like Kubernetes Deployment or docker compose --scale. However, write operations are still limited by the speed of max(redis, postgres), however, we can choose NoSQL database to achieve the higher write speed, and use Kafka to handle the log ordering and log replication as redis stream alternative(better consistency and durability). In my implementation, I've made every effort to ensure the system is fault-tolerant and consistent. If anyone notices any cases I haven't considered or areas that could be optimized, please feel free to point them out. Thank you!
The main components in my system design idea have five parts, which can correspond to the Servers in the above figure respectively.
flowchart TD
Request[R/W Request] -->|Load Balance| Instances[Dispatcher]
subgraph Instances["Instances"]
subgraph Instance1["API Instance 1"]
SM1[State Machine]
D1[Dispatcher] -.-> SM1
end
subgraph Instance2["API Instance 2"]
SM2[State Machine]
D2[Dispatcher] -.-> SM2
end
subgraph Instance3["API Instance 3"]
SM3[State Machine]
D3[Dispatcher] -.-> SM3
end
end
Scheduler["Asynq Scheduler"] -.->|Delete\nLog| Instances
Instances -.->|Schedule Delete\nat Ad End Time| Scheduler
Scheduler <-.->|Redis Baked| RedisStream
PG[(Postgres)] -.->|Update Log| RedisStream
RedisStream[("Redis / Redis Stream")] -.->|Subscribe Log| Instances
Instance1 -.->|Write/Delete Log| PG
Instance2 -.->|Write/Delete Log| PG
Instance3 -.->|Write/Delete Log| PG
For each instance, it is a state machine that can handle the advertisement CRUD operation and the range query operation. In the above diagram, it should use single-threaded to guarantee the read and write order. In Our Scenario, the consistency isn't the most important thing, so we can use Readers–writer lock to handle the concurrent read, the write operation is still single-threaded.
It is hard to implement a Linearizable Log System. so I can use Redis Stream to handle the log ordering and the log replication.
Use redis lock to prevent the concurrent write to postgres and redis stream
The state machine can be recovered from the snapshot, and the snapshot only modified if there is a new create, update, or delete operation. The snapshot can be stored in postgresql, and the recovery process can be done by the snapshot and the log to prevent the state machine need to replay all the log from the beginning. The concept is similar to the AOF and RDB in redis.
Since we didn't use the interval tree to handle the range query, we need to remove the outdated data from the in-memory database, so we need to use some scheduler to remove the outdated data from the in-memory database.
I choose the asynq to act as the scheduler
after multiple worker race for handling the delete task, the delete log would be also published to the redis stream, so the state machine can also handle the delete operation, this method also prevent the Restore operation from reading and serving stale data.
type Ad struct {
ID uuid.UUID `gorm:"type:uuid;primary_key" json:"id"`
Title string `gorm:"type:text" json:"title"`
Content string `gorm:"type:text" json:"content"`
StartAt CustomTime `gorm:"type:timestamp" json:"start_at" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05"`
EndAt CustomTime `gorm:"type:timestamp" json:"end_at" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05"`
AgeStart uint8 `gorm:"type:integer" json:"age_start"`
AgeEnd uint8 `gorm:"type:integer" json:"age_end"`
Gender pq.StringArray `gorm:"type:text[]" json:"gender"`
Country pq.StringArray `gorm:"type:text[]" json:"country"`
Platform pq.StringArray `gorm:"type:text[]" json:"platform"`
Version int `gorm:"index" json:"version"`
IsActive bool `gorm:"type:boolean; default:true" json:"-" default:"true"`
CreatedAt CustomTime `gorm:"type:timestamp" json:"created_at"`
}No leader, no follower, all instance(replica) are equal
the request id is for recognizing which client should return the response to.
We can use the redis sentinel mode to handle the redis high availability
After trying so many ways, I think the most robust, simple, and efficient way is to use sqlite as in-memory database. The performance is also good, the SQL read speed would be about 60000/s, However, the real query may be slower than ideal speed since the query is not simple as the benchmark query. But remember, our design can scale the read operation speed linearly to infinite, so the read speed in a single instance is not the most important thing.
Implement a func call GetNextIndexKey to determine the composited index order, the index with greater selectivity should be the leftmost index.
func (a Ad) GetNextIndexKey(currentKey string) string {
switch currentKey {
case "":
return "Age"
case "Age":
return "Country"
case "Country":
return "Platform"
case "Platform":
return "Gender"
default:
return ""
}
}graph TD;
Root(IndexNode: Root) -->|Country| US([IndexInternalNode: Country=US])
Root -->|Country| CA([IndexInternalNode: Country=CA])
US -->|Age| US_25([IndexLeafNode: Age=25])
US -->|Age| US_30([IndexLeafNode: Age=30])
CA -->|Age| CA_25([IndexLeafNode: Age=25])
US_25 -->|Ad| Ad_US_25_1([Ad1])
US_25 -->|Ad| Ad_US_25_2([Ad2])
US_30 -->|Ad| Ad_US_30_1([Ad3])
US_30 -->|Ad| Ad_US_30_2([Ad4])
US_30 -->|Ad| Ad_US_30_3([Ad5])
CA_25 -->|Ad| Ad_CA_25([Ad6])
type IndexNode interface {
AddAd(ad *model.Ad)
GetAd(req *model.GetAdRequest) ([]*model.Ad, error)
DeleteAd(ad *model.Ad)
}
type IndexInternalNode struct {
Key string // The key this node indexes on, e.g., "country", "age"
Children cmap.ConcurrentMap[FieldStringer, IndexNode] // The children of this node
}
func NewIndexInternalNode(key string) IndexNode {
return &IndexInternalNode{
Key: key,
Children: cmap.NewStringer[FieldStringer, IndexNode](),
}
}
type IndexLeafNode struct {
mu sync.RWMutex
Ads *sortedset.SortedSet // map[string]*model.Ad
}
func NewIndexLeafNode() IndexNode {
return &IndexLeafNode{
Ads: sortedset.New(),
}
}if interval tree is in use, it doesn't apply on time range query since the performance issue




provide a flexible API for the developer to define the index, but the performance reduce about 10%, move some coding complexity to time & space complexitytype GetAdRequest struct {
Age uint8 `form:"age" binding:"omitempty,gt=0"`
Country string `form:"country" binding:"omitempty,iso3166_1_alpha2"`
Gender string `form:"gender" binding:"omitempty,oneof=M F"`
Platform string `form:"platform" binding:"omitempty,oneof=android ios web"`
Offset int `form:"offset,default=0" binding:"min=0"`
Limit int `form:"limit,default=10" binding:"min=1,max=100"`
}Currently, the load test is only performed on the local machine, for a more accurate result, the load test should be performed distributedly, We can adopt the k6 operator to run the distributed load test in the kubernetes cluster, and the result should be analyzed by the Prometheus and Grafana
make install-k6
cp .env.example env.dev
make dev-up
make dev-migrate
make inject # inject the test data
make run-release # run the server
make k6 # run on another terminalhttps://dcard-backend-intern-2024.peterxcli.dev/coverage
| Back | FazBrowse Home | New Git URL |