Building Microservices (Part 3)
Reading notes on Building Microservices by Sam Newman
Chapter 5 Splitting the monolith
Splitting existing large codebase
Identify the seam
Rather than identifying the “seams” simply for the sake of cleaner code, we identify the service boundary based on domain concepts.
Big-Bang Approach?
Not really, separate services out one at a time. When there are features needed.
Create a micro-service right away?
Not really, create a package first. Then discover the dependencies in the whole system using some analytic tool. Make sure the “paths” are similar to the real world “interactions” between teams/divisions. If there are straight dependencies between packages that are not very likely to happen in real world, we might have identified the wrong boundaries. Try again.
Let’s start with database
Firstly, for each bounded context, write a repository layer. This will help us identifying which services are using which parts of the database (like which tables in relational db, or which collections in document-based db).
Then, use tools like SchemaSpy to have a graphical representation on the database-level relationship & constraints among table.
After that we’ll split the database. The following sections show some examples.
Example: Splitting foreign key relationships
Supposed a Ledger row has many Catalog rows. And the Finance code will read both the Ledger and Catalog tables. To break it:
- Remove the access from Finance service to Catalog table
- Add an endpoint in Catalog service, then the Finance service fetches data from it
- Good: lifecycle events of each Catalog remains in its service
- Bad: foreign key constraint is removed. Service-level instead of database-level will have to handle the business logic
- e.g. if there’s an Order table, say when a row of Catalog is removed, Order service will need to update its table accordingly
Example: Splitting shared static data
Usually they are static data like country code, and it is stored in a single table, accessed by multiple services. The static data is unnecessarily coupled with the domain services. Separate it into:
- a config file, or
- its own service
Usually the former will do the job, the latter is for cases that there are complex rules with the static data.
Example: Splitting shared mutable data
Multiple services are reading from / writing to the same table. That table usually represents a separate domain concept, so we can create a new service responsible for handling its lifecycle events.
In this example, the purchase record table can indeed be involved in a newly created service named “Order”, which will be responsible for handling order creation, update, etc.
Example: Splitting shared entity
Sometimes multiple services are accessing the same table, but it turns out that the same entity actually means different thing in each bounded context. In this case we can split the table, then each service handling its own entity.
In this example, the same entity “Item” actually means “Catalog item” in the Catalog service for displaying, and “Stock item” in the Warehouse service for counting.
Stage the split
Again, we won’t adopt the big-bang approach of splitting the database into separate services then deploying in one go. Instead we do it step by step.
We are testing out 2 things
- foreign key relationships
- performance
We want to make sure that the db-level constraints have successfully been migrated to/implemented in the relevant micro-services. Also, as we turned one db call inside a monolith into multiple API calls between services, there will be performance implications. We want to check if it is still “fast enough” to serve our customers.
Using this staging approach allows us to revert our changes easier(i.e. going back to use single schema) if things go wrong because the service is still monolithic.
Transactions
Suppose a customer created an order, the Order table and Picking table need to be updated. The diagrams below show the transaction(s) before and after the split.
Suppose “insert picking record” failed. In a single database, no changes occur in neither tables (ACID relational db). In the distributed case, the following shows possible solutions
- try again in the Warehouse service (eventual consistency)
- undo the work at Customer service (compensating transaction)
2 services are relatively easy to handle. Things become out of control when there are 4/5/6 services and so on. In latter case, have a “transaction manager” to orchestrate the operations by using
- 2-phase commit (each service votes whether to commit or not, if one or more service rejects, abort the whole operation)
BUT
- it assumes the commit in each service will work. So it is not fool-proof
- manager will lock up resources in relevant services during a transaction which inhibits scaling
To split or not to split ? Determine whether we really need a single transaction / absolute consistency.
- If no, go for eventual consistency (more on Chapter 11)
- If yes, try very hard not to split
- If split is necessary, create an abstraction for each set of distributed transactions, so orchestrating them will be easier
Special Concern — Reporting/Analytics
This common business functionality usually needs
- query (filtering, ordering, pagination), and
- detail
of multiple domain instances residing in multiple services. In monolithic world the setup can be like this:
Simple and sweet, but the downsides are:
- schema in reporting db coupled with primary one, so changes in business logic code will break the reporting code
- schema design that is good for the app may not be good for the reporting service
- db type of primary and reporting ones must be the same (i.e. can’t have relational primary db and document-based reporting db, for example)
In the micro-services world, the reporting service fetches required data from the relevant services.
Obviously there will be performance issue:
- Want customer behaviour in last 24 hours ? Need to query data from customer, order, and finance services. Imagine it is a dashboard with graphs. It will be super laggy.
- How about caching the result ? While this might help, in analytics, each query is usually calling for “long-tail” data, so cache-miss will be often
- API endpoint design may fit app use but not analytics use. A “hacky” approach will be to add batch-query (e.g. query by IDs) endpoints
The above is a pull approach (getting data from other services). What about a push approach (data pump) ?
Performance is improved because now we reclaimed the db-level query. Downsides/some points to note:
- Yes the schema in each service will be coupled with central reporting db. Changes in Customer service schema need changes in reporting service as well.
- So each service team will also maintain the reporting service
- To de-couple them, create a new “mapper” service to do the scheduled transformation and data-pumping, co-maintained by the service teams and reporting team
- In each service, make the reporting function as an additional artifact, and maintain it as the same version with the main service code
If our micro-services communicate in an event-based manner, we can have “event data pump” as an alternative as well.
Here the “mapper” service subscribes to events coming from the Customer service, transforms the embedded message data into appropriate schema, then submits it to report db. Major benefit is that we can have realtime data analytics. Possible downsides
- all required data needs to be broadcasted
- not scalable if the size of data in each pump is getting large
A deeper discussion on creating an eventing system will be in Chapter 8.
Cost of changes
At the beginning of the article we mentioned that we prefer step-by-step approach to big-bang approach, because there’re costs of making changes. Compared to moving code within codebase, here are some high cost changes
- splitting database
- database rollback
- rewriting integration interfaces / API interfaces
So, step by step.
Chapter 6 Deployment
Extra care is needed to deploy multiple services. To be continued.